diff --git a/.eslintrc.js b/.eslintrc.js index 4063bb42cf32ab..a81369b61c5f18 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,120 +44,7 @@ const restrictedImports = [ }, { name: 'lodash', - importNames: [ - 'camelCase', - 'capitalize', - 'castArray', - 'chunk', - 'clamp', - 'clone', - 'cloneDeep', - 'compact', - 'concat', - 'countBy', - 'debounce', - 'deburr', - 'defaults', - 'defaultTo', - 'delay', - 'difference', - 'differenceWith', - 'dropRight', - 'each', - 'escape', - 'escapeRegExp', - 'every', - 'extend', - 'filter', - 'find', - 'findIndex', - 'findKey', - 'findLast', - 'first', - 'flatMap', - 'flatten', - 'flattenDeep', - 'flow', - 'flowRight', - 'forEach', - 'fromPairs', - 'groupBy', - 'has', - 'identity', - 'includes', - 'invoke', - 'isArray', - 'isBoolean', - 'isEqual', - 'isFinite', - 'isFunction', - 'isMatch', - 'isNil', - 'isNumber', - 'isObject', - 'isObjectLike', - 'isPlainObject', - 'isString', - 'isUndefined', - 'keyBy', - 'keys', - 'last', - 'lowerCase', - 'map', - 'mapKeys', - 'mapValues', - 'maxBy', - 'memoize', - 'merge', - 'negate', - 'noop', - 'nth', - 'omit', - 'omitBy', - 'once', - 'orderby', - 'overEvery', - 'partial', - 'partialRight', - 'pick', - 'pickBy', - 'random', - 'reduce', - 'reject', - 'repeat', - 'reverse', - 'setWith', - 'size', - 'snakeCase', - 'some', - 'sortBy', - 'startCase', - 'startsWith', - 'stubFalse', - 'stubTrue', - 'sum', - 'sumBy', - 'take', - 'throttle', - 'times', - 'toString', - 'trim', - 'truncate', - 'unescape', - 'unionBy', - 'uniq', - 'uniqBy', - 'uniqueId', - 'uniqWith', - 'upperFirst', - 'values', - 'without', - 'words', - 'xor', - 'zip', - ], - message: - 'This Lodash method is not recommended. Please use native functionality instead. If using `memoize`, please use `memize` instead.', + message: 'Please use native functionality instead.', }, { name: 'reakit', @@ -200,6 +87,7 @@ module.exports = { extends: [ 'plugin:@wordpress/eslint-plugin/recommended', 'plugin:eslint-comments/recommended', + 'plugin:storybook/recommended', ], globals: { wp: 'off', @@ -245,6 +133,13 @@ module.exports = { ], }, ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + disallowTypeAnnotations: false, + }, + ], 'no-restricted-syntax': [ 'error', // NOTE: We can't include the forward slash in our regex or @@ -365,7 +260,7 @@ module.exports = { }, { files: [ 'packages/jest*/**/*.js', '**/test/**/*.js' ], - excludedFiles: [ 'test/e2e/**/*.js' ], + excludedFiles: [ 'test/e2e/**/*.js', 'test/performance/**/*.js' ], extends: [ 'plugin:@wordpress/eslint-plugin/test-unit' ], }, { @@ -375,6 +270,7 @@ module.exports = { 'packages/react-native-*/**/*.[tj]s?(x)', 'test/native/**/*.[tj]s?(x)', 'test/e2e/**/*.[tj]s?(x)', + 'test/performance/**/*.[tj]s?(x)', 'test/storybook-playwright/**/*.[tj]s?(x)', ], extends: [ @@ -394,6 +290,7 @@ module.exports = { { files: [ 'test/e2e/**/*.[tj]s', + 'test/performance/**/*.[tj]s', 'packages/e2e-test-utils-playwright/**/*.[tj]s', ], extends: [ @@ -404,6 +301,7 @@ module.exports = { tsconfigRootDir: __dirname, project: [ './test/e2e/tsconfig.json', + './test/performance/tsconfig.json', './packages/e2e-test-utils-playwright/tsconfig.json', ], }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c29a18dcee2e04..22404fc7c96ebe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ # Documentation /docs @ajitbohra @ryanwelcher @juanmaguitar @fabiankaegy @ndiego +/packages/interactivity/docs @juanmaguitar # Schemas /schemas/json @ajlende @@ -54,6 +55,9 @@ # Full Site Editing /packages/edit-site +# Interactivity API +/packages/interactivity @luisherranz @darerodz + # Tooling /bin @ntwb @nerrad @ajitbohra /bin/api-docs @ntwb @nerrad @ajitbohra @@ -78,13 +82,15 @@ /packages/prettier-config @ntwb @gziolo /packages/scripts @gziolo @ntwb @nerrad @ajitbohra @ryanwelcher /packages/stylelint-config @ntwb -/test/e2e @kevin940726 +/test/e2e @kevin940726 @Mamaduka +/test/php/gutenberg-coding-standards @anton-vlasenko # UI Components /packages/components @ajitbohra /packages/compose @ajitbohra /packages/element @ajitbohra /packages/notices @ajitbohra +/packages/nux @ajitbohra @peterwilsoncc /packages/viewport @ajitbohra /packages/base-styles /packages/icons diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1798e22778e386..fd63e5e2e5312e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,8 +12,8 @@ https://github.com/WordPress/gutenberg/blob/trunk/CONTRIBUTING.md --> ## Testing Instructions - - + + ### Testing Instructions for Keyboard diff --git a/.github/workflows/build-plugin-zip.yml b/.github/workflows/build-plugin-zip.yml index 9052f1689c9216..e8a65e69661501 100644 --- a/.github/workflows/build-plugin-zip.yml +++ b/.github/workflows/build-plugin-zip.yml @@ -56,7 +56,7 @@ jobs: github.event.inputs.version == 'rc' || github.event.inputs.version == 'stable' ) || ( - endsWith( github.ref, needs.compute-stable-branches.outputs.current_stable_branch ) && + startsWith( github.ref, 'refs/heads/release/' ) && github.event.inputs.version == 'stable' ) ) @@ -69,7 +69,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: token: ${{ secrets.GUTENBERG_TOKEN }} @@ -122,7 +122,7 @@ jobs: VERSION: ${{ steps.get_version.outputs.new_version }} run: | cat <<< $(jq --tab --arg version "${VERSION}" '.version = $version' package.json) > package.json - cat <<< $(jq --tab --arg version "${VERSION}" '.version = $version' package-lock.json) > package-lock.json + cat <<< $(jq --tab --arg version "${VERSION}" '.version = $version | .packages[""].version = $version' package-lock.json) > package-lock.json sed -i "s/${{ steps.get_version.outputs.old_version }}/${VERSION}/g" gutenberg.php - name: Commit the version bump to the release branch @@ -164,7 +164,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ needs.bump-version.outputs.release_branch || github.ref }} @@ -219,7 +219,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: fetch-depth: 2 ref: ${{ needs.bump-version.outputs.release_branch }} @@ -307,13 +307,13 @@ jobs: if: ${{ endsWith( needs.bump-version.outputs.new_version, '-rc.1' ) }} steps: - name: Checkout (for CLI) - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: path: main ref: trunk - name: Checkout (for publishing) - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: path: publish # Later, we switch this branch in the script that publishes packages. diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index c4fdafb422c9b6..c02731c057537e 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: fetch-depth: 1 diff --git a/.github/workflows/check-components-changelog.yml b/.github/workflows/check-components-changelog.yml index c0cb4894009a2a..26d14febcc41e4 100644 --- a/.github/workflows/check-components-changelog.yml +++ b/.github/workflows/check-components-changelog.yml @@ -20,7 +20,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@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/create-block.yml b/.github/workflows/create-block.yml index 8f76c072133d0c..d3b0efa0ce5d9e 100644 --- a/.github/workflows/create-block.yml +++ b/.github/workflows/create-block.yml @@ -20,11 +20,11 @@ jobs: strategy: fail-fast: false matrix: - node: ['14'] + node: ['16'] os: [macos-latest, ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index 39748288c4d662..157d670c09913b 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -23,11 +23,11 @@ jobs: strategy: fail-fast: false matrix: - part: [1, 2, 3, 4] - totalParts: [4] + part: [1, 2, 3] + totalParts: [3] steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node @@ -41,8 +41,8 @@ jobs: - name: Running the tests run: | - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % ${{ matrix.totalParts }} == ${{ matrix.part }} - 1' < ~/.jest-e2e-tests ) + npx wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + npx wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % ${{ matrix.totalParts }} == ${{ matrix.part }} - 1' < ~/.jest-e2e-tests ) - name: Archive debug artifacts (screenshots, HTML snapshots) uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 @@ -67,11 +67,11 @@ jobs: strategy: fail-fast: false matrix: - part: [1, 2] - totalParts: [2] + part: [1, 2, 3, 4] + totalParts: [4] steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node @@ -115,7 +115,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@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: trunk diff --git a/.github/workflows/enforce-pr-labels.yml b/.github/workflows/enforce-pr-labels.yml new file mode 100644 index 00000000000000..6dc9cb6b17add0 --- /dev/null +++ b/.github/workflows/enforce-pr-labels.yml @@ -0,0 +1,18 @@ +name: Enforce labels on Pull Request +on: + pull_request_target: + types: [labeled, unlabeled, ready_for_review, review_requested] +jobs: + type-related-labels: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: mheap/github-action-required-labels@v5 + with: + mode: exactly + count: 1 + labels: '[Type] Accessibility (a11y), [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] Performance, [Type] Project Management, [Type] Regression, [Type] Security, [Type] WP Core Ticket, Backport from WordPress Core' + add_comment: true + message: "**Warning: Type of PR label error**\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)." + exit_type: failure diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index e1da61b72e263b..633ea7135acc46 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -6,5 +6,5 @@ jobs: name: 'Validation' runs-on: ubuntu-latest steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - uses: gradle/wrapper-validation-action@v1 diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 7a5c523c8324b6..5c4c7b068108fe 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -32,11 +32,18 @@ jobs: WP_ARTIFACTS_PATH: ${{ github.workspace }}/artifacts steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node + - name: Install NVM + run: | + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + nvm -v + - name: Compare performance with trunk if: github.event_name == 'pull_request' run: ./bin/plugin/cli.js perf $GITHUB_SHA trunk --tests-branch $GITHUB_SHA @@ -59,13 +66,13 @@ jobs: - name: Compare performance with base branch if: github.event_name == 'push' # The base hash used here need to be a commit that is compatible with the current WP version - # The current one is debd225d007f4e441ceec80fbd6fa96653f94737 and it needs to be updated every WP major release. + # The current one is 34af5829ac9edb31833167ff6a3b51bea982999c and it needs to be updated every WP major release. # It is used as a base comparison point to avoid fluctuation in the performance metrics. run: | WP_VERSION=$(awk -F ': ' '/^Tested up to/{print $2}' readme.txt) IFS=. read -ra WP_VERSION_ARRAY <<< "$WP_VERSION" WP_MAJOR="${WP_VERSION_ARRAY[0]}.${WP_VERSION_ARRAY[1]}" - ./bin/plugin/cli.js perf $GITHUB_SHA debd225d007f4e441ceec80fbd6fa96653f94737 --tests-branch $GITHUB_SHA --wp-version "$WP_MAJOR" + ./bin/plugin/cli.js perf $GITHUB_SHA 34af5829ac9edb31833167ff6a3b51bea982999c --tests-branch $GITHUB_SHA --wp-version "$WP_MAJOR" - name: Compare performance with custom branches if: github.event_name == 'workflow_dispatch' @@ -77,18 +84,18 @@ jobs: - name: Archive performance results if: success() - uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # v3.1.1 + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: performance-results - path: ${{ env.WP_ARTIFACTS_PATH }}/*.performance-results.json + path: ${{ env.WP_ARTIFACTS_PATH }}/*.performance-results*.json - name: Publish performance results if: github.event_name == 'push' env: CODEHEALTH_PROJECT_TOKEN: ${{ secrets.CODEHEALTH_PROJECT_TOKEN }} run: | - COMMITTED_AT=$(git show -s $GITHUB_SHA --format="%ct") - ./bin/log-performance-results.js $CODEHEALTH_PROJECT_TOKEN trunk $GITHUB_SHA debd225d007f4e441ceec80fbd6fa96653f94737 $COMMITTED_AT + COMMITTED_AT=$(git show -s $GITHUB_SHA --format="%cI") + ./bin/log-performance-results.js $CODEHEALTH_PROJECT_TOKEN trunk $GITHUB_SHA 34af5829ac9edb31833167ff6a3b51bea982999c $COMMITTED_AT - name: Archive debug artifacts (screenshots, HTML snapshots) uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 diff --git a/.github/workflows/php-changes-detection.yml b/.github/workflows/php-changes-detection.yml new file mode 100644 index 00000000000000..2de5b573622815 --- /dev/null +++ b/.github/workflows/php-changes-detection.yml @@ -0,0 +1,100 @@ +name: OPTIONAL - Confirm if PHP changes require backporting to WordPress Core + +on: + pull_request: + types: [opened, synchronize] +jobs: + detect_php_changes: + name: Detect PHP changes + runs-on: ubuntu-latest + if: ${{ github.repository == 'WordPress/gutenberg' || github.event_name == 'pull_request' }} + steps: + - name: Check out code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + with: + fetch-depth: 0 + + - name: Get changed PHP files + id: changed-files-php + uses: tj-actions/changed-files@v37 + with: + files: | + *.{php} + lib/** + phpunit/** + + - name: List all changed files + if: steps.changed-files-php.outputs.any_changed == 'true' + id: list-changed-php-files + run: | + echo "Changed files:" + formatted_change_list="" + for file in ${{ steps.changed-files-php.outputs.all_changed_files }}; do + echo "$file was changed" + formatted_change_list+="
:grey_question: $file" + done + formatted_change_list+="
" + echo "formatted_change_list=$formatted_change_list" >> $GITHUB_OUTPUT + + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Create comment + if: steps.find-comment.outputs.comment-id == '' && steps.changed-files-php.outputs.any_changed == 'true' + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + + This pull request has changed or added PHP files. Please confirm whether these changes need to be synced to WordPress Core, and therefore featured in the next release of WordPress. + + If so, it is recommended to create a [new Trac ticket](https://core.trac.wordpress.org/newticket) and submit a pull request to the [WordPress Core Github repository](https://github.com/WordPress/wordpress-develop) soon after this pull request is merged. + + If you're unsure, you can always ask for help in the #core-editor channel in [WordPress Slack](https://make.wordpress.org/chat/). + + Thank you! :heart: + +
+ View changed files + ${{ steps.list-changed-php-files.outputs.formatted_change_list }} +
+ + - name: Update comment + if: steps.find-comment.outputs.comment-id != '' && steps.changed-files-php.outputs.any_changed == 'true' + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + + This pull request has changed or added PHP files. Please confirm whether these changes need to be synced to WordPress Core, and therefore featured in the next release of WordPress. + + If so, it is recommended to create a [new Trac ticket](https://core.trac.wordpress.org/newticket) and submit a pull request to the [WordPress Core Github repository](https://github.com/WordPress/wordpress-develop) soon after this pull request is merged. + + If you're unsure, you can always ask for help in the #core-editor channel in [WordPress Slack](https://make.wordpress.org/chat/). + + Thank you! :heart: + +
+ View changed files + ${{ steps.list-changed-php-files.outputs.formatted_change_list }} +
+ + - name: Update comment + if: steps.find-comment.outputs.comment-id != '' && steps.changed-files-php.outputs.any_changed != 'true' + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + + This pull request changed or added PHP files in previous commits, but none have been detected in the latest commit. + + Thank you! :heart: diff --git a/.github/workflows/publish-npm-packages.yml b/.github/workflows/publish-npm-packages.yml index a9b36ba7f98b5c..dd1f188e0ae2f5 100644 --- a/.github/workflows/publish-npm-packages.yml +++ b/.github/workflows/publish-npm-packages.yml @@ -30,13 +30,13 @@ jobs: environment: WordPress packages steps: - name: Checkout (for CLI) - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: - path: main + path: cli ref: trunk - name: Checkout (for publishing) - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: path: publish # Later, we switch this branch in the script that publishes packages. @@ -52,13 +52,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: - node-version-file: 'main/.nvmrc' + node-version-file: 'cli/.nvmrc' registry-url: 'https://registry.npmjs.org' - name: Publish development packages to npm ("next" dist-tag) if: ${{ github.event.inputs.release_type == 'development' }} run: | - cd main + cd cli npm ci ./bin/plugin/cli.js npm-next --ci --repository-path ../publish env: @@ -67,7 +67,7 @@ jobs: - name: Publish packages to npm with bug fixes ("latest" dist-tag) if: ${{ github.event.inputs.release_type == 'bugfix' }} run: | - cd main + cd cli npm ci ./bin/plugin/cli.js npm-bugfix --ci --repository-path ../publish env: @@ -76,8 +76,8 @@ jobs: - name: Publish packages to npm for WP major ("wp/${{ github.event.inputs.wp_version || 'X.Y' }}" dist-tag) if: ${{ github.event.inputs.release_type == 'wp' && github.event.inputs.wp_version }} run: | - cd main + cd publish npm ci - ./bin/plugin/cli.js npm-wp --wp-version=${{ github.event.inputs.wp_version }} --ci --repository-path ../publish + npx lerna publish patch --dist-tag wp-${{ github.event.inputs.wp_version }} --no-private --yes --no-verify-access env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/pull-request-automation.yml b/.github/workflows/pull-request-automation.yml index 56dce7a2825c9b..7bcfb9744aa22a 100644 --- a/.github/workflows/pull-request-automation.yml +++ b/.github/workflows/pull-request-automation.yml @@ -10,12 +10,12 @@ jobs: if: ${{ github.repository == 'WordPress/gutenberg' }} strategy: matrix: - node: ['14'] + node: ['16'] 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@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: trunk diff --git a/.github/workflows/rnmobile-android-runner.yml b/.github/workflows/rnmobile-android-runner.yml index d04f312b9521bb..7b0244b0ec5059 100644 --- a/.github/workflows/rnmobile-android-runner.yml +++ b/.github/workflows/rnmobile-android-runner.yml @@ -23,19 +23,19 @@ jobs: steps: - name: checkout - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Use desired version of Java - uses: actions/setup-java@3f07048e3d294f56e9b90ac5ea2c6f74e9ad0f98 # v3.10.0 + uses: actions/setup-java@cd89f46ac9d01407894225f350157564c9c7cee2 # v3.12.0 with: - distribution: 'temurin' - java-version: '11' + distribution: 'corretto' + java-version: '17' - name: Setup Node.js and install dependencies uses: ./.github/setup-node - name: Gradle cache - uses: gradle/gradle-build-action@6095a76664413da4c8c134ee32e8a8ae900f0f1f # v2.4.0 + uses: gradle/gradle-build-action@a4cf152f482c7ca97ef56ead29bf08bcd953284c # v2.7.0 - name: AVD cache uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 diff --git a/.github/workflows/rnmobile-ios-runner.yml b/.github/workflows/rnmobile-ios-runner.yml index ca634dc8a9c7a9..f7ee1821f3650e 100644 --- a/.github/workflows/rnmobile-ios-runner.yml +++ b/.github/workflows/rnmobile-ios-runner.yml @@ -23,7 +23,7 @@ jobs: native-test-name: [gutenberg-editor-rendering] steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node diff --git a/.github/workflows/stale-issue-gardening.yml b/.github/workflows/stale-issue-gardening.yml index 078108a182850e..0bdb1cfbf0cefd 100644 --- a/.github/workflows/stale-issue-gardening.yml +++ b/.github/workflows/stale-issue-gardening.yml @@ -33,12 +33,6 @@ jobs: only-labels: '[Type] Flaky Test' remove-stale-when-updated: true stale-issue-label: '[Status] Stale' - - name: 'Issues without recent updates that need confirmation' - message: "Hi,\nThis issue has gone 180 days without any activity. This means it is time for a check-in to make sure it is still relevant. If you are still experiencing this issue with the latest versions, you can help the project by responding to confirm the problem and by providing any updated reproduction steps.\nThanks for helping out." - days-before-stale: 180 - days-before-close: -1 - remove-stale-when-updated: false - stale-issue-label: 'Needs Testing' steps: - name: Update issues diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml index 3b457401560a8d..72b975522df821 100644 --- a/.github/workflows/static-checks.yml +++ b/.github/workflows/static-checks.yml @@ -22,7 +22,7 @@ jobs: if: ${{ github.repository == 'WordPress/gutenberg' || github.event_name == 'pull_request' }} steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Use desired version of Node.js uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 diff --git a/.github/workflows/storybook-pages.yml b/.github/workflows/storybook-pages.yml index e21c1f14d8be21..32c26652b3de0d 100644 --- a/.github/workflows/storybook-pages.yml +++ b/.github/workflows/storybook-pages.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: trunk @@ -23,7 +23,7 @@ jobs: run: npm run storybook:build - name: Deploy - uses: peaceiris/actions-gh-pages@64b46b4226a4a12da2239ba3ea5aa73e3163c75b # v3.9.1 + uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3.9.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./storybook/build diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 9c93b6b93d98dc..693543a81958fc 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -28,10 +28,10 @@ jobs: strategy: fail-fast: false matrix: - node: ['14'] + node: ['16'] steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node @@ -85,7 +85,6 @@ jobs: fail-fast: true matrix: php: - - '5.6' - '7.0' - '7.1' - '7.2' @@ -98,7 +97,7 @@ jobs: wordpress: [''] # Latest WordPress version. include: # Test with the previous WP version. - - php: '5.6' + - php: '7.0' wordpress: ${{ needs.compute-previous-wordpress-version.outputs.previous-wordpress-version }} - php: '7.4' wordpress: ${{ needs.compute-previous-wordpress-version.outputs.previous-wordpress-version }} @@ -110,7 +109,7 @@ jobs: WP_ENV_CORE: ${{ matrix.wordpress == '' && 'WordPress/WordPress' || format( 'https://wordpress.org/wordpress-{0}.zip', matrix.wordpress ) }} steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node @@ -123,7 +122,7 @@ jobs: # dependency versions are installed and cached. ## - name: Set up PHP - uses: shivammathur/setup-php@d30ad8b1843ace22e6698ab99bbafaa747b6bd0d # v2.24.0 + uses: shivammathur/setup-php@4bd44f22a98a19e0950cbad5f31095157cc9621b # v2.25.4 with: php-version: '${{ matrix.php }}' ini-file: development @@ -218,10 +217,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up PHP - uses: shivammathur/setup-php@d30ad8b1843ace22e6698ab99bbafaa747b6bd0d # v2.24.0 + uses: shivammathur/setup-php@4bd44f22a98a19e0950cbad5f31095157cc9621b # v2.25.4 with: php-version: '7.4' coverage: none @@ -285,7 +284,7 @@ jobs: if: ${{ github.repository == 'WordPress/gutenberg' || github.event_name == 'pull_request' }} steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Node.js and install dependencies uses: ./.github/setup-node @@ -297,4 +296,4 @@ jobs: run: npx lerna run build - name: Running the tests - run: npm run native test -- --ci --maxWorkers=2 --cacheDirectory="$HOME/.jest-cache" + run: npm run test:native -- --ci --maxWorkers=2 --cacheDirectory="$HOME/.jest-cache" diff --git a/.github/workflows/upload-release-to-plugin-repo.yml b/.github/workflows/upload-release-to-plugin-repo.yml index 392cf912e8db54..c39e8e6191b424 100644 --- a/.github/workflows/upload-release-to-plugin-repo.yml +++ b/.github/workflows/upload-release-to-plugin-repo.yml @@ -5,10 +5,66 @@ on: types: [published] jobs: + compute-should-update-trunk: + name: Decide if trunk or tag + runs-on: ubuntu-latest + # Skip this job if the release is a release candidate. This will in turn skip + # the upload jobs, which are only relevant for non-RC releases. + # We first check if the release is a prerelease, and then if the ref contains + # the string "rc". The latter is fallback in case the deployer accidentally + # unchecks the "This is a pre-release" checkbox in the release UI. + if: | + !github.event.release.prerelease && !contains(github.ref, 'rc') + + outputs: + should_update_trunk: ${{ steps.compute_should_update_trunk.outputs.should_update_trunk }} + + steps: + - name: Fetch latest version in the WP core repo + id: compute_latest_version_in_core_repo + run: | + latest_version_in_core_repo=$(curl -s 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request\[slug\]=gutenberg' | jq -r '.version') + echo "Latest Core Repo version: $latest_version_in_core_repo" + echo "version=$latest_version_in_core_repo" >> $GITHUB_OUTPUT + + - name: Decide if it is a trunk or tag update + id: compute_should_update_trunk + env: + GITHUB_REF: ${{ github.ref }} + run: | + latestPublishedVersion=$(echo "$GITHUB_REF" | sed -E 's/refs\/tags\/(v?)([0-9.]+)/\2/') + latestVersionInCoreRepo="${{ steps.compute_latest_version_in_core_repo.outputs.version }}" + + # Determines if the first version string is greater than the second version string. + # + # Params: + # $1 - The first version string to compare, which may have an optional leading "v". + # $2 - The second version string to compare, which may have an optional leading "v". + # + # Return values: + # 0 - The first version string is greater than the second version string. + # 1 - The first version string is less than or equal to the second version string. + is_first_version_greater_than_second() { + v1=${1#v} + v2=${2#v} + dpkg --compare-versions "$v1" gt "$v2" + return $? + } + + # Only update trunk *if* the published release's version in Github is GREATER + # than the version currently published in the WP plugins repo. If not, then it + # will upload it as a new tag. + shouldUpdateTrunk=false + if is_first_version_greater_than_second "$latestPublishedVersion" "$latestVersionInCoreRepo"; then + shouldUpdateTrunk=true + fi + + echo "Should update trunk: $shouldUpdateTrunk" + echo "should_update_trunk=$shouldUpdateTrunk" >> $GITHUB_OUTPUT + get-release-branch: name: Get release branch name runs-on: ubuntu-latest - if: github.event.release.assets[0] outputs: release_branch: ${{ steps.get_release_branch.outputs.release_branch }} @@ -25,7 +81,8 @@ jobs: update-changelog: name: Update Changelog on ${{ matrix.branch }} branch runs-on: ubuntu-latest - if: github.event.release.assets[0] + if: | + github.event.release.assets[0] needs: get-release-branch env: TAG: ${{ github.event.release.tag_name }} @@ -39,7 +96,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ matrix.branch }} token: ${{ secrets.GUTENBERG_TOKEN }} @@ -95,11 +152,12 @@ jobs: path: ./changelog.txt upload: - name: Upload Gutenberg Plugin + name: Publish as trunk (and tag) runs-on: ubuntu-latest environment: wp.org plugin - needs: update-changelog - if: ${{ !github.event.release.prerelease && github.event.release.assets[0] }} + needs: [compute-should-update-trunk, update-changelog] + if: | + needs.compute-should-update-trunk.outputs.should_update_trunk == 'true' && github.event.release.assets[0] env: PLUGIN_REPO_URL: 'https://plugins.svn.wordpress.org/gutenberg' STABLE_VERSION_REGEX: '[0-9]\+\.[0-9]\+\.[0-9]\+\s*' @@ -109,11 +167,7 @@ jobs: steps: - name: Check out Gutenberg trunk from WP.org plugin repo - run: svn checkout "$PLUGIN_REPO_URL/trunk" - - - name: Get previous stable version - id: get_previous_stable_version - run: echo "stable_version=$(awk -F ':\ ' '$1 == "Stable tag" {print $2}' ./trunk/readme.txt)" >> $GITHUB_OUTPUT + run: svn checkout "$PLUGIN_REPO_URL/trunk" --username "$SVN_USERNAME" --password "$SVN_PASSWORD" - name: Delete everything working-directory: ./trunk @@ -130,8 +184,8 @@ jobs: - name: Replace the stable tag placeholder with the existing stable tag on the SVN repository env: STABLE_TAG_PLACEHOLDER: 'Stable tag: V\.V\.V' - STABLE_TAG: 'Stable tag: ${{ steps.get_previous_stable_version.outputs.stable_version }}' - run: sed -i "s/${STABLE_TAG_PLACEHOLDER}/${STABLE_TAG}/g" ./trunk/readme.txt + run: | + sed -i "s/$STABLE_TAG_PLACEHOLDER/Stable tag: $VERSION/g" ./trunk/readme.txt - name: Download Changelog Artifact uses: actions/download-artifact@9782bd6a9848b53b110e712e20e42d89988822b7 # v3.0.1 @@ -159,3 +213,44 @@ jobs: sed -i "s/Stable tag: ${STABLE_VERSION_REGEX}/Stable tag: ${VERSION}/g" ./readme.txt svn commit -m "Releasing version $VERSION" \ --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" + + upload-tag: + name: Publish as tag + runs-on: ubuntu-latest + environment: wp.org plugin + needs: [compute-should-update-trunk, update-changelog] + if: | + needs.compute-should-update-trunk.outputs.should_update_trunk == 'false' && github.event.release.assets[0] + env: + PLUGIN_REPO_URL: 'https://plugins.svn.wordpress.org/gutenberg' + STABLE_VERSION_REGEX: '[0-9]\+\.[0-9]\+\.[0-9]\+\s*' + SVN_USERNAME: ${{ secrets.svn_username }} + SVN_PASSWORD: ${{ secrets.svn_password }} + VERSION: ${{ github.event.release.name }} + + steps: + - name: Download and unzip Gutenberg plugin asset into tags folder + env: + PLUGIN_URL: ${{ github.event.release.assets[0].browser_download_url }} + run: | + # do the magic here + curl -L -o gutenberg.zip $PLUGIN_URL + unzip gutenberg.zip -d "$VERSION" + rm gutenberg.zip + + - name: Replace the stable tag placeholder with the existing stable tag on the SVN repository + env: + STABLE_TAG_PLACEHOLDER: 'Stable tag: V\.V\.V' + run: | + sed -i "s/$STABLE_TAG_PLACEHOLDER/Stable tag: $VERSION/g" "$VERSION/readme.txt" + + - name: Download Changelog Artifact + uses: actions/download-artifact@9782bd6a9848b53b110e712e20e42d89988822b7 # v3.0.1 + with: + name: changelog trunk + path: ${{ github.event.release.name }} + + - name: Add the new version directory and commit changes to the SVN repository + run: | + svn import "$VERSION" "$PLUGIN_REPO_URL/tags/$VERSION" -m "Committing version $VERSION" \ + --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" diff --git a/.gitignore b/.gitignore index 4a7f4708ce399a..b5cd0124e5d4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ build-types node_modules gutenberg.zip coverage -*-performance-results.json .phpunit.result.cache .reassure @@ -15,6 +14,7 @@ coverage *.log yarn.lock /artifacts +/test/e2e/artifacts /perf-envs /composer.lock @@ -34,7 +34,7 @@ yarn.lock Thumbs.db # Report generated from jest-junit -test/native/junit.xml +junit.xml # Local overrides .wp-env.override.json @@ -46,3 +46,5 @@ phpunit-watcher.yml test/storybook-playwright/test-results test/storybook-playwright/specs/__snapshots__ test/storybook-playwright/specs/*-snapshots/** +test/gutenberg-test-themes/twentytwentyone +test/gutenberg-test-themes/twentytwentythree diff --git a/.npmrc b/.npmrc index aafab1669bf724..cd7566c8202337 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ save-exact = true engine-strict = true +legacy-peer-deps = true diff --git a/.nvmrc b/.nvmrc index 8351c19397f4fc..b6a7d89c68e0ca 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14 +16 diff --git a/.wp-env.json b/.wp-env.json index aa8dfaf3c0c4ab..79810b194b667c 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -8,7 +8,9 @@ "wp-content/plugins/gutenberg": ".", "wp-content/mu-plugins": "./packages/e2e-tests/mu-plugins", "wp-content/plugins/gutenberg-test-plugins": "./packages/e2e-tests/plugins", - "wp-content/themes/gutenberg-test-themes": "./test/gutenberg-test-themes" + "wp-content/themes/gutenberg-test-themes": "./test/gutenberg-test-themes", + "wp-content/themes/gutenberg-test-themes/twentytwentyone": "https://downloads.wordpress.org/theme/twentytwentyone.1.7.zip", + "wp-content/themes/gutenberg-test-themes/twentytwentythree": "https://downloads.wordpress.org/theme/twentytwentythree.1.0.zip" } } } diff --git a/README.md b/README.md index 8ac17b8574c52f..5ba112319b405c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Welcome to the development hub for the WordPress Gutenberg project! The block editor introduces a modular approach to pages and posts: each piece of content in the editor, from a paragraph to an image gallery to a headline, is its own block. And just like physical blocks, WordPress blocks can be added, arranged, and rearranged, allowing WordPress users to create media-rich pages in a visually intuitive way -- and without work-arounds like shortcodes or custom HTML. -The block editor first became available in December 2018, and we're still hard at work refining the experience, creating more and better blocks, and laying the groundwork for the next three phases of work. The Gutenberg plugin gives you the latest version of the block editor so you can join us in testing bleeding-edge features, start playing with blocks, and maybe get inspired to build your own. +The block editor first became available in December 2018, and we're still hard at work refining the experience, creating more and better blocks, and laying the groundwork for the next three phases of work. The Gutenberg plugin gives you the latest version of the block editor, so you can join us in testing bleeding-edge features, start playing with blocks, and maybe get inspired to build your own. Check out the [Ways to keep up with Gutenberg & Full Site Editing (FSE)](https://make.wordpress.org/core/2020/05/20/ways-to-keep-up-with-full-site-editing-fse/) @@ -31,7 +31,7 @@ Get hands on: check out the [block editor live demo](https://wordpress.org/guten - **User Documentation:** See the [WordPress Editor documentation](https://wordpress.org/documentation/article/wordpress-block-editor/) for detailed docs on using the editor as an author creating posts and pages. -- **User Support:** If you have run into an issue, you should check the [Support Forums first](https://wordpress.org/support/forums/). The forums are a great place to get help. If you have a bug to report, please [submit it to the Gutenberg repository](https://github.com/wordpress/gutenberg/issues). Please search prior to creating a new bug to confirm its not a duplicate. +- **User Support:** If you have run into an issue, you should check the [Support Forums first](https://wordpress.org/support/forums/). The forums are a great place to get help. If you have a bug to report, please [submit it to the Gutenberg repository](https://github.com/wordpress/gutenberg/issues). Please search prior to creating a new bug to confirm it's not a duplicate. ### Developing for Gutenberg @@ -41,7 +41,7 @@ Review the [Create a Block tutorial](/docs/getting-started/create-block/README.m ### Contribute to Gutenberg -Gutenberg is an open-source project and welcomes all contributors from code to design, and from documentation to triage. The project is built by many contributors and volunteers and we'd love your help building it. +Gutenberg is an open-source project and welcomes all contributors from code to design, and from documentation to triage. The project is built by many contributors and volunteers, and we'd love your help building it. See the [Contributors Handbook](https://developer.wordpress.org/block-editor/contributors/) for all the details on how you can contribute. diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 131e434d1383d0..4ba931c4a4aeb6 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -83,7 +83,6 @@ build_files=$( build/block-library/blocks/*.php \ build/block-library/blocks/*/block.json \ build/block-library/blocks/*/*.{js,js.map,css,asset.php} \ - build/block-library/interactivity/*.{js,js.map,asset.php} \ build/edit-widgets/blocks/*/block.json \ build/widgets/blocks/*.php \ build/widgets/blocks/*/block.json \ diff --git a/bin/cherry-pick.mjs b/bin/cherry-pick.mjs index 36829db6ee5cf8..baf8d42962f8e8 100644 --- a/bin/cherry-pick.mjs +++ b/bin/cherry-pick.mjs @@ -114,12 +114,25 @@ async function fetchPRs() { const { items } = await GitHubFetch( `/search/issues?q=is:pr state:closed sort:updated label:"${ LABEL }" repo:WordPress/gutenberg` ); - const PRs = items.map( ( { id, number, title } ) => ( { + const PRs = items.map( ( { id, number, title, pull_request, closed_at } ) => ( { id, number, title, - } ) ); - console.log( 'Found the following PRs to cherry-pick: ' ); + closed_at, + pull_request, + } ) ).sort( ( a, b ) => { + /* + * `closed_at` and `pull_request.merged_at` are _usually_ the same, + * but let's prefer the latter if it's available. + */ + if ( a?.pull_request?.merged_at && b?.pull_request?.merged_at ) { + return new Date( a?.pull_request?.merged_at ) - new Date( b?.pull_request?.merged_at ); + } + return new Date( a.closed_at ) - new Date( b.closed_at ); + } ); + + + console.log( 'Found the following PRs to cherry-pick (sorted by closed date in ascending order): ' ); PRs.forEach( ( { number, title } ) => console.log( indent( `#${ number } – ${ title }` ) ) ); diff --git a/bin/generate-public-grammar.js b/bin/generate-public-grammar.js deleted file mode 100755 index d66577adbc4d02..00000000000000 --- a/bin/generate-public-grammar.js +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node - -/** - * Internal dependencies - */ -const parser = require( '../node_modules/pegjs/lib/parser.js' ); - -/** - * External dependencies - */ -const fs = require( 'fs' ); -const path = require( 'path' ); -const grammarSource = fs.readFileSync( - './packages/block-serialization-spec-parser/grammar.pegjs', - 'utf8' -); -const grammar = parser.parse( grammarSource ); - -function escape( text ) { - return text - .replace( /\t/g, '\\t' ) - .replace( /\r/g, '\\r' ) - .replace( /\n/g, '\\n' ) - .replace( /\&/g, '&' ) - .replace( /= 0 - ); -} - -function flattenUnary( expression ) { - const shouldWrap = isGroup( expression ); - const inner = flatten( expression ); - return shouldWrap ? '(' + inner + ')' : inner; -} - -function flatten( expression ) { - switch ( expression.type ) { - // Terminal - case 'any': - return '.'; - case 'rule_ref': - return expression.name; - case 'literal': - return '"' + escape( expression.value ) + '"'; - case 'class': - return ( - '[' + - ( expression.inverted ? '^' : '' ) + - expression.parts - .map( ( part ) => - escape( - Array.isArray( part ) ? part.join( '-' ) : part - ) - ) - .join( '' ) + - ']' + - ( expression.ignoreCase ? 'i' : '' ) - ); - - // Unary - case 'zero_or_more': - return flattenUnary( expression.expression ) + '*'; - case 'one_or_more': - return flattenUnary( expression.expression ) + '+'; - case 'optional': - return flattenUnary( expression.expression ) + '?'; - case 'simple_not': - return '!' + flattenUnary( expression.expression ); - - // Other groups - case 'sequence': - return expression.elements.map( flatten ).join( ' ' ); - case 'choice': - const sep = expression.isRuleTop ? '\n / ' : ' / '; - return expression.alternatives.map( flatten ).join( sep ); - case 'group': - return '(' + flatten( expression.expression ) + ')'; - case 'text': - // Avoid double parentheses - const inner = flatten( expression.expression ); - const shouldWrap = inner.indexOf( '(' ) !== 0; - return shouldWrap ? '$(' + inner + ')' : '$' + inner; - case 'action': - case 'labeled': - case 'named': - return flatten( expression.expression ); - - // Top-level formatting - case 'grammar': - return `
${ expression.rules.map( flatten ).join( '' ) }
`; - case 'rule': - expression.expression.isRuleTop = true; - const displayName = - expression.expression.type === 'named' - ? expression.expression.name - : ''; - return ( - `
${ displayName }
` + - `
${ expression.name }
= ` + - `${ flatten( expression.expression ) }
` - ); - - default: - throw new Error( JSON.stringify( expression ) ); - } -} - -fs.writeFileSync( - path.join( __dirname, '..', 'docs', 'contributors', 'grammar.md' ), - ` -# Block Grammar - -${ flatten( grammar ) } -` -); diff --git a/bin/list-experimental-api-matches.sh b/bin/list-experimental-api-matches.sh new file mode 100755 index 00000000000000..156464c4e7375e --- /dev/null +++ b/bin/list-experimental-api-matches.sh @@ -0,0 +1,65 @@ +#!/bin/sh + +# Generate a Markdown-formatted list of experimental APIs found across our +# packages and lib, providing GitHub search links for each match. +# +# Experimental APIs must be regularly audited, particularly in the context of +# major WordPress releases. This script allows release leads to generate a list +# to share in release issues. +# +# @see example audit issue for WordPress 6.2: +# https://github.com/WordPress/gutenberg/issues/47196 + +# Exit if any command fails. +set -e + +# Change to the root directory. +cd "$(dirname "$0")" +cd .. + +# POSIX: prefer standard grep over rg. Git is assumed present (ls-files), but +# could be replaced with find. +grep_experimental_apis() { + git ls-files packages/* lib \ + | grep -E '\.(js|ts|jsx|tsx|php)$' \ + | grep -v __tests__ \ + | xargs grep -Eo '__experimental\w+' +} + +# For each line as `:`, rewrite as ` `. +namespace() { + awk -F: ' + { print module($1), $2 } + function module(path) { + n = split(path, parts, "/") + if (parts[1] == "lib") return "lib" + return parts[1] "/" parts[2] + }' +} + +# Like uniq, but applied across packages: if `__experimentalFoo` appears in +# packages A and B, only keep the occurrence under A. +compact() { + sort | uniq | awk '{ + if (known_api[$2]) next + known_api[$2] = 1 + print + }' +} + +# Output a heading for each package and a link for each experimental API. +format() { + awk '{ + if (prev_dir != $1) { + if (NR > 1) print "" + printf "## `%s`\n", $1 + prev_dir = $1 + } + printf "[`%s`](/WordPress/gutenberg/search?q=%s)\n", $2, $2 + }' +} + +grep_experimental_apis \ + | namespace \ + | compact \ + | format diff --git a/bin/log-performance-results.js b/bin/log-performance-results.js index 9bc7ef9cb99b11..861e09bbc6c4d8 100755 --- a/bin/log-performance-results.js +++ b/bin/log-performance-results.js @@ -37,7 +37,7 @@ const data = new TextEncoder().encode( branch, hash, baseHash, - timestamp: parseInt( timestamp, 10 ), + timestamp, metrics: resultsFiles.reduce( ( result, { metricsPrefix }, index ) => { return { ...result, diff --git a/bin/packages/get-packages.js b/bin/packages/get-packages.js index 695eee03c440e2..42ba08afed6f68 100644 --- a/bin/packages/get-packages.js +++ b/bin/packages/get-packages.js @@ -3,7 +3,6 @@ */ const fs = require( 'fs' ); const path = require( 'path' ); -const { isEmpty } = require( 'lodash' ); /** * Absolute path to packages directory. @@ -43,7 +42,7 @@ function hasModuleField( file ) { return false; } - return ! isEmpty( pkg.module ); + return !! pkg.module; } /** diff --git a/bin/packages/lint-staged-typecheck.js b/bin/packages/lint-staged-typecheck.js index 30c513ec983c5e..7b7eb7b846bfb0 100644 --- a/bin/packages/lint-staged-typecheck.js +++ b/bin/packages/lint-staged-typecheck.js @@ -1,7 +1,6 @@ /** * External dependencies */ -const _ = require( 'lodash' ); const path = require( 'path' ); const fs = require( 'fs' ); const execa = require( 'execa' ); @@ -18,12 +17,14 @@ const tscPath = path.join( repoRoot, 'node_modules', '.bin', 'tsc' ); const changedFiles = process.argv.slice( 2 ); // Transform changed files to package directories containing tsconfig.json. -const changedPackages = _.uniq( - changedFiles.map( ( fullPath ) => { - const relativePath = path.relative( repoRoot, fullPath ); - return path.join( ...relativePath.split( path.sep ).slice( 0, 2 ) ); - } ) -).filter( ( packageRoot ) => +const changedPackages = [ + ...new Set( + changedFiles.map( ( fullPath ) => { + const relativePath = path.relative( repoRoot, fullPath ); + return path.join( ...relativePath.split( path.sep ).slice( 0, 2 ) ); + } ) + ), +].filter( ( packageRoot ) => fs.existsSync( path.join( packageRoot, 'tsconfig.json' ) ) ); diff --git a/bin/plugin/commands/changelog.js b/bin/plugin/commands/changelog.js index 69be77ac669911..02cb5f7afdd81a 100644 --- a/bin/plugin/commands/changelog.js +++ b/bin/plugin/commands/changelog.js @@ -75,6 +75,7 @@ const LABEL_TYPE_MAPPING = { 'Automated Testing': 'Tools', '[Package] Dependency Extraction Webpack Plugin': 'Tools', '[Type] Code Quality': 'Code Quality', + '[Type] Accessibility (a11y)': 'Accessibility', '[Type] Performance': 'Performance', '[Type] Security': 'Security', '[Feature] Navigation Screen': 'Experiments', @@ -125,11 +126,6 @@ const LABEL_FEATURE_MAPPING = { '[Block] Legacy Widget': 'Widgets Editor', 'REST API Interaction': 'REST API', 'New Block': 'Block Library', - 'Accessibility (a11y)': 'Accessibility', - '[a11y] Color Contrast': 'Accessibility', - '[a11y] Keyboard & Focus': 'Accessibility', - '[a11y] Labelling': 'Accessibility', - '[a11y] Zooming': 'Accessibility', '[Package] E2E Tests': 'Testing', '[Package] E2E Test Utils': 'Testing', 'Automated Testing': 'Testing', @@ -153,6 +149,7 @@ const GROUP_TITLE_ORDER = [ 'Enhancements', 'New APIs', 'Bug Fixes', + `Accessibility`, 'Performance', 'Experiments', 'Documentation', @@ -472,6 +469,21 @@ const createOmitByLabel = ( labels ) => ( text, issue ) => ? undefined : text; +/** + * Higher-order function which returns a normalization function to omit by issue + * label starting with any of the given prefixes + * + * @param {string[]} prefixes Label prefixes from which to determine if given entry + * should be omitted. + * + * @return {WPChangelogNormalization} Normalization function. + */ +const createOmitByLabelPrefix = ( prefixes ) => ( text, issue ) => + issue.labels.some( ( label ) => + prefixes.some( ( prefix ) => label.name.startsWith( prefix ) ) + ) + ? undefined + : text; /** * Given an issue title and issue, returns the title with redundant grouping * type details removed. The prefix is redundant since it would already be clear @@ -516,7 +528,7 @@ function removeFeaturePrefix( text ) { * @type {Array} */ const TITLE_NORMALIZATIONS = [ - createOmitByLabel( [ 'Mobile App Android/iOS' ] ), + createOmitByLabelPrefix( [ 'Mobile App' ] ), createOmitByTitlePrefix( [ '[rnmobile]', '[mobile]', 'Mobile Release' ] ), removeRedundantTypePrefix, reword, @@ -1043,6 +1055,7 @@ async function getReleaseChangelog( options ) { capitalizeAfterColonSeparatedPrefix, createOmitByTitlePrefix, createOmitByLabel, + createOmitByLabelPrefix, addTrailingPeriod, getNormalizedTitle, getReleaseChangelog, diff --git a/bin/plugin/commands/packages.js b/bin/plugin/commands/packages.js index c085201a235001..87ecc6eb89cfca 100644 --- a/bin/plugin/commands/packages.js +++ b/bin/plugin/commands/packages.js @@ -25,6 +25,7 @@ const { findPluginReleaseBranchName, } = require( './common' ); const { join } = require( 'path' ); +const pluginConfig = require( '../config' ); /** * Release type names. @@ -99,7 +100,7 @@ async function checkoutNpmReleaseBranch( { * `trunk` commits from within the past week. */ await SimpleGit( gitWorkingDirectoryPath ) - .fetch( npmReleaseBranch, [ '--depth=100' ] ) + .fetch( 'origin', npmReleaseBranch, [ '--depth=100' ] ) .checkout( npmReleaseBranch ); log( '>> The local npm release branch ' + @@ -411,13 +412,27 @@ async function publishPackagesToNpm( { ); } else if ( [ 'bugfix', 'wp' ].includes( releaseType ) ) { log( '>> Publishing modified packages to npm.' ); - await command( - `npx lerna publish ${ minimumVersionBump } --dist-tag ${ distTag } --no-private ${ yesFlag } ${ noVerifyAccessFlag }`, - { - cwd: gitWorkingDirectoryPath, - stdio: 'inherit', - } - ); + try { + await command( + `npx lerna publish ${ minimumVersionBump } --dist-tag ${ distTag } --no-private ${ yesFlag } ${ noVerifyAccessFlag }`, + { + cwd: gitWorkingDirectoryPath, + stdio: 'inherit', + } + ); + } catch { + log( + '>> Trying to finish failed publishing of modified npm packages.' + ); + await SimpleGit( gitWorkingDirectoryPath ).reset( 'hard' ); + await command( + `npx lerna publish from-package --dist-tag ${ distTag } ${ yesFlag } ${ noVerifyAccessFlag }`, + { + cwd: gitWorkingDirectoryPath, + stdio: 'inherit', + } + ); + } } else { log( '>> Bumping version of public packages changed since the last release.' @@ -431,13 +446,27 @@ async function publishPackagesToNpm( { ); log( '>> Publishing modified packages to npm.' ); - await command( - `npx lerna publish from-package ${ yesFlag } ${ noVerifyAccessFlag }`, - { - cwd: gitWorkingDirectoryPath, - stdio: 'inherit', - } - ); + try { + await command( + `npx lerna publish from-package ${ yesFlag } ${ noVerifyAccessFlag }`, + { + cwd: gitWorkingDirectoryPath, + stdio: 'inherit', + } + ); + } catch { + log( + '>> Trying to finish failed publishing of modified npm packages.' + ); + await SimpleGit( gitWorkingDirectoryPath ).reset( 'hard' ); + await command( + `npx lerna publish from-package ${ yesFlag } ${ noVerifyAccessFlag }`, + { + cwd: gitWorkingDirectoryPath, + stdio: 'inherit', + } + ); + } } const afterCommitHash = await SimpleGit( gitWorkingDirectoryPath ).revparse( @@ -530,7 +559,11 @@ async function runPackagesRelease( config, customMessages ) { config.abortMessage, async () => { log( '>> Cloning the Git repository' ); - await SimpleGit( gitPath ).clone( config.gitRepositoryURL ); + await SimpleGit().clone( + pluginConfig.gitRepositoryURL, + gitPath, + [ '--depth=1', '--no-single-branch' ] + ); log( ` >> successfully clone into: ${ gitPath }` ); } ); diff --git a/bin/plugin/commands/performance.js b/bin/plugin/commands/performance.js index 57c6674500a221..e153f482137e57 100644 --- a/bin/plugin/commands/performance.js +++ b/bin/plugin/commands/performance.js @@ -14,11 +14,13 @@ const { readJSONFile, askForConfirmation, getRandomTemporaryPath, + getFilesFromDir, } = require( '../lib/utils' ); const config = require( '../config' ); const ARTIFACTS_PATH = process.env.WP_ARTIFACTS_PATH || path.join( process.cwd(), 'artifacts' ); +const RESULTS_FILE_SUFFIX = '.performance-results.json'; /** * @typedef WPPerformanceCommandOptions @@ -29,62 +31,6 @@ const ARTIFACTS_PATH = * @property {string=} wpVersion The WordPress version to be used as the base install for testing. */ -/** - * @typedef WPRawPerformanceResults - * - * @property {number[]} timeToFirstByte Represents the time since the browser started the request until it received a response. - * @property {number[]} largestContentfulPaint Represents the time when the main content of the page has likely loaded. - * @property {number[]} lcpMinusTtfb Represents the difference between LCP and TTFB. - * @property {number[]} serverResponse Represents the time the server takes to respond. - * @property {number[]} firstPaint Represents the time when the user agent first rendered after navigation. - * @property {number[]} domContentLoaded Represents the time immediately after the document's DOMContentLoaded event completes. - * @property {number[]} loaded Represents the time when the load event of the current document is completed. - * @property {number[]} firstContentfulPaint Represents the time when the browser first renders any text or media. - * @property {number[]} firstBlock Represents the time when Puppeteer first sees a block selector in the DOM. - * @property {number[]} type Average type time. - * @property {number[]} typeContainer Average type time within a container. - * @property {number[]} focus Average block selection time. - * @property {number[]} inserterOpen Average time to open global inserter. - * @property {number[]} inserterSearch Average time to search the inserter. - * @property {number[]} inserterHover Average time to move mouse between two block item in the inserter. - * @property {number[]} listViewOpen Average time to open listView - */ - -/** - * @typedef WPPerformanceResults - * - * @property {number=} timeToFirstByte Represents the time since the browser started the request until it received a response. - * @property {number=} largestContentfulPaint Represents the time when the main content of the page has likely loaded. - * @property {number=} lcpMinusTtfb Represents the difference between LCP and TTFB. - * @property {number=} serverResponse Represents the time the server takes to respond. - * @property {number=} firstPaint Represents the time when the user agent first rendered after navigation. - * @property {number=} domContentLoaded Represents the time immediately after the document's DOMContentLoaded event completes. - * @property {number=} loaded Represents the time when the load event of the current document is completed. - * @property {number=} firstContentfulPaint Represents the time when the browser first renders any text or media. - * @property {number=} firstBlock Represents the time when Puppeteer first sees a block selector in the DOM. - * @property {number=} type Average type time. - * @property {number=} minType Minimum type time. - * @property {number=} maxType Maximum type time. - * @property {number=} typeContainer Average type time within a container. - * @property {number=} minTypeContainer Minimum type time within a container. - * @property {number=} maxTypeContainer Maximum type time within a container. - * @property {number=} focus Average block selection time. - * @property {number=} minFocus Min block selection time. - * @property {number=} maxFocus Max block selection time. - * @property {number=} inserterOpen Average time to open global inserter. - * @property {number=} minInserterOpen Min time to open global inserter. - * @property {number=} maxInserterOpen Max time to open global inserter. - * @property {number=} inserterSearch Average time to open global inserter. - * @property {number=} minInserterSearch Min time to open global inserter. - * @property {number=} maxInserterSearch Max time to open global inserter. - * @property {number=} inserterHover Average time to move mouse between two block item in the inserter. - * @property {number=} minInserterHover Min time to move mouse between two block item in the inserter. - * @property {number=} maxInserterHover Max time to move mouse between two block item in the inserter. - * @property {number=} listViewOpen Average time to open list view. - * @property {number=} minListViewOpen Min time to open list view. - * @property {number=} maxListViewOpen Max time to open list view. - */ - /** * Sanitizes branch name to be used in a path or a filename. * @@ -96,93 +42,23 @@ function sanitizeBranchName( branch ) { return branch.replace( /[^a-zA-Z0-9-]/g, '-' ); } -/** - * Computes the average number from an array numbers. - * - * @param {number[]} array - * - * @return {number} Average. - */ -function average( array ) { - return array.reduce( ( a, b ) => a + b, 0 ) / array.length; -} - /** * Computes the median number from an array numbers. * * @param {number[]} array * - * @return {number} Median. + * @return {number|undefined} Median value or undefined if array empty. */ function median( array ) { - const mid = Math.floor( array.length / 2 ), - numbers = [ ...array ].sort( ( a, b ) => a - b ); - return array.length % 2 !== 0 - ? numbers[ mid ] - : ( numbers[ mid - 1 ] + numbers[ mid ] ) / 2; -} + if ( ! array || ! array.length ) return undefined; -/** - * Rounds and format a time passed in milliseconds. - * - * @param {number} number - * - * @return {number} Formatted time. - */ -function formatTime( number ) { - const factor = Math.pow( 10, 2 ); - return Math.round( number * factor ) / factor; -} + const numbers = [ ...array ].sort( ( a, b ) => a - b ); + const middleIndex = Math.floor( numbers.length / 2 ); -/** - * Curate the raw performance results. - * - * @param {string} testSuite - * @param {WPRawPerformanceResults} results - * - * @return {WPPerformanceResults} Curated Performance results. - */ -function curateResults( testSuite, results ) { - if ( - testSuite === 'front-end-classic-theme' || - testSuite === 'front-end-block-theme' - ) { - return { - timeToFirstByte: median( results.timeToFirstByte ), - largestContentfulPaint: median( results.largestContentfulPaint ), - lcpMinusTtfb: median( results.lcpMinusTtfb ), - }; + if ( numbers.length % 2 === 0 ) { + return ( numbers[ middleIndex - 1 ] + numbers[ middleIndex ] ) / 2; } - - return { - serverResponse: average( results.serverResponse ), - firstPaint: average( results.firstPaint ), - domContentLoaded: average( results.domContentLoaded ), - loaded: average( results.loaded ), - firstContentfulPaint: average( results.firstContentfulPaint ), - firstBlock: average( results.firstBlock ), - type: average( results.type ), - minType: Math.min( ...results.type ), - maxType: Math.max( ...results.type ), - typeContainer: average( results.typeContainer ), - minTypeContainer: Math.min( ...results.typeContainer ), - maxTypeContainer: Math.max( ...results.typeContainer ), - focus: average( results.focus ), - minFocus: Math.min( ...results.focus ), - maxFocus: Math.max( ...results.focus ), - inserterOpen: average( results.inserterOpen ), - minInserterOpen: Math.min( ...results.inserterOpen ), - maxInserterOpen: Math.max( ...results.inserterOpen ), - inserterSearch: average( results.inserterSearch ), - minInserterSearch: Math.min( ...results.inserterSearch ), - maxInserterSearch: Math.max( ...results.inserterSearch ), - inserterHover: average( results.inserterHover ), - minInserterHover: Math.min( ...results.inserterHover ), - maxInserterHover: Math.max( ...results.inserterHover ), - listViewOpen: average( results.listViewOpen ), - minListViewOpen: Math.min( ...results.listViewOpen ), - maxListViewOpen: Math.max( ...results.listViewOpen ), - }; + return numbers[ middleIndex ]; } /** @@ -190,27 +66,18 @@ function curateResults( testSuite, results ) { * * @param {string} testSuite Name of the tests set. * @param {string} performanceTestDirectory Path to the performance tests' clone. - * @param {string} runKey Unique identifier for the test run, e.g. `branch-name_post-editor_run-3`. - * - * @return {Promise} Performance results for the branch. + * @param {string} runKey Unique identifier for the test run. */ async function runTestSuite( testSuite, performanceTestDirectory, runKey ) { - const resultsFilename = `${ runKey }.performance-results.json`; - await runShellScript( `npm run test:performance -- ${ testSuite }`, performanceTestDirectory, { ...process.env, WP_ARTIFACTS_PATH: ARTIFACTS_PATH, - RESULTS_FILENAME: resultsFilename, + RESULTS_ID: runKey, } ); - - return curateResults( - testSuite, - await readJSONFile( path.join( ARTIFACTS_PATH, resultsFilename ) ) - ); } /** @@ -239,7 +106,10 @@ async function runPerformanceTests( branches, options ) { await askForConfirmation( 'Ready to go? ' ); } - // 1- Preparing the tests directory. + /* + * 1- Preparing the tests directory. + */ + log( '\n>> Preparing the tests directories' ); log( ' >> Cloning the repository' ); @@ -285,13 +155,22 @@ async function runPerformanceTests( branches, options ) { log( ' >> Installing dependencies and building packages' ); await runShellScript( - 'npm ci && node ./bin/packages/build.js', + `bash -c "${ [ + 'source $HOME/.nvm/nvm.sh', + 'nvm install', + 'npm ci', + 'npx playwright install chromium --with-deps', + 'npm run build:packages', + ].join( ' && ' ) }"`, performanceTestDirectory ); log( ' >> Creating the environment folders' ); await runShellScript( 'mkdir -p ' + rootDirectory + '/envs' ); - // 2- Preparing the environment directories per branch. + /* + * 2- Preparing the environment directories per branch. + */ + log( '\n>> Preparing an environment directory per branch' ); const branchDirectories = {}; for ( const branch of branches ) { @@ -321,7 +200,7 @@ async function runPerformanceTests( branches, options ) { log( ` >> Building the ${ fancyBranch } branch` ); await runShellScript( - 'npm ci && npm run prebuild:packages && node ./bin/packages/build.js && npx wp-scripts build', + 'bash -c "source $HOME/.nvm/nvm.sh && nvm install && npm ci && npm run prebuild:packages && node ./bin/packages/build.js && npx wp-scripts build"', buildPath ); @@ -330,6 +209,10 @@ async function runPerformanceTests( branches, options ) { path.join( environmentDirectory, '.wp-env.json' ), JSON.stringify( { + config: { + WP_DEBUG: false, + SCRIPT_DEBUG: false, + }, core: 'WordPress/WordPress', plugins: [ path.join( environmentDirectory, 'plugin' ) ], themes: [ @@ -337,8 +220,6 @@ async function runPerformanceTests( branches, options ) { performanceTestDirectory, 'test/emptytheme' ), - 'https://downloads.wordpress.org/theme/twentytwentyone.1.7.zip', - 'https://downloads.wordpress.org/theme/twentytwentythree.1.0.zip', ], env: { tests: { @@ -352,6 +233,15 @@ async function runPerformanceTests( branches, options ) { performanceTestDirectory, 'packages/e2e-tests/plugins' ), + 'wp-content/themes/gutenberg-test-themes': + path.join( + performanceTestDirectory, + 'test/gutenberg-test-themes' + ), + 'wp-content/themes/gutenberg-test-themes/twentytwentyone': + 'https://downloads.wordpress.org/theme/twentytwentyone.1.7.zip', + 'wp-content/themes/gutenberg-test-themes/twentytwentythree': + 'https://downloads.wordpress.org/theme/twentytwentythree.1.0.zip', }, }, }, @@ -393,7 +283,7 @@ async function runPerformanceTests( branches, options ) { } } - // 3- Printing the used folders. + // Printing the used folders. log( '\n>> Perf Tests Directory : ' + formats.success( performanceTestDirectory ) @@ -404,34 +294,28 @@ async function runPerformanceTests( branches, options ) { log( `>> Environment Directory (${ branch }) : ${ envPath }` ); } - // 4- Running the tests. + /* + * 3- Running the tests. + */ + log( '\n>> Running the tests' ); - const testSuites = [ - 'post-editor', - 'site-editor', - 'front-end-classic-theme', - 'front-end-block-theme', - ]; + const testSuites = getFilesFromDir( + path.join( performanceTestDirectory, 'test/performance/specs' ) + ).map( ( file ) => path.basename( file, '.spec.js' ) ); - /** @type {Record>} */ - const results = {}; const wpEnvPath = path.join( performanceTestDirectory, 'node_modules/.bin/wp-env' ); for ( const testSuite of testSuites ) { - results[ testSuite ] = {}; - /** @type {Array>} */ - const rawResults = []; - for ( let i = 0; i < TEST_ROUNDS; i++ ) { - const roundInfo = `round ${ i + 1 } of ${ TEST_ROUNDS }`; + for ( let i = 1; i <= TEST_ROUNDS; i++ ) { + const roundInfo = `round ${ i } of ${ TEST_ROUNDS }`; log( ` >> Suite: ${ testSuite } (${ roundInfo })` ); - rawResults[ i ] = {}; for ( const branch of branches ) { const sanitizedBranch = sanitizeBranchName( branch ); - const runKey = `${ testSuite }_${ sanitizedBranch }_run-${ i }`; + const runKey = `${ testSuite }_${ sanitizedBranch }_round-${ i }`; // @ts-ignore const environmentDirectory = branchDirectories[ branch ]; log( ` >> Branch: ${ branch }` ); @@ -441,7 +325,7 @@ async function runPerformanceTests( branches, options ) { environmentDirectory ); log( ' >> Running the test.' ); - rawResults[ i ][ branch ] = await runTestSuite( + await runTestSuite( testSuite, performanceTestDirectory, runKey @@ -453,53 +337,60 @@ async function runPerformanceTests( branches, options ) { ); } } + } - // Computing medians. - for ( const branch of branches ) { - /** - * @type {string[]} - */ - let dataPointsForTestSuite = []; - if ( rawResults.length > 0 ) { - dataPointsForTestSuite = Object.keys( - rawResults[ 0 ][ branch ] - ); - } + /* + * 4- Formatting and saving the results. + */ - const resultsByDataPoint = {}; - dataPointsForTestSuite.forEach( ( dataPoint ) => { - // @ts-ignore - resultsByDataPoint[ dataPoint ] = rawResults.map( - // @ts-ignore - ( r ) => r[ branch ][ dataPoint ] - ); - } ); - // @ts-ignore - const medians = Object.fromEntries( - Object.entries( resultsByDataPoint ).map( - ( [ dataPoint, dataPointResults ] ) => [ - dataPoint, - median( dataPointResults ), - ] - ) - ); + // Load curated results from each round. + const resultFiles = getFilesFromDir( ARTIFACTS_PATH ).filter( ( file ) => + file.endsWith( RESULTS_FILE_SUFFIX ) + ); + /** @type {Record>>} */ + const results = {}; - // Format results as times. - // @ts-ignore - results[ testSuite ][ branch ] = Object.fromEntries( - Object.entries( medians ).map( - ( [ dataPoint, dataPointMedian ] ) => [ - dataPoint, - formatTime( dataPointMedian ), - ] + // Calculate medians from all rounds. + for ( const testSuite of testSuites ) { + results[ testSuite ] = {}; + + for ( const branch of branches ) { + const sanitizedBranch = sanitizeBranchName( branch ); + const resultsRounds = resultFiles + .filter( ( file ) => + file.includes( + `${ testSuite }_${ sanitizedBranch }_round-` + ) ) - ); + .map( ( file ) => readJSONFile( file ) ); + + const metrics = Object.keys( resultsRounds[ 0 ] ); + results[ testSuite ][ branch ] = {}; + + for ( const metric of metrics ) { + const values = resultsRounds + .map( ( round ) => round[ metric ] ) + .filter( ( value ) => typeof value === 'number' ); + + const value = median( values ); + if ( value !== undefined ) { + results[ testSuite ][ branch ][ metric ] = value; + } + } } + + // Save calculated results to file. + fs.writeFileSync( + path.join( ARTIFACTS_PATH, testSuite + RESULTS_FILE_SUFFIX ), + JSON.stringify( results[ testSuite ], null, 2 ) + ); } - // 5- Formatting the results. - log( '\n>> 🎉 Results.\n' ); + /* + * 5- Displaying the results. + */ + log( '\n>> 🎉 Results.\n' ); log( '\nPlease note that client side metrics EXCLUDE the server response time.\n' ); @@ -507,31 +398,20 @@ async function runPerformanceTests( branches, options ) { for ( const testSuite of testSuites ) { log( `\n>> ${ testSuite }\n` ); + // Invert the results so we can display them in a table. /** @type {Record>} */ const invertedResult = {}; - Object.entries( results[ testSuite ] ).reduce( - ( acc, [ key, val ] ) => { - for ( const entry of Object.keys( val ) ) { - // @ts-ignore - if ( ! acc[ entry ] && isFinite( val[ entry ] ) ) - acc[ entry ] = {}; - // @ts-ignore - if ( isFinite( val[ entry ] ) ) { - // @ts-ignore - acc[ entry ][ key ] = val[ entry ] + ' ms'; - } - } - return acc; - }, - invertedResult - ); - console.table( invertedResult ); + for ( const [ branch, metrics ] of Object.entries( + results[ testSuite ] + ) ) { + for ( const [ metric, value ] of Object.entries( metrics ) ) { + invertedResult[ metric ] = invertedResult[ metric ] || {}; + invertedResult[ metric ][ branch ] = `${ value } ms`; + } + } - const resultsFilename = testSuite + '.performance-results.json'; - fs.writeFileSync( - path.join( ARTIFACTS_PATH, resultsFilename ), - JSON.stringify( results[ testSuite ], null, 2 ) - ); + // Print the results. + console.table( invertedResult ); } } diff --git a/bin/plugin/commands/test/__snapshots__/changelog.js.snap b/bin/plugin/commands/test/__snapshots__/changelog.js.snap index 2e79ecda56a7c0..571019ea3dca9c 100644 --- a/bin/plugin/commands/test/__snapshots__/changelog.js.snap +++ b/bin/plugin/commands/test/__snapshots__/changelog.js.snap @@ -8,7 +8,6 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` #### Components - Add new ColorPicker. ([33714](https://github.com/WordPress/gutenberg/pull/33714)) - Update snackbar to use framer motion instead of react spring. ([33717](https://github.com/WordPress/gutenberg/pull/33717)) -- Use updated range styles. ([33824](https://github.com/WordPress/gutenberg/pull/33824)) #### Block Library - [Post Featured Image]: Add basic dimension controls. ([31634](https://github.com/WordPress/gutenberg/pull/31634)) @@ -76,13 +75,18 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` #### Meta Boxes - Fix Safari 13 metaboxes from overlapping the content. ([33817](https://github.com/WordPress/gutenberg/pull/33817)) -#### Accessibility -- Fix some JAWS bugs. ([33627](https://github.com/WordPress/gutenberg/pull/33627)) - #### Template Editor - Template: Only show post template actions to users with correct capabilities. ([33392](https://github.com/WordPress/gutenberg/pull/33392)) +### Accessibility + +- Fix some JAWS bugs. ([33627](https://github.com/WordPress/gutenberg/pull/33627)) + +#### Components +- Use updated range styles. ([33824](https://github.com/WordPress/gutenberg/pull/33824)) + + ### Performance - Avoid double parsing the content when loading the editor. ([33727](https://github.com/WordPress/gutenberg/pull/33727)) @@ -173,8 +177,6 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` ### Various - Core Data: Deprecate \`getAuthors\` in favor of \`getUsers\`. ([33725](https://github.com/WordPress/gutenberg/pull/33725)) -- RNMobile: Add integration test guide. ([33833](https://github.com/WordPress/gutenberg/pull/33833)) -- RNMobile: Try unifying the unit test command on mobile. ([33657](https://github.com/WordPress/gutenberg/pull/33657)) - Tune appender margin. ([33866](https://github.com/WordPress/gutenberg/pull/33866)) #### Block Library @@ -192,7 +194,6 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` - Try to fix flaky customizer inspector test. ([33890](https://github.com/WordPress/gutenberg/pull/33890)) #### Block Editor -- Closing the block inserter decrements block type impressions. ([33906](https://github.com/WordPress/gutenberg/pull/33906)) - Enable rich previews for internal links. ([33086](https://github.com/WordPress/gutenberg/pull/33086)) #### Icons diff --git a/bin/plugin/commands/test/changelog.js b/bin/plugin/commands/test/changelog.js index 52a9391af11d13..7bcdcf61377e34 100644 --- a/bin/plugin/commands/test/changelog.js +++ b/bin/plugin/commands/test/changelog.js @@ -51,7 +51,7 @@ describe( 'getNormalizedTitle', () => { undefined, { ...DEFAULT_ISSUE, - labels: [ { name: 'Mobile App Android/iOS' } ], + labels: [ { name: 'Mobile App - i.e. Android or iOS' } ], }, ], [ diff --git a/bin/plugin/commands/test/fixtures/pull-requests.json b/bin/plugin/commands/test/fixtures/pull-requests.json index 34f3c06c5d8546..d114d8b9149118 100644 --- a/bin/plugin/commands/test/fixtures/pull-requests.json +++ b/bin/plugin/commands/test/fixtures/pull-requests.json @@ -5747,13 +5747,13 @@ "description": "/packages/components" }, { - "id": 1344464662, - "node_id": "MDU6TGFiZWwxMzQ0NDY0NjYy", - "url": "https://api.github.com/repos/WordPress/gutenberg/labels/[a11y]%20Keyboard%20&%20Focus", - "name": "[a11y] Keyboard & Focus", - "color": "efde5d", + "id": 546517042, + "node_id": "MDU6TGFiZWw1NDY1MTcwNDI=", + "url": "https://api.github.com/repos/WordPress/gutenberg/labels/Accessibility%20(a11y)", + "name": "[Type] Accessibility (a11y)", + "color": "655104", "default": false, - "description": "" + "description": "Changes that impact accessibility and need corresponding review (e.g. markup changes)." } ], "state": "closed", @@ -12552,7 +12552,7 @@ "id": 546517042, "node_id": "MDU6TGFiZWw1NDY1MTcwNDI=", "url": "https://api.github.com/repos/WordPress/gutenberg/labels/Accessibility%20(a11y)", - "name": "Accessibility (a11y)", + "name": "[Type] Accessibility (a11y)", "color": "655104", "default": false, "description": "Changes that impact accessibility and need corresponding review (e.g. markup changes)." diff --git a/bin/plugin/lib/utils.js b/bin/plugin/lib/utils.js index c50094321710ca..4f57269d60c772 100644 --- a/bin/plugin/lib/utils.js +++ b/bin/plugin/lib/utils.js @@ -37,8 +37,9 @@ function runShellScript( script, cwd, env = {} ) { ...env, }, }, - function ( error, _, stderr ) { + function ( error, stdout, stderr ) { if ( error ) { + console.log( stdout ); // Sometimes the error message is thrown via stdout. console.log( stderr ); reject( error ); } else { @@ -120,10 +121,30 @@ function getRandomTemporaryPath() { return path.join( os.tmpdir(), uuid() ); } +/** + * Scans the given directory and returns an array of file paths. + * + * @param {string} dir The path to the directory to scan. + * + * @return {string[]} An array of file paths. + */ +function getFilesFromDir( dir ) { + if ( ! fs.existsSync( dir ) ) { + console.log( 'Directory does not exist: ', dir ); + return []; + } + + return fs + .readdirSync( dir, { withFileTypes: true } ) + .filter( ( dirent ) => dirent.isFile() ) + .map( ( dirent ) => path.join( dir, dirent.name ) ); +} + module.exports = { askForConfirmation, runStep, readJSONFile, runShellScript, getRandomTemporaryPath, + getFilesFromDir, }; diff --git a/bin/test-create-block.sh b/bin/test-create-block.sh index e17bdbb2d66946..7959334a8e30ec 100755 --- a/bin/test-create-block.sh +++ b/bin/test-create-block.sh @@ -55,7 +55,7 @@ if [ "$expected" -ne "$actual" ]; then error "Expected $expected files in the project root, but found $actual." exit 1 fi -expected=6 +expected=7 actual=$( find src -maxdepth 1 -type f | wc -l ) if [ "$expected" -ne "$actual" ]; then error "Expected $expected files in the \`src\` directory, but found $actual." @@ -69,7 +69,7 @@ status "Building block..." ../node_modules/.bin/wp-scripts build status "Verifying build..." -expected=5 +expected=7 actual=$( find build -maxdepth 1 -type f | wc -l ) if [ "$expected" -ne "$actual" ]; then error "Expected $expected files in the \`build\` directory, but found $actual." diff --git a/bin/validate-package-lock.js b/bin/validate-package-lock.js index ec33014cbfee03..c08e0beafa2a67 100755 --- a/bin/validate-package-lock.js +++ b/bin/validate-package-lock.js @@ -20,7 +20,7 @@ const { red, yellow } = require( 'chalk' ); // @ts-ignore const packageLock = require( '../package-lock' ); -const dependencies = Object.entries( packageLock.dependencies ); +const dependencies = Object.entries( packageLock.packages ); for ( const [ name, dependency ] of dependencies ) { if ( dependency.resolved === false ) { console.log( diff --git a/changelog.txt b/changelog.txt index a86bea24af4db0..0f4f5bcf9bbcf1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,20 +1,2892 @@ == Changelog == -= 15.9.0-rc.1 = += 16.6.0-rc.1 = -There has been an error with the Github action that creates the backlog. It will be updated as soon as possible. More info +## Changelog + +### Features + +#### Interactivity API +- Add Slot and Fill directives. ([53958](https://github.com/WordPress/gutenberg/pull/53958)) +- Query block: Client-side pagination. ([53812](https://github.com/WordPress/gutenberg/pull/53812)) +- Update `data-wp-bind` directive logic. ([54003](https://github.com/WordPress/gutenberg/pull/54003)) + + +### Enhancements + +- Bundle ObserveTyping within the BlockList component. ([53875](https://github.com/WordPress/gutenberg/pull/53875)) +- Default appender: Hide the dashed indicator until ancestor is selected. ([53761](https://github.com/WordPress/gutenberg/pull/53761)) +- Register the block editor keyboard shortcuts automatically when using BlockEditorProvider. ([53910](https://github.com/WordPress/gutenberg/pull/53910)) +- [Commands]: Add toggle list view command in site editor. ([53983](https://github.com/WordPress/gutenberg/pull/53983)) + +#### Components +- Bundle SlotFillProvider within BlockEditorProvider. ([53940](https://github.com/WordPress/gutenberg/pull/53940)) +- Make the Popover.Slot optional. ([53889](https://github.com/WordPress/gutenberg/pull/53889)) +- Popover: Update `@floating-ui` to latest version, remove custom fix for iframe positioning and scaling. ([46845](https://github.com/WordPress/gutenberg/pull/46845)) +- `AlignmentMatrixControl`: Replace `act()` with `userEvent`. ([53703](https://github.com/WordPress/gutenberg/pull/53703)) +- `ProgressBar`: Add transition to determinate indicator. ([53877](https://github.com/WordPress/gutenberg/pull/53877)) + +#### Block Library +- Blocks: Move bootstrapped block types to Redux state. ([53807](https://github.com/WordPress/gutenberg/pull/53807)) +- Capture toolbars in navigation block. ([53697](https://github.com/WordPress/gutenberg/pull/53697)) +- Content Block: Change placeholder and end-to-end test to refer to Content block. ([53902](https://github.com/WordPress/gutenberg/pull/53902)) +- Make mid size parameter settable for Query Pagination block. ([51216](https://github.com/WordPress/gutenberg/pull/51216)) + +#### Block Editor +- Capture toolbars in quote block. ([53699](https://github.com/WordPress/gutenberg/pull/53699)) +- Improve writing flow for lists by capturing list item toolbars. ([53306](https://github.com/WordPress/gutenberg/pull/53306)) +- RichTextValue: Typescript Adjustment. ([54002](https://github.com/WordPress/gutenberg/pull/54002)) + +#### Typography +- Font Face: Prepare for merge into Core. ([53858](https://github.com/WordPress/gutenberg/pull/53858)) +- Renames "Fonts Library" to "Font Library". ([53780](https://github.com/WordPress/gutenberg/pull/53780)) + +#### Post Editor +- Edit Post: Use hooks instead of HoCs in `TaxonomyPanel`. ([53773](https://github.com/WordPress/gutenberg/pull/53773)) + +#### List View +- Add keyboard shortcut for duplicating blocks. ([53559](https://github.com/WordPress/gutenberg/pull/53559)) + +#### Patterns +- Add a custom taxonomy for user created patterns. ([53163](https://github.com/WordPress/gutenberg/pull/53163)) + + +### New APIs + +#### Interactivity API +- Router with region-based client-side navigation. ([53733](https://github.com/WordPress/gutenberg/pull/53733)) + + +### Bug Fixes + +- Add missing aria roles for block locking toolbar and menu buttons. ([53734](https://github.com/WordPress/gutenberg/pull/53734)) +- Block Editor: Fix cleanup in the 'useNavModeExit' hook. ([53795](https://github.com/WordPress/gutenberg/pull/53795)) +- Command Palette: Fix crash on block-related commands. ([53923](https://github.com/WordPress/gutenberg/pull/53923)) +- Date: Add relative time translations for moment.js. ([53931](https://github.com/WordPress/gutenberg/pull/53931)) +- Date: Update translation domains for strings to be translatable. ([53995](https://github.com/WordPress/gutenberg/pull/53995)) +- Iframe: Set character encoding to utf-8. ([53519](https://github.com/WordPress/gutenberg/pull/53519)) +- Replace horizontal ellipsis icon with vertical ellipsis icon. ([52731](https://github.com/WordPress/gutenberg/pull/52731)) +- Toggle Distraction free mode mode based on compatibility. ([54030](https://github.com/WordPress/gutenberg/pull/54030)) +- Warning: Introduce `SCRIPT_DEBUG` to make the package compatible with webpack 5. ([50122](https://github.com/WordPress/gutenberg/pull/50122)) +- [Commands]: Fix `move to` command condition for registering. ([54049](https://github.com/WordPress/gutenberg/pull/54049)) +- [Commands]: Fix block editor commands availability. ([53994](https://github.com/WordPress/gutenberg/pull/53994)) +- [Format library]: Fix `language` popover position. ([53841](https://github.com/WordPress/gutenberg/pull/53841)) + +#### Block Library +- Add init.js module for the Footnotes block. ([53763](https://github.com/WordPress/gutenberg/pull/53763)) +- Adding center align css for social icon issue. ([43120](https://github.com/WordPress/gutenberg/pull/43120)) +- Cover block: Fix exception when adding video background. ([53961](https://github.com/WordPress/gutenberg/pull/53961)) +- List View: Allow replacing template part when a block isn't selected. ([53757](https://github.com/WordPress/gutenberg/pull/53757)) +- Post Navigation Link: Remove unnecessary space between arrows and label. ([53572](https://github.com/WordPress/gutenberg/pull/53572)) +- Search block: Fix width input field. ([53952](https://github.com/WordPress/gutenberg/pull/53952)) +- Simplify check for no posts in query-no-results block. ([53772](https://github.com/WordPress/gutenberg/pull/53772)) +- Site Logo: Remove line-height for the anchor element. ([53909](https://github.com/WordPress/gutenberg/pull/53909)) + +#### Components +- Always render the fallback Popover anchor within the Popover's parent element. ([53982](https://github.com/WordPress/gutenberg/pull/53982)) +- Fix the cleanup method for SandBox. ([53796](https://github.com/WordPress/gutenberg/pull/53796)) +- PaletteEdit: Fix component height. ([54000](https://github.com/WordPress/gutenberg/pull/54000)) + +#### Post Editor +- Edit Post: Fix tab border conflicts in the Document Overview panel. ([53711](https://github.com/WordPress/gutenberg/pull/53711)) +- EditPostPreferencesModal: Fix intermittently failing tests. ([53814](https://github.com/WordPress/gutenberg/pull/53814)) +- getInsertionPoint: Fix type check for the state value. ([53793](https://github.com/WordPress/gutenberg/pull/53793)) + +#### npm Packages +- Workflow: Run Learn directly from GitHub action when publishing to npm targeting WP core. ([53762](https://github.com/WordPress/gutenberg/pull/53762)) +- Workflows: Fix issues with the npm publishing workflow when using locally. ([53565](https://github.com/WordPress/gutenberg/pull/53565)) + +#### Themes +- Command Palette: Proper handling of page/post links in all themes. ([53718](https://github.com/WordPress/gutenberg/pull/53718)) +- Fix query loop bugs by correctly relying on the main query and removing problematic workaround. ([49904](https://github.com/WordPress/gutenberg/pull/49904)) + +#### Block Editor +- Fix: Indicator style when block moving mode. ([53972](https://github.com/WordPress/gutenberg/pull/53972)) + +#### Icons +- Fix invalid namespaces. ([53955](https://github.com/WordPress/gutenberg/pull/53955)) + +#### Patterns +- Disable the preview option button when editing. ([53913](https://github.com/WordPress/gutenberg/pull/53913)) + +#### Global Styles +- Gallery: Re-enable block spacing at block level while still hiding in global styles. ([53900](https://github.com/WordPress/gutenberg/pull/53900)) + +#### Layout +- BlockList: Ensure element styles (and svg) are always appended at the end of the document. ([53859](https://github.com/WordPress/gutenberg/pull/53859)) + +#### Interactivity API +- Add "supports.interactivity" to Image block. ([53850](https://github.com/WordPress/gutenberg/pull/53850)) + +#### Style Variations +- Block Styles: Fix misplaced preview popover on RTL site. ([53726](https://github.com/WordPress/gutenberg/pull/53726)) + +#### List View +- Recalculate window list when expanded state changes (fix logic for long nested lists). ([53716](https://github.com/WordPress/gutenberg/pull/53716)) + +#### Widgets Editor +- Block Widget: Fix content cutoff in the keyboard shortcut modal. ([53638](https://github.com/WordPress/gutenberg/pull/53638)) + +#### Rich Text +- Fix cleanup in `useRemoveBrowserShortcuts`. ([52225](https://github.com/WordPress/gutenberg/pull/52225)) + + +### Accessibility + +- Edit site: Add missing label to post status password protected input field. ([52885](https://github.com/WordPress/gutenberg/pull/52885)) +- [a11y] Fix: Aria-haspop, aria-expanded attributes on the link format button. ([53691](https://github.com/WordPress/gutenberg/pull/53691)) + +#### Site Editor +- Add missing aria roles to the 'Create template part' menu item. ([53754](https://github.com/WordPress/gutenberg/pull/53754)) +- Unify the delete button style in the dropdown menu with red. ([52597](https://github.com/WordPress/gutenberg/pull/52597)) + +#### Block Library +- Add missing aria roles to the 'Replace template part' menu item. ([53755](https://github.com/WordPress/gutenberg/pull/53755)) + +#### Patterns +- Add missing aria roles to the 'Create pattern' menu item. ([53739](https://github.com/WordPress/gutenberg/pull/53739)) + +#### List View +- [a11y] Fix: Aria-haspop and aria-expanded attributes on list view button. ([53693](https://github.com/WordPress/gutenberg/pull/53693)) + +#### Block Editor +- [a11y] Fix: Aria-haspop and aria-expanded attributes on the inserter button. ([53692](https://github.com/WordPress/gutenberg/pull/53692)) + + +### Performance + +- Revert "Switch performance tests to Playwright (#52022)". ([53741](https://github.com/WordPress/gutenberg/pull/53741)) +- StartPageOptions: Load and parse patterns only after establishing the need for them. ([53673](https://github.com/WordPress/gutenberg/pull/53673)) +- Switch performance tests to Playwright: Take 2. ([53768](https://github.com/WordPress/gutenberg/pull/53768)) + + +### Experiments + +#### Block API +- Auto-inserting blocks: Add block inspector panel. ([52969](https://github.com/WordPress/gutenberg/pull/52969)) + + +### Documentation + +- Add juanmaguitar as codeowner of /packages/interactivity/docs. ([53845](https://github.com/WordPress/gutenberg/pull/53845)) +- Add new How-to Guide for enqueueing assets in the Editor. ([53828](https://github.com/WordPress/gutenberg/pull/53828)) +- Adds example for useBlockProps hook. ([53646](https://github.com/WordPress/gutenberg/pull/53646)) +- Adds explanatory text to view.js template. ([53870](https://github.com/WordPress/gutenberg/pull/53870)) +- Clarification for `parent` and `ancestor` hierarchical relationships. ([53855](https://github.com/WordPress/gutenberg/pull/53855)) +- Docs: Extend the information about using `render` with `block.json`. ([53973](https://github.com/WordPress/gutenberg/pull/53973)) +- Docs: Remove duplicate sections from FAQ page. ([53830](https://github.com/WordPress/gutenberg/pull/53830)) +- Document the naming convention for `block-library` PHP functions. ([53777](https://github.com/WordPress/gutenberg/pull/53777)) +- Fix 'lerna' links in the release documentation. ([53770](https://github.com/WordPress/gutenberg/pull/53770)) +- Fix typo in code sample for Interactivity API. ([53916](https://github.com/WordPress/gutenberg/pull/53916)) +- MenuItem: Add Storybook stories. ([53613](https://github.com/WordPress/gutenberg/pull/53613)) +- Shortcut: Add Storybook stories. ([53627](https://github.com/WordPress/gutenberg/pull/53627)) +- Storybook: Add back subcomponents to props table. ([53751](https://github.com/WordPress/gutenberg/pull/53751)) +- Storybook: Fix default source visibility. ([53749](https://github.com/WordPress/gutenberg/pull/53749)) +- Storybook: Show main story before description. ([53753](https://github.com/WordPress/gutenberg/pull/53753)) +- Update local instructions on the dev env documentation. ([53924](https://github.com/WordPress/gutenberg/pull/53924)) +- Update the Block Variations API doc. ([53817](https://github.com/WordPress/gutenberg/pull/53817)) +- Update to node 16 and npm 8 in the getting started with code contribution doc. ([53912](https://github.com/WordPress/gutenberg/pull/53912)) +- docs: Fix report-flaky-test link. ([53848](https://github.com/WordPress/gutenberg/pull/53848)) + + +### Code Quality + +- Components: Update Popover per reviews. ([53907](https://github.com/WordPress/gutenberg/pull/53907)) +- Edit Site: Rename `CanvasSpinner` to `CanvasLoader`. ([53728](https://github.com/WordPress/gutenberg/pull/53728)) +- Enforce valid function names in the packages/block-library/src/*/*.php files. ([53438](https://github.com/WordPress/gutenberg/pull/53438)) +- Fonts Library: Update properties name from snake case to camel case to match the rest of the properties. ([53746](https://github.com/WordPress/gutenberg/pull/53746)) + +#### Post Editor +- Editor: Fix the 'useSelect' warning in the 'useIsDirty' hook. ([53759](https://github.com/WordPress/gutenberg/pull/53759)) +- Fix browser console error when changing device preview mode. ([53969](https://github.com/WordPress/gutenberg/pull/53969)) +- Refactor latest content selectors in 'CopyContentMenuItem' components. ([53676](https://github.com/WordPress/gutenberg/pull/53676)) + +#### Components +- Remove unnecessary utils. ([53679](https://github.com/WordPress/gutenberg/pull/53679)) +- SlotFill: Refactor ``. ([53272](https://github.com/WordPress/gutenberg/pull/53272)) +- Storybook: Update TypeScript types. ([53748](https://github.com/WordPress/gutenberg/pull/53748)) + +#### List View +- Fix warning error when the gallery block has the same image URLs. ([53809](https://github.com/WordPress/gutenberg/pull/53809)) + +#### Typography +- Font Face API: Use `gutenberg_get_global_settings` instead of private API. ([53805](https://github.com/WordPress/gutenberg/pull/53805)) + + +### Tools + +- Try: Change PR label enforcer automation not to work on draft PRs by default. ([53417](https://github.com/WordPress/gutenberg/pull/53417)) + +#### Testing +- Attempt to fix intermittent end-to-end test failure. ([53905](https://github.com/WordPress/gutenberg/pull/53905)) +- Fonts Library: Test improvements. ([53702](https://github.com/WordPress/gutenberg/pull/53702)) +- Make fonts test files use Core approach. ([53856](https://github.com/WordPress/gutenberg/pull/53856)) +- Migrate shortcut help end-to-end tests to Playwright. ([53832](https://github.com/WordPress/gutenberg/pull/53832)) +- Relocates Font Face and Fonts Library PHP files into Core's fonts directory. ([53747](https://github.com/WordPress/gutenberg/pull/53747)) +- `ColorPalette`: Refine test query. ([53704](https://github.com/WordPress/gutenberg/pull/53704)) +- end-to-end Playwright Utils: Automatically detect canvas type. ([53744](https://github.com/WordPress/gutenberg/pull/53744)) +- test: Automate mobile editor tests. ([53991](https://github.com/WordPress/gutenberg/pull/53991)) + +#### Build Tooling +- Update Jest to latest version, and use optimized JSDOM. ([53736](https://github.com/WordPress/gutenberg/pull/53736)) + +#### Plugin +- Backport themes `is_block_theme` collection param from core. ([53846](https://github.com/WordPress/gutenberg/pull/53846)) + + +## First time contributors + +The following PRs were merged by first time contributors: + +- @JEverhart383: Fix typo in code sample for Interactivity API. ([53916](https://github.com/WordPress/gutenberg/pull/53916)) +- @krokodok: Make mid size parameter settable for Query Pagination block. ([51216](https://github.com/WordPress/gutenberg/pull/51216)) +- @mklute101: Update local instructions on the dev env documentation. ([53924](https://github.com/WordPress/gutenberg/pull/53924)) + + +## Contributors + +The following contributors merged PRs in this release: + +@afercia @andrewserong @anton-vlasenko @bangank36 @brookewp @ciampo @colorful-tones @DAreRodz @dcalhoun @derekblank @ellatrix @felixarntz @geriux @glendaviesnz @gziolo @hellofromtonya @jasmussen @jblz @JEverhart383 @jordesign @jorgefilipecosta @jsnajdr @juanmaguitar @krokodok @luisherranz @Mamaduka @margolisj @matiasbenedetto @mburridge @mirka @mklute101 @mokagio @ndiego @ntsekouras @oandregal @ocean90 @ockham @priethor @ramonjd @richtabor @SiobhyB @Smit2808 @stokesman @t-hamano @torounit @tyxla @walbo @WunderBart @youknowriad + + += 16.5.1 = + + + +## Changelog + +### Bug Fixes + +#### Block Editor +- Pass entire link value on toggle of setting on Link Preview. ([53949](https://github.com/WordPress/gutenberg/pull/53949)) + + + + +## Contributors + +The following contributors merged PRs in this release: + +@getdave + + += 16.5.0 = + + + +## Changelog + +### Features + +#### Interactivity API +- Allow passing optional `afterLoad` callbacks to `store` calls. ([53363](https://github.com/WordPress/gutenberg/pull/53363)) + +### Enhancements + +#### Commands +- Add block-related commands. ([52509](https://github.com/WordPress/gutenberg/pull/52509)) +- Add support for registering commands without icons. ([53647](https://github.com/WordPress/gutenberg/pull/53647)) +- Update the "Preview in a new tab" command to reuse the preview target tab when available. ([53242](https://github.com/WordPress/gutenberg/pull/53242)) +- Update command palette styling. ([53117](https://github.com/WordPress/gutenberg/pull/53117)) +- Improve command palette rendering on smaller viewports. ([53661](https://github.com/WordPress/gutenberg/pull/53661)) +- Tweak existing commands to establish consistency with command language. ([53496](https://github.com/WordPress/gutenberg/pull/53496)) +- End the command palette description with a period in the keyboard shortcut modal. ([53635](https://github.com/WordPress/gutenberg/pull/53635)) + +#### Components +- Button: Remove default border from the destructive button. ([53607](https://github.com/WordPress/gutenberg/pull/53607)) +- LineHeightControl: Allow for more granular control of decimal places. ([52902](https://github.com/WordPress/gutenberg/pull/52902)) +- Snackbar: Design and motion improvements. ([53248](https://github.com/WordPress/gutenberg/pull/53248)) +- Modal: + - Add `headerActions` prop to enable buttons or other elements to be injected in the header. ([53328](https://github.com/WordPress/gutenberg/pull/53328)) + - Enhance overlay interactions, enabling outside interactions without dismissal. ([52994](https://github.com/WordPress/gutenberg/pull/52994)) +- ProgressBar: Update colors, including gray 300 for track color ([53349](https://github.com/WordPress/gutenberg/pull/53349)), theme system accent for indicator color ([53347](https://github.com/WordPress/gutenberg/pull/53347)), and the theme accent color variable. ([53632](https://github.com/WordPress/gutenberg/pull/53632)). + +#### Block Library +- Column block: + - Add a `stretch` option to block's vertical alignment options. ([53325](https://github.com/WordPress/gutenberg/pull/53325)) + - Exit upon pressing enter in an empty paragraph at the end of the block. ([53311](https://github.com/WordPress/gutenberg/pull/53311)) +- Classic block: Increase dimensions of modal and allow toggling fullscreen. ([53449](https://github.com/WordPress/gutenberg/pull/53449)) +- Details block: + - Add `accordion` and `toggle` keywords to improve block's discoverability. ([53501](https://github.com/WordPress/gutenberg/pull/53501)) + - Add layout and block spacing options. ([53282](https://github.com/WordPress/gutenberg/pull/53282)) +- File block: Add block spacing options. ([45107](https://github.com/WordPress/gutenberg/pull/45107)) +- Image block: Add aspect ratio support to lightbox. ([52765](https://github.com/WordPress/gutenberg/pull/52765)) +- Post Content block: Add color controls. ([51326](https://github.com/WordPress/gutenberg/pull/51326)) +- Remove "post" from block titles. ([53492](https://github.com/WordPress/gutenberg/pull/53492)) + +#### Patterns +- Open detail view when duplicating a pattern. ([53214](https://github.com/WordPress/gutenberg/pull/53214)) +- Prevent the "create pattern" modal from closing the block options menu when it is closed. ([53707](https://github.com/WordPress/gutenberg/pull/53707)) +- Skip migration logs in the patterns screen. ([53626](https://github.com/WordPress/gutenberg/pull/53626)) +- Add missing full stop to string. ([53544](https://github.com/WordPress/gutenberg/pull/53544)) + +#### Global Styles +- Add a reset to default global styles revision ([52965](https://github.com/WordPress/gutenberg/pull/52965)) and reduce visibility check from two to one revision ([53281](https://github.com/WordPress/gutenberg/pull/53281)). + +#### Media +- Adjust size of image previews in list view. ([53649](https://github.com/WordPress/gutenberg/pull/53649)) +- Add media previews to list view for gallery and image blocks. ([53381](https://github.com/WordPress/gutenberg/pull/53381)) + +#### Site Editor +- Expose `Theme` via private APIs ([53262](https://github.com/WordPress/gutenberg/pull/53262)), which was necessary to use the progress bar component for the site editor loading screen ([53032](https://github.com/WordPress/gutenberg/pull/53032)). + +#### Block Editor +- Add `Opens in new Tab` control into Link Preview. ([53566](https://github.com/WordPress/gutenberg/pull/53566)) +- Dependencies: Bump `remove-accents` to 0.5.0. ([53420](https://github.com/WordPress/gutenberg/pull/53420)) +- Top-align Publish row in the post panel. ([53573](https://github.com/WordPress/gutenberg/pull/53573)) +- Allow layout controls to be disabled per block from theme.json. ([53378](https://github.com/WordPress/gutenberg/pull/53378)) +- Fluid typography: Add min and max viewport width configurable options. ([53081](https://github.com/WordPress/gutenberg/pull/53081)) + + +### New APIs + +#### Extensibility +- Make `useBlockEditingMode()` public. ([52094](https://github.com/WordPress/gutenberg/pull/52094)) + + +### Bug Fixes + +#### Commands +- Style tweaks to fix metrics for resting and no results view in command palette. ([53497](https://github.com/WordPress/gutenberg/pull/53497)) +- Order template results in Site Editor, to fix some templates not displaying. ([53286](https://github.com/WordPress/gutenberg/pull/53286)) +- Don't allow access to Styles-related pages via the command palette in the hybrid theme. ([53123](https://github.com/WordPress/gutenberg/pull/53123)) + +#### Block Library +- Button block: Avoid losing user changes when the `ButtonEdit` component re-renders. ([53507](https://github.com/WordPress/gutenberg/pull/53507)) +- Cover block: Fix flickering when inserted in templates and also fix `isDark` calculation bugs. ([53253](https://github.com/WordPress/gutenberg/pull/53253)) +- Footnotes block: + - Ensure autosave works and escapes quotes as expected. ([53664](https://github.com/WordPress/gutenberg/pull/53664)) + - Fix accidental override. ([53663](https://github.com/WordPress/gutenberg/pull/53663)) + - Fix recursion into updating attributes when attributes is not an object. ([53257](https://github.com/WordPress/gutenberg/pull/53257)) + - Remove Footnotes when interactive formatting is disabled. ([53474](https://github.com/WordPress/gutenberg/pull/53474)) +- Image block: + - Fix image stretching with only height. ([53443](https://github.com/WordPress/gutenberg/pull/53443)) + - Don't render `DimensionsTool` if it is not resizable. ([53181](https://github.com/WordPress/gutenberg/pull/53181)) + - Fix stretched images constrained by max-width. ([53274](https://github.com/WordPress/gutenberg/pull/53274)) + - Clear aspect ratio when wide aligned. ([53439](https://github.com/WordPress/gutenberg/pull/53439)) + - Change the conditions under which we display the scale control. ([53334](https://github.com/WordPress/gutenberg/pull/53334)) + - Reset height when selecting the original aspect ratio. ([53339](https://github.com/WordPress/gutenberg/pull/53339)) +- Latest Posts block: Make categories handling more defensive to prevent multisite error. ([53659](https://github.com/WordPress/gutenberg/pull/53659)) +- Media & Text block: Fix deprecation with `isStackOnMobile` default value changed. ([49538](https://github.com/WordPress/gutenberg/pull/49538)) +- Inject theme stylesheet value as template part theme attribute. ([53423](https://github.com/WordPress/gutenberg/pull/53423)) +- Block serialization: Correctly compare default attribute values. ([53521](https://github.com/WordPress/gutenberg/pull/53521)) + +#### Block Editor +- LinkControl: Prevent overflow when the title is a URL. ([53356](https://github.com/WordPress/gutenberg/pull/53356)) +- Fix broken flows on Safari, including `ArrowUp` functionality in an empty paragraph ([53341](https://github.com/WordPress/gutenberg/pull/53341)) and multi-selection upon shift plus click ([53440](https://github.com/WordPress/gutenberg/pull/53440)). +- Restore focus after dragging out of the block repeatedly. ([53429](https://github.com/WordPress/gutenberg/pull/53429)) +- Avoid merging paragraph into a Columns block. ([53508](https://github.com/WordPress/gutenberg/pull/53508)) +- Prevent vertical arrow keys getting stuck in view. ([53454](https://github.com/WordPress/gutenberg/pull/53454)) +- Set top toolbar size dynamically. ([53526](https://github.com/WordPress/gutenberg/pull/53526)) +- Support container queries in editor CSS. ([49915](https://github.com/WordPress/gutenberg/pull/49915)) +- Copy tag name on internal paste. ([48254](https://github.com/WordPress/gutenberg/pull/48254)) + +#### Site Editor +- Add missing i18n in `HomeTemplateDetails`. ([53543](https://github.com/WordPress/gutenberg/pull/53543)) +- Add buttons for block settings and styles in smaller viewport. ([53412](https://github.com/WordPress/gutenberg/pull/53412)) +- Ensure canvas edit mode button occupies the entire frame canvas. ([53730](https://github.com/WordPress/gutenberg/pull/53730)) +- Fix document actions label helper method. ([52974](https://github.com/WordPress/gutenberg/pull/52974)) +- Fix document title alignment in command palette button. ([53224](https://github.com/WordPress/gutenberg/pull/53224)) + +#### Post Editor +- Address crash by moving editor style logic into a hook with `useMemo`. ([53596](https://github.com/WordPress/gutenberg/pull/53596)) +- Fix support of sticky position in non-iframed post editor. ([53540](https://github.com/WordPress/gutenberg/pull/53540)) +- Correct typo when setting the preview device type to 'Desktop'. ([53409](https://github.com/WordPress/gutenberg/pull/53409)) +- Avoid returning a different object on every call to `getInsertionPoint`. ([53722](https://github.com/WordPress/gutenberg/pull/53722)) +- Fix top toolbar in the post editor with custom fields in Safari. ([53688](https://github.com/WordPress/gutenberg/pull/53688)) +- Improve metrics on post publish view buttons. ([53245](https://github.com/WordPress/gutenberg/pull/53245)) + +#### Page Content Focus +- Fix missing Replace button in content-locked Image blocks. ([53410](https://github.com/WordPress/gutenberg/pull/53410)) +- Fix BlockPreview in Template panel when editing a page in the site editor. ([53550](https://github.com/WordPress/gutenberg/pull/53550)) +- Use `template.blocks` in BlockPreview if it exists. ([53611](https://github.com/WordPress/gutenberg/pull/53611)) + +#### Navigation Menus +- Make all the 'Loading' strings consistent. ([52901](https://github.com/WordPress/gutenberg/pull/52901)) +- Fix title not being copied correctly when duplicating navigation. ([53610](https://github.com/WordPress/gutenberg/pull/53610)) +- Remove "go to" for terms and posts. ([53408](https://github.com/WordPress/gutenberg/pull/53408)) + +#### Typography +- Fallback to default max viewport if layout wide size is fluid. ([53551](https://github.com/WordPress/gutenberg/pull/53551)) +- Fix typo and add tests for fonts install endpoint. ([53644](https://github.com/WordPress/gutenberg/pull/53644)) + +#### Patterns +- Fix Synced Patterns' color in quick inserter. ([53327](https://github.com/WordPress/gutenberg/pull/53327)) +- Hide pattern previews on hover in inserter. ([53331](https://github.com/WordPress/gutenberg/pull/53331)) +- Ensure it's possible to delete draft patterns. ([53405](https://github.com/WordPress/gutenberg/pull/53405)) +- Fix pattern creation button in list view dropdown menu. ([53562](https://github.com/WordPress/gutenberg/pull/53562)) +- Prevent sync status overlapping for some languages in patterns. ([53243](https://github.com/WordPress/gutenberg/pull/53243)) + +#### Global Styles +- Fix push-to-global-styles clearing of attributes, border fallbacks, link hover colors, and behaviors. ([51621](https://github.com/WordPress/gutenberg/pull/51621)) +- Preserve block style variations when securing theme json. ([53466](https://github.com/WordPress/gutenberg/pull/53466)) + +#### Layout +- Don't add root padding to children of flex and grid layout blocks. ([53259](https://github.com/WordPress/gutenberg/pull/53259)) +- Include namespace in layout classname for non-core blocks. ([53404](https://github.com/WordPress/gutenberg/pull/53404)) + +#### Interactivity API +- Add short-circuit to `useSignalEffect`. ([53358](https://github.com/WordPress/gutenberg/pull/53358)) +- Add support for underscores and leading dashes in the suffix part of the directive. ([53337](https://github.com/WordPress/gutenberg/pull/53337)) +- Update deepsignal version. ([53549](https://github.com/WordPress/gutenberg/pull/53549)) + +#### Components +- Button: Add `:Disabled` selector to reset hover color for disabled buttons. ([53411](https://github.com/WordPress/gutenberg/pull/53411)) +- Preferences Modal: Insert path and query args to form before submitting. ([53324](https://github.com/WordPress/gutenberg/pull/53324)) + + +### Accessibility + +- Type labels GH Action: Fix accessibility issues in error message. ([53371](https://github.com/WordPress/gutenberg/pull/53371)) +- Add accessible description of current Navigation block state. ([53469](https://github.com/WordPress/gutenberg/pull/53469)) +- Implement accessible version of Navigation overlay preview toggle control. ([53462](https://github.com/WordPress/gutenberg/pull/53462)) +- Search Block: Fix unintended wrapping of button text in "Button only" style. ([53373](https://github.com/WordPress/gutenberg/pull/53373)) + + +### Performance + +- Compute presets from `theme.json`: Skip those without classes or variables. ([53574](https://github.com/WordPress/gutenberg/pull/53574)) +- Switch performance tests to Playwright. ([52022](https://github.com/WordPress/gutenberg/pull/52022)) +- Fix memory leaks in ` diff --git a/packages/block-editor/src/components/iframe/use-compatibility-styles.js b/packages/block-editor/src/components/iframe/use-compatibility-styles.js index 92e755b318ec45..eb738c7ebefdfe 100644 --- a/packages/block-editor/src/components/iframe/use-compatibility-styles.js +++ b/packages/block-editor/src/components/iframe/use-compatibility-styles.js @@ -38,12 +38,6 @@ export function useCompatibilityStyles() { return accumulator; } - // Generally, ignore inline styles. We add inline styles belonging to a - // stylesheet later, which may or may not match the selectors. - if ( ownerNode.tagName !== 'LINK' ) { - return accumulator; - } - // Don't try to add the reset styles, which were removed as a dependency // from `edit-blocks` for the iframe since we don't need to reset admin // styles. @@ -51,6 +45,11 @@ export function useCompatibilityStyles() { return accumulator; } + // Don't try to add styles without ID. Styles enqueued via the WP dependency system will always have IDs. + if ( ! ownerNode.id ) { + return accumulator; + } + function matchFromRules( _cssRules ) { return Array.from( _cssRules ).find( ( { @@ -76,20 +75,42 @@ export function useCompatibilityStyles() { } if ( matchFromRules( cssRules ) ) { - // Display warning once we have a way to add style dependencies to the editor. - // See: https://github.com/WordPress/gutenberg/pull/37466. - accumulator.push( ownerNode.cloneNode( true ) ); + const isInline = ownerNode.tagName === 'STYLE'; - // Add inline styles belonging to the stylesheet. - const inlineCssId = ownerNode.id.replace( - '-css', - '-inline-css' - ); - const inlineCssElement = - document.getElementById( inlineCssId ); + if ( isInline ) { + // If the current target is inline, + // it could be a dependency of an existing stylesheet. + // Look for that dependency and add it BEFORE the current target. + const mainStylesCssId = ownerNode.id.replace( + '-inline-css', + '-css' + ); + const mainStylesElement = + document.getElementById( mainStylesCssId ); + if ( mainStylesElement ) { + accumulator.push( + mainStylesElement.cloneNode( true ) + ); + } + } - if ( inlineCssElement ) { - accumulator.push( inlineCssElement.cloneNode( true ) ); + accumulator.push( ownerNode.cloneNode( true ) ); + + if ( ! isInline ) { + // If the current target is not inline, + // we still look for inline styles that could be relevant for the current target. + // If they exist, add them AFTER the current target. + const inlineStylesCssId = ownerNode.id.replace( + '-css', + '-inline-css' + ); + const inlineStylesElement = + document.getElementById( inlineStylesCssId ); + if ( inlineStylesElement ) { + accumulator.push( + inlineStylesElement.cloneNode( true ) + ); + } } } diff --git a/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js b/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js index c2e8a2cefe4773..5019da9c515d05 100644 --- a/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js +++ b/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js @@ -54,7 +54,7 @@ export default function AspectRatioDropdown( { toggleProps } ) { } } value={ aspect } aspectRatios={ [ - // All ratios should be mirrored in PostFeaturedImage in @wordpress/block-library + // All ratios should be mirrored in AspectRatioTool in @wordpress/block-editor. { title: __( 'Original' ), aspect: defaultAspect, diff --git a/packages/block-editor/src/components/image-editor/constants.js b/packages/block-editor/src/components/image-editor/constants.js index 92b58c628f7ae1..78ba8969a4fd5b 100644 --- a/packages/block-editor/src/components/image-editor/constants.js +++ b/packages/block-editor/src/components/image-editor/constants.js @@ -2,5 +2,4 @@ export const MIN_ZOOM = 100; export const MAX_ZOOM = 300; export const POPOVER_PROPS = { placement: 'bottom-start', - variant: 'toolbar', }; diff --git a/packages/block-editor/src/components/image-editor/use-save-image.js b/packages/block-editor/src/components/image-editor/use-save-image.js index 2d1515ff0e3f0e..dbd95323225cb7 100644 --- a/packages/block-editor/src/components/image-editor/use-save-image.js +++ b/packages/block-editor/src/components/image-editor/use-save-image.js @@ -66,7 +66,6 @@ export default function useSaveImage( { onSaveImage( { id: response.id, url: response.source_url, - height: height && width ? width / aspect : undefined, } ); } ) .catch( ( error ) => { diff --git a/packages/block-editor/src/components/image-size-control/index.js b/packages/block-editor/src/components/image-size-control/index.js index 46e87de60f2fc8..d929b129313938 100644 --- a/packages/block-editor/src/components/image-size-control/index.js +++ b/packages/block-editor/src/components/image-size-control/index.js @@ -8,6 +8,7 @@ import { __experimentalNumberControl as NumberControl, __experimentalHStack as HStack, } from '@wordpress/components'; +import deprecated from '@wordpress/deprecated'; import { __ } from '@wordpress/i18n'; /** @@ -30,6 +31,11 @@ export default function ImageSizeControl( { onChange, onChangeImage = noop, } ) { + deprecated( 'wp.blockEditor.__experimentalImageSizeControl', { + since: '6.3', + alternative: + 'wp.blockEditor.privateApis.DimensionsTool and wp.blockEditor.privateApis.ResolutionTool', + } ); const { currentHeight, currentWidth, updateDimension, updateDimensions } = useDimensionHandler( height, width, imageHeight, imageWidth, onChange ); diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index f113ad3b05f630..7e46698e2b61b3 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -26,6 +26,7 @@ export { default as BlockEdit, useBlockEditContext } from './block-edit'; export { default as BlockIcon } from './block-icon'; export { default as BlockNavigationDropdown } from './block-navigation/dropdown'; export { default as BlockStyles } from './block-styles'; +export { default as HeadingLevelDropdown } from './block-heading-level-dropdown'; export { default as __experimentalBlockVariationPicker } from './block-variation-picker'; export { default as __experimentalBlockPatternSetup } from './block-pattern-setup'; export { default as __experimentalBlockVariationTransforms } from './block-variation-transforms'; @@ -49,6 +50,7 @@ export { default as __experimentalFontFamilyControl } from './font-family'; export { default as __experimentalLetterSpacingControl } from './letter-spacing-control'; export { default as __experimentalTextDecorationControl } from './text-decoration-control'; export { default as __experimentalTextTransformControl } from './text-transform-control'; +export { default as __experimentalWritingModeControl } from './writing-mode-control'; export { default as __experimentalColorGradientControl } from './colors-gradients/control'; export { default as __experimentalColorGradientSettingsDropdown } from './colors-gradients/dropdown'; export { default as __experimentalPanelColorGradientSettings } from './colors-gradients/panel-color-gradient-settings'; @@ -110,7 +112,6 @@ export { default as __experimentalUseResizeCanvas } from './use-resize-canvas'; export { default as BlockInspector } from './block-inspector'; export { default as BlockList } from './block-list'; export { useBlockProps } from './block-list/use-block-props'; -export { LayoutStyle as __experimentalLayoutStyle } from './block-list/layout'; export { default as BlockMover } from './block-mover'; export { default as BlockPreview, @@ -157,6 +158,7 @@ export { export { default as __experimentalBlockPatternsList } from './block-patterns-list'; export { default as __experimentalPublishDateTimePicker } from './publish-date-time-picker'; export { default as __experimentalInspectorPopoverHeader } from './inspector-popover-header'; +export { useBlockEditingMode } from './block-editing-mode'; /* * State Related Components @@ -164,3 +166,9 @@ export { default as __experimentalInspectorPopoverHeader } from './inspector-pop export { default as BlockEditorProvider } from './provider'; export { default as useSetting } from './use-setting'; +export { useBlockCommands } from './use-block-commands'; + +/* + * The following rename hint component can be removed in 6.4. + */ +export { default as ReusableBlocksRenameHint } from './inserter/reusable-block-rename-hint'; diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index d5b182572e386c..a6ab6bb76d0480 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -28,6 +28,7 @@ export { JustifyContentControl, } from './justify-content-control'; export { default as LineHeightControl } from './line-height-control'; +export { default as HeadingLevelDropdown } from './block-heading-level-dropdown'; export { default as PlainText } from './plain-text'; export { default as RichText, diff --git a/packages/block-editor/src/components/inner-blocks/README.md b/packages/block-editor/src/components/inner-blocks/README.md index 5ecd9c90898210..0f5d303b8c7917 100644 --- a/packages/block-editor/src/components/inner-blocks/README.md +++ b/packages/block-editor/src/components/inner-blocks/README.md @@ -185,3 +185,13 @@ For example, a button block, deeply nested in several levels of block `X` that u - **Type:** `Array` - **Default:** - `undefined`. Determines which block types should be shown in the block inserter. For example, when inserting a block within the Navigation block we specify `core/navigation-link` and `core/navigation-link/page` as these are the most commonly used inner blocks. `prioritizedInserterBlocks` takes an array of the form {blockName}/{variationName}, where {variationName} is optional. + +### `defaultBlock` + +- **Type:** `Array` +- **Default:** - `undefined`. Determines which block type should be inserted by default and any attributes that should be set by default when the block is inserted. Takes an array in the form of `[ blockname, {blockAttributes} ]`. + +### `directInsert` + +- **Type:** `Boolean` +- **Default:** - `undefined`. Determines whether the default block should be inserted directly into the InnerBlocks area by the block appender. diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index bf33abad4864f3..9e0e4f19cfc7ea 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -46,6 +46,8 @@ function UncontrolledInnerBlocks( props ) { clientId, allowedBlocks, prioritizedInserterBlocks, + defaultBlock, + directInsert, __experimentalDefaultBlock, __experimentalDirectInsert, template, @@ -64,6 +66,8 @@ function UncontrolledInnerBlocks( props ) { clientId, allowedBlocks, prioritizedInserterBlocks, + defaultBlock, + directInsert, __experimentalDefaultBlock, __experimentalDirectInsert, templateLock, @@ -88,7 +92,9 @@ function UncontrolledInnerBlocks( props ) { ); const defaultLayoutBlockSupport = - getBlockSupport( name, '__experimentalLayout' ) || EMPTY_OBJECT; + getBlockSupport( name, 'layout' ) || + getBlockSupport( name, '__experimentalLayout' ) || + EMPTY_OBJECT; const { allowSizingOnChildren = false } = defaultLayoutBlockSupport; @@ -116,7 +122,7 @@ function UncontrolledInnerBlocks( props ) { rootClientId={ clientId } renderAppender={ renderAppender } __experimentalAppenderTagName={ __experimentalAppenderTagName } - __experimentalLayout={ memoedLayout } + layout={ memoedLayout } wrapperRef={ wrapperRef } placeholder={ placeholder } /> diff --git a/packages/block-editor/src/components/inner-blocks/index.native.js b/packages/block-editor/src/components/inner-blocks/index.native.js index 54e168f8ee43f4..f07dcf4fc53053 100644 --- a/packages/block-editor/src/components/inner-blocks/index.native.js +++ b/packages/block-editor/src/components/inner-blocks/index.native.js @@ -73,6 +73,8 @@ function UncontrolledInnerBlocks( props ) { clientId, allowedBlocks, prioritizedInserterBlocks, + defaultBlock, + directInsert, __experimentalDefaultBlock, __experimentalDirectInsert, template, @@ -93,7 +95,7 @@ function UncontrolledInnerBlocks( props ) { horizontalAlignment, filterInnerBlocks, blockWidth, - __experimentalLayout: layout = defaultLayout, + layout = defaultLayout, gridProperties, } = props; @@ -103,6 +105,8 @@ function UncontrolledInnerBlocks( props ) { clientId, allowedBlocks, prioritizedInserterBlocks, + defaultBlock, + directInsert, __experimentalDefaultBlock, __experimentalDirectInsert, templateLock, diff --git a/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js b/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js index 49d2da85688c3f..44f99428a31bf8 100644 --- a/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js +++ b/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js @@ -3,7 +3,7 @@ */ import { useLayoutEffect, useMemo } from '@wordpress/element'; import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; -import isShallowEqual from '@wordpress/is-shallow-equal'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -26,9 +26,13 @@ const pendingSettingsUpdates = new WeakMap(); * @param {string[]} allowedBlocks An array of block names which are permitted * in inner blocks. * @param {string[]} prioritizedInserterBlocks Block names and/or block variations to be prioritized in the inserter, in the format {blockName}/{variationName}. - * @param {?WPDirectInsertBlock} __experimentalDefaultBlock The default block to insert: [ blockName, { blockAttributes } ]. - * @param {?Function|boolean} __experimentalDirectInsert If a default block should be inserted directly by the - * appender. + * @param {?WPDirectInsertBlock} defaultBlock The default block to insert: [ blockName, { blockAttributes } ]. + * @param {?Function|boolean} directInsert If a default block should be inserted directly by the appender. + * + * @param {?WPDirectInsertBlock} __experimentalDefaultBlock A deprecated prop for the default block to insert: [ blockName, { blockAttributes } ]. Use `defaultBlock` instead. + * + * @param {?Function|boolean} __experimentalDirectInsert A deprecated prop for whether a default block should be inserted directly by the appender. Use `directInsert` instead. + * * @param {string} [templateLock] The template lock specified for the inner * blocks component. (e.g. "all") * @param {boolean} captureToolbars Whether or children toolbars should be shown @@ -42,6 +46,8 @@ export default function useNestedSettingsUpdate( clientId, allowedBlocks, prioritizedInserterBlocks, + defaultBlock, + directInsert, __experimentalDefaultBlock, __experimentalDirectInsert, templateLock, @@ -52,13 +58,11 @@ export default function useNestedSettingsUpdate( const { updateBlockListSettings } = useDispatch( blockEditorStore ); const registry = useRegistry(); - const { blockListSettings, parentLock } = useSelect( + const { parentLock } = useSelect( ( select ) => { const rootClientId = select( blockEditorStore ).getBlockRootClientId( clientId ); return { - blockListSettings: - select( blockEditorStore ).getBlockListSettings( clientId ), parentLock: select( blockEditorStore ).getTemplateLock( rootClientId ), }; @@ -83,14 +87,16 @@ export default function useNestedSettingsUpdate( prioritizedInserterBlocks ); + const _templateLock = + templateLock === undefined || parentLock === 'contentOnly' + ? parentLock + : templateLock; + useLayoutEffect( () => { const newSettings = { allowedBlocks: _allowedBlocks, prioritizedInserterBlocks: _prioritizedInserterBlocks, - templateLock: - templateLock === undefined || parentLock === 'contentOnly' - ? parentLock - : templateLock, + templateLock: _templateLock, }; // These values are not defined for RN, so only include them if they @@ -109,48 +115,64 @@ export default function useNestedSettingsUpdate( } if ( __experimentalDefaultBlock !== undefined ) { - newSettings.__experimentalDefaultBlock = __experimentalDefaultBlock; + deprecated( '__experimentalDefaultBlock', { + alternative: 'defaultBlock', + since: '6.3', + version: '6.4', + } ); + newSettings.defaultBlock = __experimentalDefaultBlock; } - if ( __experimentalDirectInsert !== undefined ) { - newSettings.__experimentalDirectInsert = __experimentalDirectInsert; + if ( defaultBlock !== undefined ) { + newSettings.defaultBlock = defaultBlock; } - if ( ! isShallowEqual( blockListSettings, newSettings ) ) { - // Batch updates to block list settings to avoid triggering cascading renders - // for each container block included in a tree and optimize initial render. - // To avoid triggering updateBlockListSettings for each container block - // causing X re-renderings for X container blocks, - // we batch all the updatedBlockListSettings in a single "data" batch - // which results in a single re-render. - if ( ! pendingSettingsUpdates.get( registry ) ) { - pendingSettingsUpdates.set( registry, [] ); - } - pendingSettingsUpdates - .get( registry ) - .push( [ clientId, newSettings ] ); - window.queueMicrotask( () => { - if ( pendingSettingsUpdates.get( registry )?.length ) { - registry.batch( () => { - pendingSettingsUpdates - .get( registry ) - .forEach( ( args ) => { - updateBlockListSettings( ...args ); - } ); - pendingSettingsUpdates.set( registry, [] ); - } ); - } + if ( __experimentalDirectInsert !== undefined ) { + deprecated( '__experimentalDirectInsert', { + alternative: 'directInsert', + since: '6.3', + version: '6.4', } ); + newSettings.directInsert = __experimentalDirectInsert; + } + + if ( directInsert !== undefined ) { + newSettings.directInsert = directInsert; } + + // Batch updates to block list settings to avoid triggering cascading renders + // for each container block included in a tree and optimize initial render. + // To avoid triggering updateBlockListSettings for each container block + // causing X re-renderings for X container blocks, + // we batch all the updatedBlockListSettings in a single "data" batch + // which results in a single re-render. + if ( ! pendingSettingsUpdates.get( registry ) ) { + pendingSettingsUpdates.set( registry, [] ); + } + pendingSettingsUpdates + .get( registry ) + .push( [ clientId, newSettings ] ); + window.queueMicrotask( () => { + if ( pendingSettingsUpdates.get( registry )?.length ) { + registry.batch( () => { + pendingSettingsUpdates + .get( registry ) + .forEach( ( args ) => { + updateBlockListSettings( ...args ); + } ); + pendingSettingsUpdates.set( registry, [] ); + } ); + } + } ); }, [ clientId, - blockListSettings, _allowedBlocks, _prioritizedInserterBlocks, + _templateLock, + defaultBlock, + directInsert, __experimentalDefaultBlock, __experimentalDirectInsert, - templateLock, - parentLock, captureToolbars, orientation, updateBlockListSettings, diff --git a/packages/block-editor/src/components/inserter-draggable-blocks/index.js b/packages/block-editor/src/components/inserter-draggable-blocks/index.js index 0de9b3a2260f6b..42cae8c7bcde98 100644 --- a/packages/block-editor/src/components/inserter-draggable-blocks/index.js +++ b/packages/block-editor/src/components/inserter-draggable-blocks/index.js @@ -2,7 +2,8 @@ * WordPress dependencies */ import { Draggable } from '@wordpress/components'; -import { serialize } from '@wordpress/blocks'; +import { serialize, store as blocksStore } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ @@ -20,6 +21,16 @@ const InserterDraggableBlocks = ( { blocks, }; + const blockTypeIcon = useSelect( + ( select ) => { + const { getBlockType } = select( blocksStore ); + return ( + blocks.length === 1 && getBlockType( blocks[ 0 ].name )?.icon + ); + }, + [ blocks ] + ); + return ( } diff --git a/packages/block-editor/src/components/inserter-list-item/index.js b/packages/block-editor/src/components/inserter-list-item/index.js index 43510cdd52d96e..dc755e87676233 100644 --- a/packages/block-editor/src/components/inserter-list-item/index.js +++ b/packages/block-editor/src/components/inserter-list-item/index.js @@ -49,7 +49,9 @@ function InserterListItem( { ]; }, [ item.name, item.initialAttributes, item.initialAttributes ] ); - const isSynced = isReusableBlock( item ) || isTemplatePart( item ); + const isSynced = + ( isReusableBlock( item ) && item.syncStatus !== 'unsynced' ) || + isTemplatePart( item ); return ( patternCategories.map( @@ -75,7 +76,12 @@ function PatternList( { filterValue, selectedCategory, patternCategories } ) { ); } return searchItems( allPatterns, filterValue ); - }, [ filterValue, selectedCategory, allPatterns ] ); + }, [ + filterValue, + allPatterns, + selectedCategory, + registeredPatternCategories, + ] ); // Announce search results on change. useEffect( () => { @@ -89,7 +95,7 @@ function PatternList( { filterValue, selectedCategory, patternCategories } ) { count ); debouncedSpeak( resultsFoundMessage ); - }, [ filterValue, debouncedSpeak ] ); + }, [ filterValue, debouncedSpeak, filteredBlockPatterns.length ] ); const currentShownPatterns = useAsyncList( filteredBlockPatterns, { step: INITIAL_INSERTER_RESULTS, diff --git a/packages/block-editor/src/components/inserter/block-patterns-tab.js b/packages/block-editor/src/components/inserter/block-patterns-tab.js index dfa0676eefb6fe..f66d27ac06170d 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-tab.js +++ b/packages/block-editor/src/components/inserter/block-patterns-tab.js @@ -8,7 +8,7 @@ import { useRef, useEffect, } from '@wordpress/element'; -import { _x, __ } from '@wordpress/i18n'; +import { _x, __, isRTL } from '@wordpress/i18n'; import { useAsyncList, useViewportMatch } from '@wordpress/compose'; import { __experimentalItemGroup as ItemGroup, @@ -17,7 +17,7 @@ import { FlexBlock, Button, } from '@wordpress/components'; -import { Icon, chevronRight } from '@wordpress/icons'; +import { Icon, chevronRight, chevronLeft } from '@wordpress/icons'; import { focus } from '@wordpress/dom'; /** @@ -33,6 +33,7 @@ const noop = () => {}; // Preferred order of pattern categories. Any other categories should // be at the bottom without any re-ordering. const patternCategoriesOrder = [ + 'custom', 'featured', 'posts', 'text', @@ -95,7 +96,7 @@ function usePatternsCategories( rootClientId ) { } return categories; - }, [ allPatterns, allCategories ] ); + }, [ allCategories, allPatterns, hasRegisteredCategory ] ); return populatedCategories; } @@ -165,10 +166,10 @@ export function BlockPatternsCategoryPanel( { return availablePatternCategories.length === 0; } ), - [ allPatterns, category ] + [ allPatterns, availableCategories, category.name ] ); - const currentShownPatterns = useAsyncList( currentCategoryPatterns ); + const categoryPatternsList = useAsyncList( currentCategoryPatterns ); // Hide block pattern preview on unmount. useEffect( () => () => onHover( null ), [] ); @@ -184,7 +185,7 @@ export function BlockPatternsCategoryPanel( {

{ category.description }

{ category.label } - + ) ) } diff --git a/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js b/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js index 0b7c8040af8d34..2faa5036327831 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js +++ b/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js @@ -5,6 +5,7 @@ import { createBlock, createBlocksFromInnerBlocksTemplate, store as blocksStore, + parse, } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; import { useCallback } from '@wordpress/element'; @@ -37,12 +38,20 @@ const useBlockTypesState = ( rootClientId, onInsert ) => { ); const onSelectItem = useCallback( - ( { name, initialAttributes, innerBlocks }, shouldFocusBlock ) => { - const insertedBlock = createBlock( - name, - initialAttributes, - createBlocksFromInnerBlocksTemplate( innerBlocks ) - ); + ( + { name, initialAttributes, innerBlocks, syncStatus, content }, + shouldFocusBlock + ) => { + const insertedBlock = + syncStatus === 'unsynced' + ? parse( content, { + __unstableSkipMigrationLogs: true, + } ) + : createBlock( + name, + initialAttributes, + createBlocksFromInnerBlocksTemplate( innerBlocks ) + ); onInsert( insertedBlock, undefined, shouldFocusBlock ); }, diff --git a/packages/block-editor/src/components/inserter/hooks/use-debounced-input.js b/packages/block-editor/src/components/inserter/hooks/use-debounced-input.js index 55d0ce989293e5..26cd6c0da0e0a9 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-debounced-input.js +++ b/packages/block-editor/src/components/inserter/hooks/use-debounced-input.js @@ -6,12 +6,13 @@ import { useDebounce } from '@wordpress/compose'; export default function useDebouncedInput( defaultValue = '' ) { const [ input, setInput ] = useState( defaultValue ); - const [ debounced, setter ] = useState( defaultValue ); - const setDebounced = useDebounce( setter, 250 ); + const [ debouncedInput, setDebouncedState ] = useState( defaultValue ); + + const setDebouncedInput = useDebounce( setDebouncedState, 250 ); + useEffect( () => { - if ( debounced !== input ) { - setDebounced( input ); - } - }, [ debounced, input ] ); - return [ input, setInput, debounced ]; + setDebouncedInput( input ); + }, [ input ] ); + + return [ input, setInput, debouncedInput ]; } diff --git a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js index 52c7f9ba23f83e..8252de58b49d51 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js +++ b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js @@ -135,6 +135,7 @@ function useInsertionPoint( { destinationIndex, onSelect, shouldFocusBlock, + selectBlockOnInsert, ] ); diff --git a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js index ca287b95c43b9b..5f47897f50b7a2 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js +++ b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useCallback } from '@wordpress/element'; +import { useCallback, useMemo } from '@wordpress/element'; import { cloneBlock } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; import { __, sprintf } from '@wordpress/i18n'; @@ -12,6 +12,12 @@ import { store as noticesStore } from '@wordpress/notices'; */ import { store as blockEditorStore } from '../../../store'; +const CUSTOM_CATEGORY = { + name: 'custom', + label: __( 'My patterns' ), + description: __( 'Custom patterns added by site users.' ), +}; + /** * Retrieves the block patterns inserter state. * @@ -25,6 +31,7 @@ const usePatternsState = ( onInsert, rootClientId ) => { ( select ) => { const { __experimentalGetAllowedPatterns, getSettings } = select( blockEditorStore ); + return { patterns: __experimentalGetAllowedPatterns( rootClientId ), patternCategories: @@ -33,25 +40,35 @@ const usePatternsState = ( onInsert, rootClientId ) => { }, [ rootClientId ] ); + + const allCategories = useMemo( + () => [ ...patternCategories, CUSTOM_CATEGORY ], + [ patternCategories ] + ); + const { createSuccessNotice } = useDispatch( noticesStore ); - const onClickPattern = useCallback( ( pattern, blocks ) => { - onInsert( - ( blocks ?? [] ).map( ( block ) => cloneBlock( block ) ), - pattern.name - ); - createSuccessNotice( - sprintf( - /* translators: %s: block pattern title. */ - __( 'Block pattern "%s" inserted.' ), - pattern.title - ), - { - type: 'snackbar', - } - ); - }, [] ); - - return [ patterns, patternCategories, onClickPattern ]; + const onClickPattern = useCallback( + ( pattern, blocks ) => { + onInsert( + ( blocks ?? [] ).map( ( block ) => cloneBlock( block ) ), + pattern.name + ); + createSuccessNotice( + sprintf( + /* translators: %s: block pattern title. */ + __( 'Block pattern "%s" inserted.' ), + pattern.title + ), + { + type: 'snackbar', + id: 'block-pattern-inserted-notice', + } + ); + }, + [ createSuccessNotice, onInsert ] + ); + + return [ patterns, allCategories, onClickPattern ]; }; export default usePatternsState; diff --git a/packages/block-editor/src/components/inserter/index.js b/packages/block-editor/src/components/inserter/index.js index 9c24497e5a9078..8e2972fbe2bf5e 100644 --- a/packages/block-editor/src/components/inserter/index.js +++ b/packages/block-editor/src/components/inserter/index.js @@ -231,7 +231,7 @@ export const ComposedPrivateInserter = compose( [ getBlockRootClientId, hasInserterItems, getAllowedBlocks, - __experimentalGetDirectInsertBlock, + getDirectInsertBlock, getSettings, } = select( blockEditorStore ); @@ -243,8 +243,7 @@ export const ComposedPrivateInserter = compose( [ const allowedBlocks = getAllowedBlocks( rootClientId ); const directInsertBlock = - shouldDirectInsert && - __experimentalGetDirectInsertBlock( rootClientId ); + shouldDirectInsert && getDirectInsertBlock( rootClientId ); const settings = getSettings(); diff --git a/packages/block-editor/src/components/inserter/index.native.js b/packages/block-editor/src/components/inserter/index.native.js index a3e6981e6ecfc7..6edef19583b3fc 100644 --- a/packages/block-editor/src/components/inserter/index.native.js +++ b/packages/block-editor/src/components/inserter/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { AccessibilityInfo, Platform, Text } from 'react-native'; +import { AccessibilityInfo, Platform } from 'react-native'; /** * WordPress dependencies @@ -35,33 +35,17 @@ const VOICE_OVER_ANNOUNCEMENT_DELAY = 1000; const defaultRenderToggle = ( { onToggle, disabled, - style, - containerStyle, + iconStyle, + buttonStyle, onLongPress, - useExpandedMode, } ) => { - // The "expanded mode" refers to the editor's appearance when no blocks - // are currently selected. The "add block" button has a separate style - // for the "expanded mode", which are added via the below "expandedModeViewProps" - // and "expandedModeViewText" variables. - const expandedModeViewProps = useExpandedMode && { - icon: , - customContainerStyles: containerStyle, - fixedRatio: false, - }; - const expandedModeViewText = ( - - { __( 'Add blocks' ) } - - ); - return ( } + icon={ } onClick={ onToggle } extraProps={ { hint: __( 'Double tap to add a block' ), @@ -69,12 +53,12 @@ const defaultRenderToggle = ( { // usually required for components. See: https://github.com/WordPress/gutenberg/pull/18832#issuecomment-561411389. testID: 'add-block-button', onLongPress, + hitSlop: { top: 10, bottom: 10, left: 10, right: 10 }, } } isDisabled={ disabled } - { ...expandedModeViewProps } - > - { useExpandedMode && expandedModeViewText } - + customContainerStyles={ buttonStyle } + fixedRatio={ false } + /> ); }; @@ -249,23 +233,21 @@ export class Inserter extends Component { renderToggle = defaultRenderToggle, getStylesFromColorScheme, showSeparator, - useExpandedMode, } = this.props; if ( showSeparator && isOpen ) { return ; } - const style = useExpandedMode - ? styles[ 'inserter-menu__add-block-button-icon--expanded' ] - : getStylesFromColorScheme( - styles[ 'inserter-menu__add-block-button-icon' ], - styles[ 'inserter-menu__add-block-button-icon--dark' ] - ); - - const containerStyle = getStylesFromColorScheme( + + const buttonStyle = getStylesFromColorScheme( styles[ 'inserter-menu__add-block-button' ], styles[ 'inserter-menu__add-block-button--dark' ] ); + const iconStyle = getStylesFromColorScheme( + styles[ 'inserter-menu__add-block-button-icon' ], + styles[ 'inserter-menu__add-block-button-icon--dark' ] + ); + const onPress = () => { this.setState( { @@ -301,10 +283,9 @@ export class Inserter extends Component { onToggle: onPress, isOpen, disabled, - style, - containerStyle, + iconStyle, + buttonStyle, onLongPress, - useExpandedMode, } ) } ( this.picker = instance ) } diff --git a/packages/block-editor/src/components/inserter/library.js b/packages/block-editor/src/components/inserter/library.js index 23dfdb7fd7dc62..3a814638c2f48e 100644 --- a/packages/block-editor/src/components/inserter/library.js +++ b/packages/block-editor/src/components/inserter/library.js @@ -36,7 +36,8 @@ function InserterLibrary( return { destinationRootClientId: _rootClientId, prioritizePatterns: - getSettings().__experimentalPreferPatternsOnRoot, + getSettings().__experimentalPreferPatternsOnRoot && + ! _rootClientId, }; }, [ clientId, rootClientId ] diff --git a/packages/block-editor/src/components/inserter/media-tab/hooks.js b/packages/block-editor/src/components/inserter/media-tab/hooks.js index 0822e2bf67e367..3d248c237be7c1 100644 --- a/packages/block-editor/src/components/inserter/media-tab/hooks.js +++ b/packages/block-editor/src/components/inserter/media-tab/hooks.js @@ -9,28 +9,8 @@ import { useSelect } from '@wordpress/data'; */ import { store as blockEditorStore } from '../../../store'; -/** - * Interface for inserter media requests. - * - * @typedef {Object} InserterMediaRequest - * @property {number} per_page How many items to fetch per page. - * @property {string} search The search term to use for filtering the results. - */ - -/** - * Interface for inserter media responses. Any media resource should - * map their response to this interface, in order to create the core - * WordPress media blocks (image, video, audio). - * - * @typedef {Object} InserterMediaItem - * @property {string} title The title of the media item. - * @property {string} url The source url of the media item. - * @property {string} [previewUrl] The preview source url of the media item to display in the media list. - * @property {number} [id] The WordPress id of the media item. - * @property {number|string} [sourceId] The id of the media item from external source. - * @property {string} [alt] The alt text of the media item. - * @property {string} [caption] The caption of the media item. - */ +/** @typedef {import('./api').InserterMediaRequest} InserterMediaRequest */ +/** @typedef {import('./api').InserterMediaItem} InserterMediaItem */ /** * Fetches media items based on the provided category. diff --git a/packages/block-editor/src/components/inserter/media-tab/media-tab.js b/packages/block-editor/src/components/inserter/media-tab/media-tab.js index b84b5aa68b2dff..b448ae2a406499 100644 --- a/packages/block-editor/src/components/inserter/media-tab/media-tab.js +++ b/packages/block-editor/src/components/inserter/media-tab/media-tab.js @@ -6,7 +6,7 @@ import classNames from 'classnames'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, isRTL } from '@wordpress/i18n'; import { useViewportMatch } from '@wordpress/compose'; import { __experimentalItemGroup as ItemGroup, @@ -16,7 +16,7 @@ import { Button, } from '@wordpress/components'; import { useCallback, useMemo } from '@wordpress/element'; -import { Icon, chevronRight } from '@wordpress/icons'; +import { Icon, chevronRight, chevronLeft } from '@wordpress/icons'; /** * Internal dependencies @@ -89,7 +89,13 @@ function MediaTab( { { mediaCategory.labels.name } - + ) ) } diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js index 86cad9ed6d8681..c2257cf237669e 100644 --- a/packages/block-editor/src/components/inserter/menu.js +++ b/packages/block-editor/src/components/inserter/menu.js @@ -260,6 +260,7 @@ function InserterMenu( filterValue={ delayedFilterValue } onSelect={ onSelect } onHover={ onHover } + onHoverPattern={ onHoverPattern } rootClientId={ rootClientId } clientId={ clientId } isAppender={ isAppender } diff --git a/packages/block-editor/src/components/inserter/preview-panel.js b/packages/block-editor/src/components/inserter/preview-panel.js index 4f154a034bf4a6..554e87688bbd21 100644 --- a/packages/block-editor/src/components/inserter/preview-panel.js +++ b/packages/block-editor/src/components/inserter/preview-panel.js @@ -6,6 +6,7 @@ import { createBlock, getBlockFromExample, } from '@wordpress/blocks'; +import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -17,23 +18,26 @@ import BlockPreview from '../block-preview'; function InserterPreviewPanel( { item } ) { const { name, title, icon, description, initialAttributes, example } = item; const isReusable = isReusableBlock( item ); + const blocks = useMemo( () => { + if ( ! example ) { + return createBlock( name, initialAttributes ); + } + return getBlockFromExample( name, { + attributes: { + ...example.attributes, + ...initialAttributes, + }, + innerBlocks: example.innerBlocks, + } ); + }, [ name, example, initialAttributes ] ); + return (
{ isReusable || example ? (
+ select( preferencesStore ).get( 'core', PREFERENCE_NAME ) ?? true, + [] + ); +} + +/* + * This component was added in 6.3 to help users with the transition from Reusable blocks to Patterns. + * It is only exported for use in the reusable-blocks package as well as block-editor. + * It will be removed in 6.4. and should not be used in any new code. + */ +export default function ReusableBlocksRenameHint() { + const isReusableBlocksRenameHint = useSelect( + ( select ) => + select( preferencesStore ).get( 'core', PREFERENCE_NAME ) ?? true, + [] + ); + + const ref = useRef(); + + const { set: setPreference } = useDispatch( preferencesStore ); + if ( ! isReusableBlocksRenameHint ) { + return null; + } + + return ( +
+
+ { __( + 'Reusable blocks are now synced patterns. A synced pattern will behave in exactly the same way as a reusable block.' + ) } +
+
+ ); +} diff --git a/packages/block-editor/src/components/inserter/reusable-blocks-tab.js b/packages/block-editor/src/components/inserter/reusable-blocks-tab.js index 9505dd77f3b94d..920b9f56384d4b 100644 --- a/packages/block-editor/src/components/inserter/reusable-blocks-tab.js +++ b/packages/block-editor/src/components/inserter/reusable-blocks-tab.js @@ -13,6 +13,7 @@ import BlockTypesList from '../block-types-list'; import InserterPanel from './panel'; import InserterNoResults from './no-results'; import useBlockTypesState from './hooks/use-block-types-state'; +import ReusableBlocksRenameHint from './reusable-block-rename-hint'; function ReusableBlocksList( { onHover, onInsert, rootClientId } ) { const [ items, , , onSelectItem ] = useBlockTypesState( @@ -21,7 +22,10 @@ function ReusableBlocksList( { onHover, onInsert, rootClientId } ) { ); const filteredItems = useMemo( () => { - return items.filter( ( { category } ) => category === 'reusable' ); + return items.filter( + ( { category, syncStatus } ) => + category === 'reusable' && syncStatus !== 'unsynced' + ); }, [ items ] ); if ( filteredItems.length === 0 ) { @@ -29,12 +33,12 @@ function ReusableBlocksList( { onHover, onInsert, rootClientId } ) { } return ( - + ); @@ -54,6 +58,9 @@ function ReusableBlocksList( { onHover, onInsert, rootClientId } ) { export function ReusableBlocksTab( { rootClientId, onInsert, onHover } ) { return ( <> +
+ +
- { __( 'Manage Reusable blocks' ) } + { __( 'Manage my patterns' ) }
diff --git a/packages/block-editor/src/components/inserter/reusable-blocks-tab.native.js b/packages/block-editor/src/components/inserter/reusable-blocks-tab.native.js index d50a5a99f2728a..97f3ab7c3ce4fa 100644 --- a/packages/block-editor/src/components/inserter/reusable-blocks-tab.native.js +++ b/packages/block-editor/src/components/inserter/reusable-blocks-tab.native.js @@ -3,6 +3,7 @@ */ import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -12,27 +13,31 @@ import { store as blockEditorStore } from '../../store'; import { createInserterSection, filterInserterItems } from './utils'; function ReusableBlocksTab( { onSelect, rootClientId, listProps } ) { - const { items } = useSelect( + const { inserterItems } = useSelect( ( select ) => { const { getInserterItems } = select( blockEditorStore ); const allItems = getInserterItems( rootClientId ); return { - items: filterInserterItems( allItems, { onlyReusable: true } ), + inserterItems: allItems, }; }, [ rootClientId ] ); + const items = useMemo( () => { + return filterInserterItems( inserterItems, { onlyReusable: true } ); + }, [ inserterItems ] ); + const sections = [ createInserterSection( { key: 'reuseable', items } ) ]; return ( ); } diff --git a/packages/block-editor/src/components/inserter/search-results.js b/packages/block-editor/src/components/inserter/search-results.js index b32cfcf1ecb3f2..e55fb650d83596 100644 --- a/packages/block-editor/src/components/inserter/search-results.js +++ b/packages/block-editor/src/components/inserter/search-results.js @@ -38,6 +38,7 @@ function InserterSearchResults( { filterValue, onSelect, onHover, + onHoverPattern, rootClientId, clientId, isAppender, @@ -189,7 +190,7 @@ function InserterSearchResults( { shownPatterns={ currentShownPatterns } blockPatterns={ filteredBlockPatterns } onClickPattern={ onSelectBlockPattern } - onHover={ onHover } + onHover={ onHoverPattern } isDraggable={ isDraggable } />
diff --git a/packages/block-editor/src/components/inserter/search-results.native.js b/packages/block-editor/src/components/inserter/search-results.native.js index bc951dac1de02f..93bdaa96b5175f 100644 --- a/packages/block-editor/src/components/inserter/search-results.native.js +++ b/packages/block-editor/src/components/inserter/search-results.native.js @@ -3,6 +3,7 @@ */ import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -21,21 +22,24 @@ function InserterSearchResults( { rootClientId, isFullScreen, } ) { - const { blockTypes } = useSelect( + const { inserterItems } = useSelect( ( select ) => { - const allItems = + const items = select( blockEditorStore ).getInserterItems( rootClientId ); - const availableItems = filterInserterItems( allItems, { - allowReusable: true, - } ); - const filteredItems = searchItems( availableItems, filterValue ); - - return { blockTypes: filteredItems }; + return { inserterItems: items }; }, - [ rootClientId, filterValue ] + [ rootClientId ] ); + const blockTypes = useMemo( () => { + const availableItems = filterInserterItems( inserterItems, { + allowReusable: true, + } ); + + return searchItems( availableItems, filterValue ); + }, [ inserterItems, filterValue ] ); + const { items, trackBlockTypeSelected } = useBlockTypeImpressions( blockTypes ); diff --git a/packages/block-editor/src/components/inserter/stories/index.js b/packages/block-editor/src/components/inserter/stories/index.js deleted file mode 100644 index 960fd86b3bc8ff..00000000000000 --- a/packages/block-editor/src/components/inserter/stories/index.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Internal dependencies - */ -import BlockLibrary from '../library'; -import { ExperimentalBlockEditorProvider } from '../../provider'; -import { patternCategories, patterns, reusableBlocks } from './utils/fixtures'; -import Inserter from '../'; - -export default { title: 'BlockEditor/Inserter' }; - -export const libraryWithoutPatterns = () => { - const wrapperStyle = { - margin: '24px', - height: 400, - border: '1px solid #f3f3f3', - display: 'inline-block', - }; - return ( - -
- -
-
- ); -}; - -export const libraryWithPatterns = () => { - const wrapperStyle = { - margin: '24px', - height: 400, - border: '1px solid #f3f3f3', - display: 'inline-block', - }; - return ( - -
- -
-
- ); -}; - -export const libraryWithPatternsAndReusableBlocks = () => { - const wrapperStyle = { - margin: '24px', - height: 400, - border: '1px solid #f3f3f3', - display: 'inline-block', - }; - return ( - -
- -
-
- ); -}; - -export const quickInserter = () => { - const wrapperStyle = { - margin: '24px', - height: 400, - border: '1px solid #f3f3f3', - display: 'inline-block', - }; - return ( - -
- -
-
- ); -}; diff --git a/packages/block-editor/src/components/inserter/stories/index.story.js b/packages/block-editor/src/components/inserter/stories/index.story.js new file mode 100644 index 00000000000000..58dd99b48d9a02 --- /dev/null +++ b/packages/block-editor/src/components/inserter/stories/index.story.js @@ -0,0 +1,90 @@ +/** + * Internal dependencies + */ +import BlockLibrary from '../library'; +import { ExperimentalBlockEditorProvider } from '../../provider'; +import { patternCategories, patterns, reusableBlocks } from './utils/fixtures'; +import Inserter from '../'; + +export default { title: 'BlockEditor/Inserter' }; + +export const LibraryWithoutPatterns = () => { + const wrapperStyle = { + margin: '24px', + height: 400, + border: '1px solid #f3f3f3', + display: 'inline-block', + }; + return ( + +
+ +
+
+ ); +}; + +export const LibraryWithPatterns = () => { + const wrapperStyle = { + margin: '24px', + height: 400, + border: '1px solid #f3f3f3', + display: 'inline-block', + }; + return ( + +
+ +
+
+ ); +}; + +export const LibraryWithPatternsAndReusableBlocks = () => { + const wrapperStyle = { + margin: '24px', + height: 400, + border: '1px solid #f3f3f3', + display: 'inline-block', + }; + return ( + +
+ +
+
+ ); +}; + +export const QuickInserter = () => { + const wrapperStyle = { + margin: '24px', + height: 400, + border: '1px solid #f3f3f3', + display: 'inline-block', + }; + return ( + +
+ +
+
+ ); +}; diff --git a/packages/block-editor/src/components/inserter/style.native.scss b/packages/block-editor/src/components/inserter/style.native.scss index fd17217da75da1..cc4b8d95a22011 100644 --- a/packages/block-editor/src/components/inserter/style.native.scss +++ b/packages/block-editor/src/components/inserter/style.native.scss @@ -1,26 +1,21 @@ /** @format */ - -.inserter-menu__add-block-button-icon { - color: $blue-50; +.inserter-menu__add-block-button { + border-radius: 2px; + background-color: $black; + margin: 9px $grid-unit-20 10px $grid-unit-20; + padding: 0; } -.inserter-menu__add-block-button-icon--dark { - color: $blue-30; +.inserter-menu__add-block-button--dark { + background-color: $white; } -.inserter-menu__add-block-button-icon--expanded { +.inserter-menu__add-block-button-icon { color: $white; } -.inserter-menu__add-block-button { - border-radius: 22px; - background-color: $blue-50; - margin: 8px; - padding: 6px 16px 6px 12px; -} - -.inserter-menu__add-block-button--dark { - background-color: $blue-30; +.inserter-menu__add-block-button-icon--dark { + color: $black; } .inserter-menu__add-block-button-text { diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss index d6cf14872462e4..e3546e29f5dc9d 100644 --- a/packages/block-editor/src/components/inserter/style.scss +++ b/packages/block-editor/src/components/inserter/style.scss @@ -711,3 +711,31 @@ $block-inserter-tabs-height: 44px; margin: 0; } } + +.block-editor-inserter__hint { + margin: $grid-unit-20 $grid-unit-20 0; +} + +.reusable-blocks-menu-items__rename-hint { + align-items: top; + background: $gray-100; + border-radius: $radius-block-ui; + color: $gray-900; + display: flex; + flex-direction: row; + max-width: 380px; +} + +.reusable-blocks-menu-items__rename-hint-content { + margin: $grid-unit-15 0 $grid-unit-15 $grid-unit-15; +} + +.reusable-blocks-menu-items__rename-hint-dismiss { + // The dismiss button has a lot of empty space through its padding. + // Apply margin to visually align the icon with the top of the text to its left. + margin: $grid-unit-05 $grid-unit-05 $grid-unit-05 0; +} + +.components-menu-group .reusable-blocks-menu-items__rename-hint { + margin: 0; +} diff --git a/packages/block-editor/src/components/inserter/tabs.js b/packages/block-editor/src/components/inserter/tabs.js index 6f8377892059b5..1ff8b529707a4b 100644 --- a/packages/block-editor/src/components/inserter/tabs.js +++ b/packages/block-editor/src/components/inserter/tabs.js @@ -13,13 +13,13 @@ const blocksTab = { }; const patternsTab = { name: 'patterns', - /* translators: Patterns tab title in the block inserter. */ + /* translators: Theme and Directory Patterns tab title in the block inserter. */ title: __( 'Patterns' ), }; const reusableBlocksTab = { name: 'reusable', - /* translators: Reusable blocks tab title in the block inserter. */ - title: __( 'Reusable' ), + /* translators: Locally created Patterns tab title in the block inserter. */ + title: __( 'Synced patterns' ), icon: reusableBlockIcon, }; const mediaTab = { diff --git a/packages/block-editor/src/components/inserter/tabs.native.js b/packages/block-editor/src/components/inserter/tabs.native.js index 452305663358af..a921f8e4c5794c 100644 --- a/packages/block-editor/src/components/inserter/tabs.native.js +++ b/packages/block-editor/src/components/inserter/tabs.native.js @@ -142,7 +142,11 @@ InserterTabs.Control = TabsControl; InserterTabs.getTabs = () => [ { name: 'blocks', title: __( 'Blocks' ), component: BlockTypesTab }, - { name: 'reusable', title: __( 'Reusable' ), component: ReusableBlocksTab }, + { + name: 'reusable', + title: __( 'Synced patterns' ), + component: ReusableBlocksTab, + }, ]; export default InserterTabs; diff --git a/packages/block-editor/src/components/inserter/test/search-items.js b/packages/block-editor/src/components/inserter/test/search-items.js index 057432113b7317..990e545b3a3994 100644 --- a/packages/block-editor/src/components/inserter/test/search-items.js +++ b/packages/block-editor/src/components/inserter/test/search-items.js @@ -50,7 +50,10 @@ describe( 'getNormalizedSearchTerms', () => { ).toEqual( [ '师父领进门', '修行在个人' ] ); expect( getNormalizedSearchTerms( 'Бързата работа – срам за майстора.' ) - ).toEqual( [ 'бързата', 'работа', 'срам', 'за', 'майстора' ] ); + ).toEqual( [ 'бързата', 'работа', 'срам', 'за', 'маистора' ] ); + expect( + getNormalizedSearchTerms( 'Cảm ơn sự giúp đỡ của bạn.' ) + ).toEqual( [ 'cam', 'on', 'su', 'giup', 'do', 'cua', 'ban' ] ); } ); } ); diff --git a/packages/block-editor/src/components/inspector-controls-tabs/index.js b/packages/block-editor/src/components/inspector-controls-tabs/index.js index a02aa7cc208a6b..de192050d05cb2 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/index.js +++ b/packages/block-editor/src/components/inspector-controls-tabs/index.js @@ -26,7 +26,6 @@ export default function InspectorControlsTabs( { const initialTabName = ! useIsListViewTabDisabled( blockName ) ? TAB_LIST_VIEW.name : undefined; - return ( 0 ? fillProps : null; return ( { children } diff --git a/packages/block-editor/src/components/keyboard-shortcuts/index.js b/packages/block-editor/src/components/keyboard-shortcuts/index.js index ae41f75e473fca..0e0a57257becca 100644 --- a/packages/block-editor/src/components/keyboard-shortcuts/index.js +++ b/packages/block-editor/src/components/keyboard-shortcuts/index.js @@ -93,6 +93,16 @@ function KeyboardShortcutsRegister() { }, } ); + registerShortcut( { + name: 'core/block-editor/multi-text-selection', + category: 'selection', + description: __( 'Select text across multiple blocks.' ), + keyCombination: { + modifier: 'shift', + character: 'arrow', + }, + } ); + registerShortcut( { name: 'core/block-editor/focus-toolbar', category: 'global', diff --git a/packages/block-editor/src/components/line-height-control/index.js b/packages/block-editor/src/components/line-height-control/index.js index 6fee380d52262b..a57add244cc76d 100644 --- a/packages/block-editor/src/components/line-height-control/index.js +++ b/packages/block-editor/src/components/line-height-control/index.js @@ -12,6 +12,7 @@ import { BASE_DEFAULT_VALUE, RESET_VALUE, STEP, + SPIN_FACTOR, isLineHeightDefined, } from './utils'; @@ -30,24 +31,25 @@ const LineHeightControl = ( { if ( isDefined ) return nextValue; /** - * The following logic handles the initial step up/down action + * The following logic handles the initial spin up/down action * (from an undefined value state) so that the next values are better suited for - * line-height rendering. For example, the first step up should immediately + * line-height rendering. For example, the first spin up should immediately * go to 1.6, rather than the normally expected 0.1. * - * Step up/down actions can be triggered by keydowns of the up/down arrow keys, - * or by clicking the spin buttons. + * Spin up/down actions can be triggered by keydowns of the up/down arrow keys, + * dragging the input or by clicking the spin buttons. */ + const spin = STEP * SPIN_FACTOR; switch ( `${ nextValue }` ) { - case `${ STEP }`: - // Increment by step value. - return BASE_DEFAULT_VALUE + STEP; + case `${ spin }`: + // Increment by spin value. + return BASE_DEFAULT_VALUE + spin; case '0': { - // This means the user explicitly input '0', rather than stepped down - // from an undefined value state. + // This means the user explicitly input '0', rather than using the + // spin down action from an undefined value state. if ( wasTypedOrPasted ) return nextValue; - // Decrement by step value. - return BASE_DEFAULT_VALUE - STEP; + // Decrement by spin value. + return BASE_DEFAULT_VALUE - spin; } case '': return BASE_DEFAULT_VALUE; @@ -111,6 +113,7 @@ const LineHeightControl = ( { label={ __( 'Line height' ) } placeholder={ BASE_DEFAULT_VALUE } step={ STEP } + spinFactor={ SPIN_FACTOR } value={ value } min={ 0 } spinControls="custom" diff --git a/packages/block-editor/src/components/line-height-control/stories/index.js b/packages/block-editor/src/components/line-height-control/stories/index.story.js similarity index 100% rename from packages/block-editor/src/components/line-height-control/stories/index.js rename to packages/block-editor/src/components/line-height-control/stories/index.story.js diff --git a/packages/block-editor/src/components/line-height-control/test/index.js b/packages/block-editor/src/components/line-height-control/test/index.js index ea2eb2b6f6e25b..3b997c07e9212c 100644 --- a/packages/block-editor/src/components/line-height-control/test/index.js +++ b/packages/block-editor/src/components/line-height-control/test/index.js @@ -13,7 +13,9 @@ import { UP, DOWN } from '@wordpress/keycodes'; * Internal dependencies */ import LineHeightControl from '../'; -import { BASE_DEFAULT_VALUE, STEP } from '../utils'; +import { BASE_DEFAULT_VALUE, SPIN_FACTOR, STEP } from '../utils'; + +const SPIN = STEP * SPIN_FACTOR; const ControlledLineHeightControl = () => { const [ value, setValue ] = useState(); @@ -32,7 +34,7 @@ describe( 'LineHeightControl', () => { const input = screen.getByRole( 'spinbutton' ); act( () => input.focus() ); fireEvent.keyDown( input, { keyCode: UP } ); - expect( input ).toHaveValue( BASE_DEFAULT_VALUE + STEP ); + expect( input ).toHaveValue( BASE_DEFAULT_VALUE + SPIN ); } ); it( 'should immediately step down from the default value if down-arrowed from an unset state', () => { @@ -40,7 +42,7 @@ describe( 'LineHeightControl', () => { const input = screen.getByRole( 'spinbutton' ); act( () => input.focus() ); fireEvent.keyDown( input, { keyCode: DOWN } ); - expect( input ).toHaveValue( BASE_DEFAULT_VALUE - STEP ); + expect( input ).toHaveValue( BASE_DEFAULT_VALUE - SPIN ); } ); it( 'should immediately step up from the default value if spin button up was clicked from an unset state', () => { @@ -48,7 +50,7 @@ describe( 'LineHeightControl', () => { const input = screen.getByRole( 'spinbutton' ); act( () => input.focus() ); fireEvent.change( input, { target: { value: 0.1 } } ); // simulates click on spin button up - expect( input ).toHaveValue( BASE_DEFAULT_VALUE + STEP ); + expect( input ).toHaveValue( BASE_DEFAULT_VALUE + SPIN ); } ); it( 'should immediately step down from the default value if spin button down was clicked from an unset state', () => { @@ -56,6 +58,6 @@ describe( 'LineHeightControl', () => { const input = screen.getByRole( 'spinbutton' ); act( () => input.focus() ); fireEvent.change( input, { target: { value: 0 } } ); // simulates click on spin button down - expect( input ).toHaveValue( BASE_DEFAULT_VALUE - STEP ); + expect( input ).toHaveValue( BASE_DEFAULT_VALUE - SPIN ); } ); } ); diff --git a/packages/block-editor/src/components/line-height-control/utils.js b/packages/block-editor/src/components/line-height-control/utils.js index a232394540d2f6..e9bf66259991e3 100644 --- a/packages/block-editor/src/components/line-height-control/utils.js +++ b/packages/block-editor/src/components/line-height-control/utils.js @@ -1,5 +1,10 @@ export const BASE_DEFAULT_VALUE = 1.5; -export const STEP = 0.1; +export const STEP = 0.01; +/** + * A spin factor of 10 allows the spin controls to increment/decrement by 0.1. + * e.g. A line-height value of 1.55 will increment to 1.65. + */ +export const SPIN_FACTOR = 10; /** * There are varying value types within LineHeightControl: * diff --git a/packages/block-editor/src/components/link-control/README.md b/packages/block-editor/src/components/link-control/README.md index c3fc7262adb956..fef68318867e8a 100644 --- a/packages/block-editor/src/components/link-control/README.md +++ b/packages/block-editor/src/components/link-control/README.md @@ -15,6 +15,16 @@ The distinction between the two components is perhaps best summarized by the fol - `` - an input for presenting and managing selection behaviors associated with choosing a URL, optionally from a pool of available candidates. - `` - includes the features of ``, plus additional UI and behaviors to control how this URL applies to the concept of a "link". This includes link "settings" (eg: "opens in new tab", etc) and dynamic, "on the fly" link creation capabilities. +## Persistent "Advanced" (settings) toggle state + +By default the link "settings" are hidden and can be toggled open/closed by way of a button labelled `Advanced` in the UI. + +In some circumstances if may be desirable to persist the toggle state of this portion of the UI so that it remains in the last state triggered by user interaction. + +For example, once the user has toggled the UI to "open", then it may remain open across all links on the site until such time as the user toggles the UI back again. + +Consumers who which to take advantage of this functionality should ensure that their block editor environment utilizes the [`@wordpress/preferences`](packages/preferences/README.md) package. By default the `` component will attempt to persist the state of UI to a setting named `linkControlSettingsDrawer` with a scope of `core/block-editor`. If the preferences package is not available then local state is used and the setting will not be persisted. + ## Search Suggestions When creating links the `LinkControl` component will handle two kinds of input from users: @@ -69,9 +79,7 @@ An array of settings objects associated with a link (for example: a setting to d To disable settings, pass in an empty array. for example: ```jsx - + ``` ### onChange @@ -192,6 +200,7 @@ A `suggestion` should have the following shape: )} /> ``` + ### renderControlBottom - Type: `Function` diff --git a/packages/block-editor/src/components/link-control/constants.js b/packages/block-editor/src/components/link-control/constants.js index eaf07aea73703b..e70ff7b04c7477 100644 --- a/packages/block-editor/src/components/link-control/constants.js +++ b/packages/block-editor/src/components/link-control/constants.js @@ -8,7 +8,7 @@ import { __ } from '@wordpress/i18n'; // order to handle it as a unique case. export const CREATE_TYPE = '__CREATE__'; export const TEL_TYPE = 'tel'; -export const URL_TYPE = 'URL'; +export const URL_TYPE = 'link'; export const MAILTO_TYPE = 'mailto'; export const INTERNAL_TYPE = 'internal'; diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index 142ffd6db1e503..ac1ec660934a3a 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -6,11 +6,14 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { Button, Spinner, Notice } from '@wordpress/components'; +import { Button, Spinner, Notice, TextControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useRef, useState, useEffect } from '@wordpress/element'; import { focus } from '@wordpress/dom'; import { ENTER } from '@wordpress/keycodes'; +import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies @@ -18,8 +21,9 @@ import { ENTER } from '@wordpress/keycodes'; import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchInput from './search-input'; import LinkPreview from './link-preview'; +import LinkSettings from './settings'; import useCreatePage from './use-create-page'; -import useInternalInputValue from './use-internal-input-value'; +import useInternalValue from './use-internal-value'; import { ViewerFill } from './viewer-slot'; import { DEFAULT_LINK_SETTINGS } from './constants'; @@ -99,6 +103,9 @@ import { DEFAULT_LINK_SETTINGS } from './constants'; const noop = () => {}; +const PREFERENCE_SCOPE = 'core/block-editor'; +const PREFERENCE_KEY = 'linkControlSettingsDrawer'; + /** * Renders a link control. A link control is a controlled input which maintains * a value associated with a link (HTML anchor element) and relevant settings @@ -131,18 +138,58 @@ function LinkControl( { withCreateSuggestion = true; } + const [ settingsOpen, setSettingsOpen ] = useState( false ); + + const { advancedSettingsPreference } = useSelect( ( select ) => { + const prefsStore = select( preferencesStore ); + + return { + advancedSettingsPreference: + prefsStore.get( PREFERENCE_SCOPE, PREFERENCE_KEY ) ?? false, + }; + }, [] ); + + const { set: setPreference } = useDispatch( preferencesStore ); + + /** + * Sets the open/closed state of the Advanced Settings Drawer, + * optionlly persisting the state to the user's preferences. + * + * Note that Block Editor components can be consumed by non-WordPress + * environments which may not have preferences setup. + * Therefore a local state is also used as a fallback. + * + * @param {boolean} prefVal the open/closed state of the Advanced Settings Drawer. + */ + const setSettingsOpenWithPreference = ( prefVal ) => { + if ( setPreference ) { + setPreference( PREFERENCE_SCOPE, PREFERENCE_KEY, prefVal ); + } + setSettingsOpen( prefVal ); + }; + + // Block Editor components can be consumed by non-WordPress environments + // which may not have these preferences setup. + // Therefore a local state is used as a fallback. + const isSettingsOpen = advancedSettingsPreference || settingsOpen; + const isMounting = useRef( true ); const wrapperNode = useRef(); const textInputRef = useRef(); const isEndingEditWithFocus = useRef( false ); - const [ settingsOpen, setSettingsOpen ] = useState( false ); + const settingsKeys = settings.map( ( { id } ) => id ); - const [ internalUrlInputValue, setInternalUrlInputValue ] = - useInternalInputValue( value?.url || '' ); + const [ + internalControlValue, + setInternalControlValue, + setInternalURLInputValue, + setInternalTextInputValue, + createSetInternalSettingValueHandler, + ] = useInternalValue( value ); - const [ internalTextInputValue, setInternalTextInputValue ] = - useInternalInputValue( value?.title || '' ); + const valueHasChanges = + value && ! isShallowEqualObjects( internalControlValue, value ); const [ isEditingLink, setIsEditingLink ] = useState( forceIsEditingLink !== undefined @@ -154,12 +201,11 @@ function LinkControl( { useCreatePage( createSuggestion ); useEffect( () => { - if ( - forceIsEditingLink !== undefined && - forceIsEditingLink !== isEditingLink - ) { - setIsEditingLink( forceIsEditingLink ); + if ( forceIsEditingLink === undefined ) { + return; } + + setIsEditingLink( forceIsEditingLink ); }, [ forceIsEditingLink ] ); useEffect( () => { @@ -170,12 +216,6 @@ function LinkControl( { isMounting.current = false; return; } - // Unless we are mounting, we always want to focus either: - // - the URL input - // - the first focusable element in the Link UI. - // But in editing mode if there is a text input present then - // the URL input is at index 1. If not then it is at index 0. - const whichFocusTargetIndex = textInputRef?.current ? 1 : 0; // Scenario - when: // - switching between editable and non editable LinkControl @@ -183,9 +223,8 @@ function LinkControl( { // ...then move focus to the *first* element to avoid focus loss // and to ensure focus is *within* the Link UI. const nextFocusTarget = - focus.focusable.find( wrapperNode.current )[ - whichFocusTargetIndex - ] || wrapperNode.current; + focus.focusable.find( wrapperNode.current )[ 0 ] || + wrapperNode.current; nextFocusTarget.focus(); @@ -203,27 +242,43 @@ function LinkControl( { wrapperNode.current.ownerDocument.activeElement ); - setSettingsOpen( false ); setIsEditingLink( false ); }; const handleSelectSuggestion = ( updatedValue ) => { + // Suggestions may contains "settings" values (e.g. `opensInNewTab`) + // which should not overide any existing settings values set by the + // user. This filters out any settings values from the suggestion. + const nonSettingsChanges = Object.keys( updatedValue ).reduce( + ( acc, key ) => { + if ( ! settingsKeys.includes( key ) ) { + acc[ key ] = updatedValue[ key ]; + } + return acc; + }, + {} + ); + onChange( { - ...updatedValue, - title: internalTextInputValue || updatedValue?.title, + ...internalControlValue, + ...nonSettingsChanges, + // As title is not a setting, it must be manually applied + // in such a way as to preserve the users changes over + // any "title" value provided by the "suggestion". + title: internalControlValue?.title || updatedValue?.title, } ); + stopEditing(); }; const handleSubmit = () => { - if ( - currentUrlInputValue !== value?.url || - internalTextInputValue !== value?.title - ) { + if ( valueHasChanges ) { + // Submit the original value with new stored values applied + // on top. URL is a special case as it may also be a prop. onChange( { ...value, + ...internalControlValue, url: currentUrlInputValue, - title: internalTextInputValue, } ); } stopEditing(); @@ -231,6 +286,7 @@ function LinkControl( { const handleSubmitWithEnter = ( event ) => { const { keyCode } = event; + if ( keyCode === ENTER && ! currentInputIsEmpty // Disallow submitting empty values. @@ -241,8 +297,7 @@ function LinkControl( { }; const resetInternalValues = () => { - setInternalUrlInputValue( value?.url ); - setInternalTextInputValue( value?.title ); + setInternalControlValue( value ); }; const handleCancel = ( event ) => { @@ -263,14 +318,15 @@ function LinkControl( { onCancel?.(); }; - const currentUrlInputValue = propInputValue || internalUrlInputValue; + const currentUrlInputValue = + propInputValue || internalControlValue?.url || ''; const currentInputIsEmpty = ! currentUrlInputValue?.trim()?.length; const shownUnlinkControl = onRemove && value && ! isEditingLink && ! isCreatingPage; - const showSettings = !! settings?.length; + const showActions = isEditingLink && hasLinkValue; // Only show text control once a URL value has been committed // and it isn't just empty whitespace. @@ -278,6 +334,8 @@ function LinkControl( { const showTextControl = hasLinkValue && hasTextControl; const isEditing = ( isEditingLink || ! value ) && ! isCreatingPage; + const isDisabled = ! valueHasChanges || currentInputIsEmpty; + const showSettings = !! settings?.length && isEditingLink && hasLinkValue; return (
+ { showTextControl && ( + + ) }
{ errorMessage && ( @@ -338,43 +408,64 @@ function LinkControl( { onEditClick={ () => setIsEditingLink( true ) } hasRichPreviews={ hasRichPreviews } hasUnlinkControl={ shownUnlinkControl } - onRemove={ onRemove } + additionalControls={ () => { + // Expose the "Opens in new tab" settings in the preview + // as it is the most common setting to change. + if ( + settings?.find( + ( setting ) => setting.id === 'opensInNewTab' + ) + ) { + return ( + id === 'opensInNewTab' + ) } + onChange={ onChange } + /> + ); + } + } } + onRemove={ () => { + onRemove(); + setIsEditingLink( true ); + } } /> ) } - { isEditing && ( + { showSettings && (
- { ( showSettings || showTextControl ) && ( + { ! currentInputIsEmpty && ( + settingsOpen={ isSettingsOpen } + setSettingsOpen={ setSettingsOpenWithPreference } + > + + ) } +
+ ) } -
- - -
+ { showActions && ( +
+ +
) } diff --git a/packages/block-editor/src/components/link-control/is-url-like.js b/packages/block-editor/src/components/link-control/is-url-like.js index 3021ace38a26e1..58861b71b837b8 100644 --- a/packages/block-editor/src/components/link-control/is-url-like.js +++ b/packages/block-editor/src/components/link-control/is-url-like.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { isURL } from '@wordpress/url'; +import { getProtocol, isValidProtocol, isValidFragment } from '@wordpress/url'; /** * Determines whether a given value could be a URL. Note this does not @@ -15,6 +15,43 @@ import { isURL } from '@wordpress/url'; * @return {boolean} whether or not the value is potentially a URL. */ export default function isURLLike( val ) { - const isInternal = val?.startsWith( '#' ); - return isURL( val ) || ( val && val.includes( 'www.' ) ) || isInternal; + const hasSpaces = val.includes( ' ' ); + + if ( hasSpaces ) { + return false; + } + + const protocol = getProtocol( val ); + const protocolIsValid = isValidProtocol( protocol ); + + const mayBeTLD = hasPossibleTLD( val ); + + const isWWW = val?.startsWith( 'www.' ); + + const isInternal = val?.startsWith( '#' ) && isValidFragment( val ); + + return protocolIsValid || isWWW || isInternal || mayBeTLD; +} + +/** + * Checks if a given URL has a valid Top-Level Domain (TLD). + * + * @param {string} url - The URL to check. + * @param {number} maxLength - The maximum length of the TLD. + * @return {boolean} Returns true if the URL has a valid TLD, false otherwise. + */ +function hasPossibleTLD( url, maxLength = 6 ) { + // Clean the URL by removing anything after the first occurrence of "?" or "#". + const cleanedURL = url.split( /[?#]/ )[ 0 ]; + + // Regular expression explanation: + // - (?<=\S) : Positive lookbehind assertion to ensure there is at least one non-whitespace character before the TLD + // - \. : Matches a literal dot (.) + // - [a-zA-Z_]{2,maxLength} : Matches 2 to maxLength letters or underscores, representing the TLD + // - (?:\/|$) : Non-capturing group that matches either a forward slash (/) or the end of the string + const regex = new RegExp( + `(?<=\\S)\\.(?:[a-zA-Z_]{2,${ maxLength }})(?:\\/|$)` + ); + + return regex.test( cleanedURL ); } diff --git a/packages/block-editor/src/components/link-control/link-preview.js b/packages/block-editor/src/components/link-control/link-preview.js index 71e3cd464ada14..8272602cde908d 100644 --- a/packages/block-editor/src/components/link-control/link-preview.js +++ b/packages/block-editor/src/components/link-control/link-preview.js @@ -29,6 +29,7 @@ export default function LinkPreview( { hasRichPreviews = false, hasUnlinkControl = false, onRemove, + additionalControls, } ) { // Avoid fetching if rich previews are not desired. const showRichPreviews = hasRichPreviews ? value?.url : null; @@ -42,11 +43,13 @@ export default function LinkPreview( { ( value && filterURLForDisplay( safeDecodeURI( value.url ), 16 ) ) || ''; - const displayTitle = richData?.title || value?.title || displayURL; - // url can be undefined if the href attribute is unset const isEmptyURL = ! value?.url?.length; + const displayTitle = + ! isEmptyURL && + stripHTML( richData?.title || value?.title || displayURL ); + let icon; if ( richData?.icon ) { @@ -66,6 +69,7 @@ export default function LinkPreview( { 'is-fetching': !! isFetching, 'is-preview': true, 'is-error': isEmptyURL, + 'is-url-title': displayTitle === displayURL, } ) } >
@@ -87,10 +91,10 @@ export default function LinkPreview( { className="block-editor-link-control__search-item-title" href={ value.url } > - { stripHTML( displayTitle ) } + { displayTitle } - { value?.url && ( + { value?.url && displayTitle !== displayURL && ( { displayURL } @@ -164,6 +168,8 @@ export default function LinkPreview( { ) }
) } + + { additionalControls && additionalControls() }
); } diff --git a/packages/block-editor/src/components/link-control/search-create-button.js b/packages/block-editor/src/components/link-control/search-create-button.js index 1786b516df5c02..6a53fd36cf893e 100644 --- a/packages/block-editor/src/components/link-control/search-create-button.js +++ b/packages/block-editor/src/components/link-control/search-create-button.js @@ -1,21 +1,15 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; +import { MenuItem } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; -import { Icon, plus } from '@wordpress/icons'; +import { plus } from '@wordpress/icons'; export const LinkControlSearchCreate = ( { searchTerm, onClick, itemProps, - isSelected, buttonText, } ) => { if ( ! searchTerm ) { @@ -40,27 +34,15 @@ export const LinkControlSearchCreate = ( { } return ( - + { text } + ); }; diff --git a/packages/block-editor/src/components/link-control/search-input.js b/packages/block-editor/src/components/link-control/search-input.js index f38ae4384f1cfa..d6023b9220d630 100644 --- a/packages/block-editor/src/components/link-control/search-input.js +++ b/packages/block-editor/src/components/link-control/search-input.js @@ -46,7 +46,7 @@ const LinkControlSearchInput = forwardRef( suggestionsQuery = {}, withURLSuggestion = true, createSuggestionButtonText, - useLabel = false, + hideLabelFromVision = false, }, ref ) => { @@ -120,14 +120,16 @@ const LinkControlSearchInput = forwardRef( }; const inputClasses = classnames( className, { - 'has-no-label': ! useLabel, + // 'has-no-label': ! hideLabelFromVision, } ); return (
{ + const info = isURL + ? __( 'Press ENTER to add this link' ) + : filterURLForDisplay( safeDecodeURI( suggestion?.url ), 24 ); + return ( - + + ); }; diff --git a/packages/block-editor/src/components/link-control/search-results.js b/packages/block-editor/src/components/link-control/search-results.js index 37ef1f72ce3d8b..71e258c769bf17 100644 --- a/packages/block-editor/src/components/link-control/search-results.js +++ b/packages/block-editor/src/components/link-control/search-results.js @@ -2,13 +2,12 @@ * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { VisuallyHidden } from '@wordpress/components'; +import { VisuallyHidden, MenuGroup } from '@wordpress/components'; /** * External dependencies */ import classnames from 'classnames'; -import { createElement, Fragment } from '@wordpress/element'; /** * Internal dependencies @@ -53,25 +52,16 @@ export default function LinkControlSearchResults( { // See: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role const searchResultsLabelId = `block-editor-link-control-search-results-label-${ instanceId }`; const labelText = isInitialSuggestions - ? __( 'Recently updated' ) + ? __( 'Suggestions' ) : sprintf( /* translators: %s: search term. */ __( 'Search results for "%s"' ), currentInputValue ); - - // VisuallyHidden rightly doesn't accept custom classNames - // so we conditionally render it as a wrapper to visually hide the label - // when that is required. - const searchResultsLabel = createElement( - isInitialSuggestions ? Fragment : VisuallyHidden, - {}, // Empty props. - + const searchResultsLabel = ( + { labelText } - + ); return ( @@ -82,59 +72,61 @@ export default function LinkControlSearchResults( { className={ resultsListClasses } aria-labelledby={ searchResultsLabelId } > - { suggestions.map( ( suggestion, index ) => { - if ( - shouldShowCreateSuggestion && - CREATE_TYPE === suggestion.type - ) { + + { suggestions.map( ( suggestion, index ) => { + if ( + shouldShowCreateSuggestion && + CREATE_TYPE === suggestion.type + ) { + return ( + + handleSuggestionClick( suggestion ) + } + // Intentionally only using `type` here as + // the constant is enough to uniquely + // identify the single "CREATE" suggestion. + key={ suggestion.type } + itemProps={ buildSuggestionItemProps( + suggestion, + index + ) } + isSelected={ index === selectedSuggestion } + /> + ); + } + + // If we're not handling "Create" suggestions above then + // we don't want them in the main results so exit early. + if ( CREATE_TYPE === suggestion.type ) { + return null; + } + return ( - - handleSuggestionClick( suggestion ) - } - // Intentionally only using `type` here as - // the constant is enough to uniquely - // identify the single "CREATE" suggestion. - key={ suggestion.type } + { + handleSuggestionClick( suggestion ); + } } isSelected={ index === selectedSuggestion } + isURL={ LINK_ENTRY_TYPES.includes( + suggestion.type + ) } + searchTerm={ currentInputValue } + shouldShowType={ shouldShowSuggestionsTypes } + isFrontPage={ suggestion?.isFrontPage } /> ); - } - - // If we're not handling "Create" suggestions above then - // we don't want them in the main results so exit early. - if ( CREATE_TYPE === suggestion.type ) { - return null; - } - - return ( - { - handleSuggestionClick( suggestion ); - } } - isSelected={ index === selectedSuggestion } - isURL={ LINK_ENTRY_TYPES.includes( - suggestion.type - ) } - searchTerm={ currentInputValue } - shouldShowType={ shouldShowSuggestionsTypes } - isFrontPage={ suggestion?.isFrontPage } - /> - ); - } ) } + } ) } +
); diff --git a/packages/block-editor/src/components/link-control/settings-drawer.js b/packages/block-editor/src/components/link-control/settings-drawer.js index 0cb12f2c5904c6..8d0c717b1af6cc 100644 --- a/packages/block-editor/src/components/link-control/settings-drawer.js +++ b/packages/block-editor/src/components/link-control/settings-drawer.js @@ -3,33 +3,15 @@ */ import { Button, - TextControl, __unstableMotion as motion, __unstableAnimatePresence as AnimatePresence, } from '@wordpress/components'; -import { settings as settingsIcon } from '@wordpress/icons'; +import { chevronLeftSmall, chevronRightSmall } from '@wordpress/icons'; import { useReducedMotion, useInstanceId } from '@wordpress/compose'; -import { __ } from '@wordpress/i18n'; +import { _x, isRTL } from '@wordpress/i18n'; import { Fragment } from '@wordpress/element'; -/** - * Internal dependencies - */ -import Settings from './settings'; - -function LinkSettingsDrawer( { - settingsOpen, - setSettingsOpen, - showTextControl, - showSettings, - textInputRef, - internalTextInputValue, - setInternalTextInputValue, - handleSubmitWithEnter, - value, - settings, - onChange, -} ) { +function LinkSettingsDrawer( { children, settingsOpen, setSettingsOpen } ) { const prefersReducedMotion = useReducedMotion(); const MaybeAnimatePresence = prefersReducedMotion ? Fragment @@ -46,10 +28,11 @@ function LinkSettingsDrawer( { className="block-editor-link-control__drawer-toggle" aria-expanded={ settingsOpen } onClick={ () => setSettingsOpen( ! settingsOpen ) } - icon={ settingsIcon } - label={ __( 'Link Settings' ) } + icon={ isRTL() ? chevronLeftSmall : chevronRightSmall } aria-controls={ settingsDrawerId } - /> + > + { _x( 'Advanced', 'Additional link settings' ) } + { settingsOpen && (
- { showTextControl && ( - - ) } - { showSettings && ( - - ) } + { children }
) } diff --git a/packages/block-editor/src/components/link-control/settings.js b/packages/block-editor/src/components/link-control/settings.js index 4e71bccaf3286d..1d70cc97dff417 100644 --- a/packages/block-editor/src/components/link-control/settings.js +++ b/packages/block-editor/src/components/link-control/settings.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { ToggleControl, VisuallyHidden } from '@wordpress/components'; +import { CheckboxControl, VisuallyHidden } from '@wordpress/components'; const noop = () => {}; @@ -19,7 +19,7 @@ const LinkControlSettings = ( { value, onChange = noop, settings } ) => { }; const theSettings = settings.map( ( setting ) => ( - .components-base-control__field { - display: flex; - align-items: center; - } - - .components-base-control__label { - margin-right: $grid-unit-20; - margin-bottom: 0; - min-width: 29px; // align with search results. - } - input[type="text"], // Specificity overide of URLInput defaults. &.block-editor-url-input input[type="text"].block-editor-url-input__input { @include input-control; - width: calc(100% - #{$grid-unit-20 * 2}); display: block; - padding: 11px $grid-unit-20; + border: 1px solid $gray-600; + border-radius: $radius-block-ui; + height: $button-size-next-default-40px; // components do not properly support unstable-large yet. margin: 0; + padding: $grid-unit-10 $grid-unit-20; position: relative; - border: 1px solid $gray-300; - border-radius: $radius-block-ui; + width: 100%; } } @@ -94,12 +84,12 @@ $preview-image-height: 140px; flex-direction: row-reverse; // put "Cancel" on the left but retain DOM order. justify-content: flex-start; gap: $grid-unit-10; + padding: $grid-unit-10 $grid-unit-20 $grid-unit-20; order: 20; } .block-editor-link-control__search-results-wrapper { position: relative; - margin-top: -$grid-unit-20 + 1px; &::before, &::after { @@ -125,15 +115,9 @@ $preview-image-height: 140px; } } -.block-editor-link-control__search-results-label { - padding: $grid-unit-20 $grid-unit-40 0; - display: block; - font-weight: 600; -} - .block-editor-link-control__search-results { - margin: 0; - padding: $grid-unit-20 * 0.5 $grid-unit-20 $grid-unit-20 * 0.5; + margin-top: -$grid-unit-20; + padding: $grid-unit-10; max-height: 200px; overflow-y: auto; // allow results list to scroll @@ -143,39 +127,35 @@ $preview-image-height: 140px; } .block-editor-link-control__search-item { - position: relative; - display: flex; - align-items: flex-start; // when link text is very long it is important this indicator remains visible and thus should be aligned top. - font-size: $default-font-size; - cursor: pointer; - background: $white; - width: 100%; - border: none; - text-align: left; - padding: $grid-unit-15 $grid-unit-20; - border-radius: 2px; - height: auto; - &:hover, - &:focus { - background-color: $gray-100; + &.components-button.components-menu-item__button { + height: auto; + text-align: left; + } - .block-editor-link-control__search-item-type { - background: $white; + .components-menu-item__item { + overflow: hidden; + text-overflow: ellipsis; + // Inline block required to preserve white space + // between `` elements and text nodes. + display: inline-block; + width: 100%; + + mark { + font-weight: 600; + color: inherit; + background-color: transparent; } } - // The added specificity is needed to override. - &:focus:not(:disabled) { - box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color) inset; + .components-menu-item__shortcut { + color: $gray-700; + text-transform: capitalize; + white-space: nowrap; // tags shouldn't go over two lines. } - &.is-selected { + &[aria-selected] { background: $gray-100; - - .block-editor-link-control__search-item-type { - background: $white; - } } &.is-current { @@ -213,9 +193,13 @@ $preview-image-height: 140px; align-items: center; } + &.is-url-title .block-editor-link-control__search-item-title { + // To prevent overflow when the title is a URL + word-break: break-all; + } + .block-editor-link-control__search-item-icon { position: relative; - top: 0.2em; margin-right: $grid-unit-10; max-height: 24px; flex-shrink: 0; @@ -234,26 +218,14 @@ $preview-image-height: 140px; max-height: 32px; } - .block-editor-link-control__search-item-info, - .block-editor-link-control__search-item-title { - overflow: hidden; - text-overflow: ellipsis; - - .components-external-link__icon { - position: absolute; - right: 0; - margin-top: 0; - } - } - .block-editor-link-control__search-item-title { display: block; - margin-bottom: 0.2em; font-weight: 500; position: relative; + line-height: $grid-unit-30; mark { - font-weight: 700; + font-weight: 600; color: inherit; background-color: transparent; } @@ -267,28 +239,6 @@ $preview-image-height: 140px; } } - .block-editor-link-control__search-item-info { - display: block; - color: $gray-700; - font-size: 0.9em; - line-height: 1.3; - } - - .block-editor-link-control__search-item-error-notice { - font-style: italic; - font-size: 1.1em; - } - - .block-editor-link-control__search-item-type { - display: block; - padding: 3px 6px; - margin-left: auto; - font-size: 0.9em; - background-color: $gray-100; - border-radius: 2px; - white-space: nowrap; // tags shouldn't go over two lines. - } - .block-editor-link-control__search-item-description { padding-top: 12px; margin: 0; @@ -346,6 +296,7 @@ $preview-image-height: 140px; display: flex; flex-direction: row; width: 100%; // clip. + align-items: center; } .block-editor-link-control__search-item-bottom { @@ -428,11 +379,6 @@ $preview-image-height: 140px; } } -// Specificity override -.block-editor-link-control__search-results div[role="menu"] > .block-editor-link-control__search-item.block-editor-link-control__search-item { - padding: 10px; -} - .block-editor-link-control__drawer { display: flex; // allow for ordering. order: 30; @@ -446,36 +392,7 @@ $preview-image-height: 140px; display: flex; // allow for ordering. flex-direction: column; flex-basis: 100%; // occupy full width. - margin-top: $grid-unit-20; - padding-top: $grid-unit-20; position: relative; - - &::after { - content: ""; - display: block; - height: 1px; - background-color: $gray-300; - position: absolute; - left: -$grid-unit-20; - right: -$grid-unit-20; - top: 0; - } -} - -.block-editor-link-control__tools { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - margin: 0; - padding: $grid-unit-20; - - // To hide the horizontal scrollbar on toggle. - // Margin and padding are needed to prevent cutoff of the toggle button focus outline. - // See: https://github.com/WordPress/gutenberg/pull/47986 - margin-top: calc(var(--wp-admin-border-width-focus) * -1); - padding-top: var(--wp-admin-border-width-focus); - overflow: hidden; } .block-editor-link-control__unlink { @@ -483,21 +400,48 @@ $preview-image-height: 140px; padding-right: $grid-unit-20; } -.block-editor-link-control__settings { +.block-editor-link-control__setting { + margin-bottom: $grid-unit-20; flex: 1; - margin: 0; + padding: $grid-unit-10 0 $grid-unit-10 $grid-unit-30; - .is-alternate & { - border-top: $border-width solid $gray-900; + // Cancel left margin inherited from WP Admin Forms CSS. + input { + margin-left: 0; } -} - -.block-editor-link-control__setting { - margin-bottom: $grid-unit-20; &.block-editor-link-control__setting:last-child { margin-bottom: 0; } + + .is-preview & { + padding: 20px $grid-unit-10 $grid-unit-10 0; + } +} + +.block-editor-link-control__tools { + padding: $grid-unit-10 $grid-unit-10 0 $grid-unit-10; + margin-top: #{$grid-unit-20 * -1}; + + .components-button.block-editor-link-control__drawer-toggle { + padding-left: 0; + gap: 0; + + // Point downwards when open (same as list view expander) + &[aria-expanded="true"] svg { + visibility: visible; + transition: transform 0.1s ease; + transform: rotate(90deg); + @include reduce-motion("transition"); + } + // Point rightwards when closed (same as list view expander) + &[aria-expanded="false"] svg { + visibility: visible; + transform: rotate(0deg); + transition: transform 0.1s ease; + @include reduce-motion("transition"); + } + } } .block-editor-link-control .block-editor-link-control__search-input .components-spinner { diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index 4e2428fc0ac3f8..f8e9aedcbd5bd5 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -138,7 +138,7 @@ describe( 'Basic rendering', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); expect( searchInput ).toBeVisible(); } ); @@ -147,7 +147,7 @@ describe( 'Basic rendering', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); expect( searchInput ).toBeVisible(); // Make sure we use the ARIA 1.0 pattern with aria-owns. @@ -170,7 +170,7 @@ describe( 'Basic rendering', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, 'Hello' ); @@ -196,8 +196,7 @@ describe( 'Basic rendering', () => { within( resultsList ).getAllByRole( 'option' ); expect( searchResultElements ).toHaveLength( - // The fauxEntitySuggestions length plus the 'Press ENTER to add this link' button. - fauxEntitySuggestions.length + 1 + fauxEntitySuggestions.length ); // Step down into the search results, highlighting the first result item. @@ -284,7 +283,7 @@ describe( 'Basic rendering', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -297,7 +296,7 @@ describe( 'Basic rendering', () => { render( ); expect( - screen.queryByRole( 'combobox', { name: 'URL' } ) + screen.queryByRole( 'combobox', { name: 'Link' } ) ).not.toBeInTheDocument(); } ); @@ -310,7 +309,7 @@ describe( 'Basic rendering', () => { ); expect( - screen.getByRole( 'combobox', { name: 'URL' } ) + screen.getByRole( 'combobox', { name: 'Link' } ) ).toBeVisible(); } ); @@ -328,7 +327,7 @@ describe( 'Basic rendering', () => { await user.click( editButton ); expect( - screen.getByRole( 'combobox', { name: 'URL' } ) + screen.getByRole( 'combobox', { name: 'Link' } ) ).toBeVisible(); // If passed `forceIsEditingLink` of `false` while editing, should @@ -341,7 +340,7 @@ describe( 'Basic rendering', () => { ); expect( - screen.queryByRole( 'combobox', { name: 'URL' } ) + screen.queryByRole( 'combobox', { name: 'Link' } ) ).not.toBeInTheDocument(); } ); @@ -406,6 +405,32 @@ describe( 'Basic rendering', () => { expect( mockOnRemove ).toHaveBeenCalled(); } ); + + it( 'should revert to "editing" mode when onRemove is triggered', async () => { + const user = userEvent.setup(); + const mockOnRemove = jest.fn(); + + render( + + ); + + const unLinkButton = screen.queryByRole( 'button', { + name: 'Unlink', + } ); + expect( unLinkButton ).toBeVisible(); + + await user.click( unLinkButton ); + + expect( mockOnRemove ).toHaveBeenCalled(); + + // Should revert back to editing mode. + expect( + screen.getByRole( 'combobox', { name: 'Link' } ) + ).toBeVisible(); + } ); } ); } ); @@ -425,7 +450,7 @@ describe( 'Searching for a link', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -440,44 +465,91 @@ describe( 'Searching for a link', () => { expect( screen.queryByRole( 'presentation' ) ).not.toBeInTheDocument(); } ); - it( 'should display only search suggestions when current input value is not URL-like', async () => { - const user = userEvent.setup(); - const searchTerm = 'Hello world'; - const firstSuggestion = fauxEntitySuggestions[ 0 ]; + it.each( [ 'With spaces', 'Uppercase', 'lowercase' ] )( + 'should display only search suggestions (and not URL result type) when current input value (e.g. %s) is not URL-like', + async ( searchTerm ) => { + const user = userEvent.setup(); + const firstSuggestion = fauxEntitySuggestions[ 0 ]; - render( ); + render( ); - // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + // Search Input UI. + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); - // Simulate searching for a term. - await user.type( searchInput, searchTerm ); + // Simulate searching for a term. + await user.type( searchInput, searchTerm ); - const searchResultElements = within( - await screen.findByRole( 'listbox', { - name: /Search results for.*/, - } ) - ).getAllByRole( 'option' ); + const searchResultElements = within( + await screen.findByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).getAllByRole( 'option' ); - expect( searchResultElements ).toHaveLength( - fauxEntitySuggestions.length - ); + expect( searchResultElements ).toHaveLength( + fauxEntitySuggestions.length + ); - expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); + expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); - // Check that a search suggestion shows up corresponding to the data. - expect( searchResultElements[ 0 ] ).toHaveTextContent( - firstSuggestion.title - ); - expect( searchResultElements[ 0 ] ).toHaveTextContent( - firstSuggestion.type - ); + // Check that a search suggestion shows up corresponding to the data. + expect( searchResultElements[ 0 ] ).toHaveTextContent( + firstSuggestion.title + ); + expect( searchResultElements[ 0 ] ).toHaveTextContent( + firstSuggestion.type + ); - // The fallback URL suggestion should not be shown when input is not URL-like. - expect( - searchResultElements[ searchResultElements.length - 1 ] - ).not.toHaveTextContent( 'URL' ); - } ); + // The fallback URL suggestion should not be shown when input is not URL-like. + expect( + searchResultElements[ searchResultElements.length - 1 ] + ).not.toHaveTextContent( 'Press ENTER to add this link' ); + } + ); + + it.each( [ + [ 'https://wordpress.org', 'link' ], + [ 'http://wordpress.org', 'link' ], + [ 'www.wordpress.org', 'link' ], + [ 'wordpress.org', 'link' ], + [ 'ftp://wordpress.org', 'link' ], + [ 'mailto:hello@wordpress.org', 'mailto' ], + [ 'tel:123456789', 'tel' ], + [ '#internal', 'internal' ], + ] )( + 'should display only URL result when current input value is URL-like (e.g. %s)', + async ( searchTerm, type ) => { + const user = userEvent.setup(); + + render( ); + + // Search Input UI. + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); + + // Simulate searching for a term. + await user.type( searchInput, searchTerm ); + + const searchResultElement = within( + await screen.findByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).getByRole( 'option' ); + + expect( searchResultElement ).toBeInTheDocument(); + + // Should only be the `URL` suggestion. + expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); + + expect( searchResultElement ).toHaveTextContent( searchTerm ); + expect( searchResultElement ).toHaveTextContent( type ); + expect( searchResultElement ).toHaveTextContent( + 'Press ENTER to add this link' + ); + } + ); it( 'should trim search term', async () => { const user = userEvent.setup(); @@ -486,7 +558,7 @@ describe( 'Searching for a link', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -504,8 +576,7 @@ describe( 'Searching for a link', () => { .flat() .filter( Boolean ); - // Given we're mocking out the results we should always have 4 mark elements. - expect( searchResultTextHighlightElements ).toHaveLength( 4 ); + expect( searchResultTextHighlightElements ).toHaveLength( 3 ); // Make sure there are no `mark` elements which contain anything other // than the trimmed search term (ie: no whitespace). @@ -530,7 +601,7 @@ describe( 'Searching for a link', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, 'anything' ); @@ -541,51 +612,43 @@ describe( 'Searching for a link', () => { expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled(); } ); - it.each( [ - [ 'couldbeurlorentitysearchterm' ], - [ 'ThisCouldAlsoBeAValidURL' ], - ] )( - 'should display a URL suggestion as a default fallback for the search term "%s" which could potentially be a valid url.', - async ( searchTerm ) => { - const user = userEvent.setup(); - render( ); + it( 'should not display a URL suggestion when input is not likely to be a URL.', async () => { + const searchTerm = 'unlikelytobeaURL'; + const user = userEvent.setup(); + render( ); - // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + // Search Input UI. + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); - // Simulate searching for a term. - await user.type( searchInput, searchTerm ); + // Simulate searching for a term. + await user.type( searchInput, searchTerm ); - const searchResultElements = within( - await screen.findByRole( 'listbox', { - name: /Search results for.*/, - } ) - ).getAllByRole( 'option' ); + const searchResultElements = within( + await screen.findByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).getAllByRole( 'option' ); - const lastSearchResultItem = - searchResultElements[ searchResultElements.length - 1 ]; + const lastSearchResultItem = + searchResultElements[ searchResultElements.length - 1 ]; - // We should see a search result for each of the expect search suggestions - // plus 1 additional one for the fallback URL suggestion. - expect( searchResultElements ).toHaveLength( - fauxEntitySuggestions.length + 1 - ); + // We should see a search result for each of the expect search suggestions. + expect( searchResultElements ).toHaveLength( + fauxEntitySuggestions.length + ); - // The last item should be a URL search suggestion. - expect( lastSearchResultItem ).toHaveTextContent( searchTerm ); - expect( lastSearchResultItem ).toHaveTextContent( 'URL' ); - expect( lastSearchResultItem ).toHaveTextContent( - 'Press ENTER to add this link' - ); - } - ); + // The URL search suggestion should not exist. + expect( lastSearchResultItem ).not.toHaveTextContent( + 'Press ENTER to add this link' + ); + } ); it( 'should not display a URL suggestion as a default fallback when noURLSuggestion is passed.', async () => { const user = userEvent.setup(); render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, 'couldbeurlorentitysearchterm' ); @@ -615,7 +678,9 @@ describe( 'Manual link entry', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -628,7 +693,6 @@ describe( 'Manual link entry', () => { expect( searchResultElements ).toBeVisible(); expect( searchResultElements ).toHaveTextContent( searchTerm ); - expect( searchResultElements ).toHaveTextContent( 'URL' ); expect( searchResultElements ).toHaveTextContent( 'Press ENTER to add this link' ); @@ -651,16 +715,9 @@ describe( 'Manual link entry', () => { // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); - let submitButton = screen.getByRole( 'button', { - name: 'Apply', - } ); - - expect( submitButton ).toBeDisabled(); - expect( submitButton ).toBeVisible(); - if ( searchString.length ) { // Simulate searching for a term. await user.type( searchInput, searchString ); @@ -672,93 +729,74 @@ describe( 'Manual link entry', () => { // Attempt to submit the empty search value in the input. await user.keyboard( '[Enter]' ); - submitButton = screen.getByRole( 'button', { - name: 'Apply', - } ); - - // Verify the UI hasn't allowed submission. + // Verify the UI hasn't allowed submission because + // the search input is still visible. expect( searchInput ).toBeVisible(); - expect( submitButton ).toBeDisabled(); - expect( submitButton ).toBeVisible(); } ); it.each( testTable )( - 'should not allow creation of links %s via the UI "submit" button', + 'should not allow editing of links to a new link %s via the UI "submit" button', async ( _desc, searchString ) => { const user = userEvent.setup(); - render( ); + render( + + ); // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); - let submitButton = screen.queryByRole( 'button', { - name: 'Apply', - } ); + // Remove the existing link. + await user.clear( searchInput ); - expect( submitButton ).toBeDisabled(); - expect( submitButton ).toBeVisible(); - - // Simulate searching for a term. if ( searchString.length ) { - // Simulate searching for a term. await user.type( searchInput, searchString ); } else { // Simulate clearing the search term. await user.clear( searchInput ); } + const submitButton = screen.queryByRole( 'button', { + name: 'Save', + } ); + + // debug the UI state + // screen.debug(); + + // Verify the submission UI is disabled. + expect( submitButton ).toBeVisible(); + expect( submitButton ).toHaveAttribute( + 'aria-disabled', + 'true' + ); + // Attempt to submit the empty search value in the input. await user.click( submitButton ); - submitButton = screen.queryByRole( 'button', { - name: 'Apply', - } ); - - // Verify the UI hasn't allowed submission. + // Verify the UI hasn't allowed submission because + // the search input is still visible. expect( searchInput ).toBeVisible(); - expect( submitButton ).toBeDisabled(); - expect( submitButton ).toBeVisible(); } ); } ); describe( 'Handling cancellation', () => { - it( 'should allow cancellation of the link creation process and reset any entered values', async () => { - const user = userEvent.setup(); + it( 'should not show cancellation button during link creation', async () => { const mockOnRemove = jest.fn(); - const mockOnCancel = jest.fn(); render( ); - // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { - name: 'URL', - } ); - const cancelButton = screen.queryByRole( 'button', { name: 'Cancel', } ); - expect( cancelButton ).toBeEnabled(); - expect( cancelButton ).toBeVisible(); - - // Simulate adding a link for a term. - await user.type( searchInput, 'https://www.wordpress.org' ); - - // Attempt to submit the empty search value in the input. - await user.click( cancelButton ); - - // Verify the consumer can handle the cancellation. - expect( mockOnRemove ).toHaveBeenCalled(); - - // Ensure optional callback is not called. - expect( mockOnCancel ).not.toHaveBeenCalled(); - - expect( searchInput ).toHaveValue( '' ); + expect( cancelButton ).not.toBeInTheDocument(); } ); it( 'should allow cancellation of the link editing process and reset any entered values', async () => { @@ -795,7 +833,7 @@ describe( 'Manual link entry', () => { await toggleSettingsDrawer( user ); let searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); let textInput = screen.getByRole( 'textbox', { @@ -830,7 +868,7 @@ describe( 'Manual link entry', () => { // Re-query the inputs as they have been replaced. searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); textInput = screen.getByRole( 'textbox', { @@ -846,7 +884,13 @@ describe( 'Manual link entry', () => { const user = userEvent.setup(); const mockOnCancel = jest.fn(); - render( ); + render( + + ); const cancelButton = screen.queryByRole( 'button', { name: 'Cancel', @@ -872,7 +916,7 @@ describe( 'Manual link entry', () => { // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); // Simulate searching for a term. @@ -901,14 +945,14 @@ describe( 'Default search suggestions', () => { expect( await screen.findByRole( 'listbox', { - name: 'Recently updated', + name: 'Suggestions', } ) ).toBeVisible(); // Verify input has no value has default suggestions should only show // when this does not have a value. // Search Input UI. - expect( screen.getByRole( 'combobox', { name: 'URL' } ) ).toHaveValue( + expect( screen.getByRole( 'combobox', { name: 'Link' } ) ).toHaveValue( '' ); @@ -929,8 +973,6 @@ describe( 'Default search suggestions', () => { const initialValue = fauxEntitySuggestions[ 0 ]; render( ); - expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled(); - // Click the "Edit/Change" button and check initial suggestions are not // shown. const currentLinkUI = screen.getByLabelText( 'Currently selected' ); @@ -939,25 +981,19 @@ describe( 'Default search suggestions', () => { } ); await user.click( currentLinkBtn ); - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); + // Search input is set to the URL value. expect( searchInput ).toHaveValue( initialValue.url ); - // Focus the search input to display suggestions - await user.click( searchInput ); - - const searchResultElements = within( - await screen.findByRole( 'listbox', { + // Ensure no initial suggestions are shown. + expect( + screen.queryByRole( 'listbox', { name: /Search results for.*/, } ) - ).getAllByRole( 'option' ); - - // It should match any url that's like ?p= and also include a URL option. - expect( searchResultElements ).toHaveLength( 5 ); - - expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); + ).not.toBeInTheDocument(); - expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 ); + expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled(); } ); it( 'should display initial suggestions when input value is manually deleted', async () => { @@ -967,7 +1003,7 @@ describe( 'Default search suggestions', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -991,7 +1027,7 @@ describe( 'Default search suggestions', () => { expect( searchInput ).toHaveValue( '' ); const initialResultsList = await screen.findByRole( 'listbox', { - name: 'Recently updated', + name: 'Suggestions', } ); expect( @@ -1005,10 +1041,10 @@ describe( 'Default search suggestions', () => { render( ); - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); const searchResultsField = screen.queryByRole( 'listbox', { - name: 'Recently updated', + name: 'Suggestions', } ); expect( searchResultsField ).not.toBeInTheDocument(); @@ -1063,7 +1099,9 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); // Simulate searching for a term. await user.type( searchInput, entityNameText ); @@ -1130,7 +1168,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, 'Some new page to create' ); @@ -1179,7 +1217,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, entityNameText ); @@ -1222,7 +1260,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, entityNameText ); @@ -1246,7 +1284,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); const searchResultsField = screen.queryByRole( 'listbox' ); @@ -1266,7 +1304,9 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); const searchResultsField = screen.queryByRole( 'listbox' ); @@ -1289,7 +1329,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); // Simulate searching for a term. @@ -1323,7 +1363,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { render( ); // Search Input UI. - searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, searchText ); @@ -1338,7 +1378,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { await user.click( createButton ); - searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); const errorNotice = screen.getAllByText( 'API response returned invalid entity.' @@ -1411,7 +1451,7 @@ describe( 'Selecting links', () => { // Simulate searching for a term. await user.click( currentLinkBtn ); - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); currentLinkUI = screen.queryByLabelText( 'Currently selected' ); // We should be back to showing the search input. @@ -1452,7 +1492,7 @@ describe( 'Selecting links', () => { // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); // Simulate searching for a term. @@ -1514,7 +1554,7 @@ describe( 'Selecting links', () => { // Search Input UI. const searchInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', } ); // Simulate searching for a term. @@ -1598,19 +1638,21 @@ describe( 'Selecting links', () => { expect( await screen.findByRole( 'listbox', { - name: 'Recently updated', + name: 'Suggestions', } ) ).toBeVisible(); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); // Step down into the search results, highlighting the first result item. triggerArrowDown( searchInput ); const searchResultElements = within( screen.getByRole( 'listbox', { - name: 'Recently updated', + name: 'Suggestions', } ) ).getAllByRole( 'option' ); @@ -1652,11 +1694,107 @@ describe( 'Selecting links', () => { expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 ); } ); + + it( 'should not show search results on URL input focus when the URL has not changed', async () => { + const selectedLink = fauxEntitySuggestions[ 0 ]; + + render( ); + + // focus the search input + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); + + fireEvent.focus( searchInput ); + + // check that the search results are not visible + expect( + screen.queryByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).not.toBeInTheDocument(); + + // check that the mock fetch function was not called + expect( mockFetchSearchSuggestions ).not.toHaveBeenCalled(); + + // check that typing in the search input to make the value dirty + // does trigger search results + fireEvent.change( searchInput, { target: { value: 'changes' } } ); + + expect( + await screen.findByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).toBeVisible(); + + // check the mock fetch function was called + expect( mockFetchSearchSuggestions ).toHaveBeenCalledTimes( 1 ); + } ); } ); } ); describe( 'Addition Settings UI', () => { - it( 'should not show a means to toggle the link settings when not editing a link', async () => { + it( 'should allow toggling the "Opens in new tab" setting control (only) on the link preview', async () => { + const user = userEvent.setup(); + const selectedLink = fauxEntitySuggestions[ 0 ]; + const mockOnChange = jest.fn(); + + const customSettings = [ + { + id: 'opensInNewTab', + title: 'Open in new tab', + }, + { + id: 'noFollow', + title: 'No follow', + }, + ]; + + const LinkControlConsumer = () => { + const [ link, setLink ] = useState( selectedLink ); + + return ( + { + mockOnChange( newVal ); + setLink( newVal ); + } } + /> + ); + }; + + render( ); + + const opensInNewTabField = screen.queryByRole( 'checkbox', { + name: 'Open in new tab', + checked: false, + } ); + + expect( opensInNewTabField ).toBeInTheDocument(); + + // No matter which settings are passed in only the `Opens in new tab` + // setting should be shown on the link preview (non-editing) state. + const noFollowField = screen.queryByRole( 'checkbox', { + name: 'No follow', + } ); + expect( noFollowField ).not.toBeInTheDocument(); + + // Check that the link value is updated immediately upon checking + // the checkbox. + await user.click( opensInNewTabField ); + + expect( opensInNewTabField ).toBeChecked(); + + expect( mockOnChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnChange ).toHaveBeenCalledWith( { + ...selectedLink, + opensInNewTab: true, + } ); + } ); + + it( 'should hide advanced link settings and toggle when not editing a link', async () => { const selectedLink = fauxEntitySuggestions[ 0 ]; const LinkControlConsumer = () => { @@ -1667,14 +1805,12 @@ describe( 'Addition Settings UI', () => { render( ); - const settingsToggle = screen.queryByRole( 'button', { - name: 'Link Settings', - ariaControls: 'link-settings-1', - } ); + const settingsToggle = getSettingsDrawerToggle(); expect( settingsToggle ).not.toBeInTheDocument(); } ); - it( 'should provides a means to toggle the link settings', async () => { + + it( 'should provides a means to toggle the advanced link settings when editing a link', async () => { const selectedLink = fauxEntitySuggestions[ 0 ]; const LinkControlConsumer = () => { @@ -1687,10 +1823,7 @@ describe( 'Addition Settings UI', () => { const user = userEvent.setup(); - const settingsToggle = screen.queryByRole( 'button', { - name: 'Link Settings', - ariaControls: 'link-settings-1', - } ); + const settingsToggle = getSettingsDrawerToggle(); expect( settingsToggle ).toHaveAttribute( 'aria-expanded', 'false' ); @@ -1712,7 +1845,7 @@ describe( 'Addition Settings UI', () => { expect( newTabSettingInput ).not.toBeVisible(); } ); - it( 'should display "New Tab" setting (in "off" mode) by default when a link is selected', async () => { + it( 'should display "New Tab" setting (in "off" mode) by default when a link is edited', async () => { const selectedLink = fauxEntitySuggestions[ 0 ]; const expectedSettingText = 'Open in new tab'; @@ -1744,7 +1877,7 @@ describe( 'Addition Settings UI', () => { const customSettings = [ { - id: 'newTab', + id: 'opensInNewTab', title: 'Open in new tab', }, { @@ -1784,6 +1917,63 @@ describe( 'Addition Settings UI', () => { } ) ).toBeChecked(); } ); + + it( 'should require settings changes to be submitted/applied', async () => { + const user = userEvent.setup(); + + const mockOnChange = jest.fn(); + + const selectedLink = { + ...fauxEntitySuggestions[ 0 ], + // Including a setting here helps to assert on a potential bug + // whereby settings on the suggestion override the current (internal) + // settings values set by the user in the UI. + opensInNewTab: false, + }; + + render( + + ); + + // check that the "Apply" button is disabled by default. + const submitButton = screen.queryByRole( 'button', { + name: 'Save', + } ); + + expect( submitButton ).toHaveAttribute( 'aria-disabled', 'true' ); + + await toggleSettingsDrawer( user ); + + const opensInNewTabToggle = screen.queryByRole( 'checkbox', { + name: 'Open in new tab', + } ); + + // toggle the checkbox + await user.click( opensInNewTabToggle ); + + // Check settings are **not** directly submitted + // which would trigger the onChange handler. + expect( mockOnChange ).not.toHaveBeenCalled(); + + // Check Apply button is now enabled because changes + // have been detected. + expect( submitButton ).toBeEnabled(); + + // Submit the changed setting value using the Apply button + await user.click( submitButton ); + + // Assert the value is updated. + expect( mockOnChange ).toHaveBeenCalledWith( + expect.objectContaining( { + opensInNewTab: true, + } ) + ); + } ); } ); describe( 'Post types', () => { @@ -1794,7 +1984,7 @@ describe( 'Post types', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { name: 'Link' } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -1821,7 +2011,9 @@ describe( 'Post types', () => { render( ); // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + const searchInput = screen.getByRole( 'combobox', { + name: 'Link', + } ); // Simulate searching for a term. await user.type( searchInput, searchTerm ); @@ -2185,7 +2377,7 @@ describe( 'Controlling link title text', () => { expect( textInput ).toHaveValue( textValue ); const submitButton = screen.queryByRole( 'button', { - name: 'Apply', + name: 'Save', } ); await user.click( submitButton ); @@ -2199,7 +2391,7 @@ describe( 'Controlling link title text', () => { it( 'should allow `ENTER` keypress within the text field to trigger submission of value', async () => { const user = userEvent.setup(); - const textValue = 'My new text value'; + const newTextValue = 'My new text value'; const mockOnChange = jest.fn(); render( @@ -2218,14 +2410,14 @@ describe( 'Controlling link title text', () => { expect( textInput ).toBeVisible(); await user.clear( textInput ); - await user.keyboard( textValue ); + await user.keyboard( newTextValue ); // Attempt to submit the empty search value in the input. triggerEnter( textInput ); expect( mockOnChange ).toHaveBeenCalledWith( expect.objectContaining( { - title: textValue, + title: newTextValue, url: selectedLink.url, } ) ); @@ -2236,7 +2428,7 @@ describe( 'Controlling link title text', () => { ).not.toBeInTheDocument(); } ); - it( 'should reset state on value change', async () => { + it( 'should reset state upon controlled value change', async () => { const user = userEvent.setup(); const textValue = 'My new text value'; const mockOnChange = jest.fn(); @@ -2279,10 +2471,14 @@ describe( 'Controlling link title text', () => { } ); } ); -async function toggleSettingsDrawer( user ) { - const settingsToggle = screen.queryByRole( 'button', { - name: 'Link Settings', +function getSettingsDrawerToggle() { + return screen.queryByRole( 'button', { + name: 'Advanced', } ); +} + +async function toggleSettingsDrawer( user ) { + const settingsToggle = getSettingsDrawerToggle(); await user.click( settingsToggle ); } diff --git a/packages/block-editor/src/components/link-control/test/is-url-like.js b/packages/block-editor/src/components/link-control/test/is-url-like.js new file mode 100644 index 00000000000000..37bec089931931 --- /dev/null +++ b/packages/block-editor/src/components/link-control/test/is-url-like.js @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import isURLLike from '../is-url-like'; + +describe( 'isURLLike', () => { + it.each( [ 'https://wordpress.org', 'http://wordpress.org' ] )( + 'returns true for a string that starts with an http(s) protocol', + ( testString ) => { + expect( isURLLike( testString ) ).toBe( true ); + } + ); + + it.each( [ + 'hello world', + 'https:// has spaces even though starts with protocol', + 'www. wordpress . org', + ] )( + 'returns false for any string with spaces (e.g. "%s")', + ( testString ) => { + expect( isURLLike( testString ) ).toBe( false ); + } + ); + + it( 'returns false for a string without a protocol or a TLD', () => { + expect( isURLLike( 'somedirectentryhere' ) ).toBe( false ); + } ); + + it( 'returns true for a string beginning with www.', () => { + expect( isURLLike( 'www.wordpress.org' ) ).toBe( true ); + } ); + + it.each( [ 'mailto:test@wordpress.org', 'tel:123456' ] )( + 'returns true for common protocols', + ( testString ) => { + expect( isURLLike( testString ) ).toBe( true ); + } + ); + + it( 'returns true for internal anchor ("hash") links.', () => { + expect( isURLLike( '#someinternallink' ) ).toBe( true ); + } ); + + // use .each to test multiple cases + it.each( [ + [ true, 'http://example.com' ], + [ true, 'https://test.co.uk?query=param' ], + [ true, 'ftp://openai.ai?param=value#section' ], + [ true, 'example.com' ], + [ true, 'http://example.com?query=param#section' ], + [ true, 'https://test.co.uk/some/path' ], + [ true, 'ftp://openai.ai/some/path' ], + [ true, 'example.org/some/path' ], + [ true, 'example_test.tld' ], + [ true, 'example_test.com' ], + [ false, 'example' ], + [ false, '.com' ], + [ true, '_test.com' ], + [ true, 'http://example_test.com' ], + ] )( + 'returns %s when testing against string "%s" for a valid TLD', + ( expected, testString ) => { + expect( isURLLike( testString ) ).toBe( expected ); + } + ); +} ); diff --git a/packages/block-editor/src/components/link-control/use-internal-input-value.js b/packages/block-editor/src/components/link-control/use-internal-input-value.js deleted file mode 100644 index 5dd3c59f3e873a..00000000000000 --- a/packages/block-editor/src/components/link-control/use-internal-input-value.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -export default function useInternalInputValue( value ) { - const [ internalInputValue, setInternalInputValue ] = useState( - value || '' - ); - - // If the value prop changes, update the internal state. - useEffect( () => { - setInternalInputValue( ( prevValue ) => { - if ( value && value !== prevValue ) { - return value; - } - - return prevValue; - } ); - }, [ value ] ); - - return [ internalInputValue, setInternalInputValue ]; -} diff --git a/packages/block-editor/src/components/link-control/use-internal-value.js b/packages/block-editor/src/components/link-control/use-internal-value.js new file mode 100644 index 00000000000000..ac58c05b10a870 --- /dev/null +++ b/packages/block-editor/src/components/link-control/use-internal-value.js @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +export default function useInternalValue( value ) { + const [ internalValue, setInternalValue ] = useState( value || {} ); + + // If the value prop changes, update the internal state. + useEffect( () => { + setInternalValue( ( prevValue ) => { + if ( value && value !== prevValue ) { + return value; + } + + return prevValue; + } ); + }, [ value ] ); + + const setInternalURLInputValue = ( nextValue ) => { + setInternalValue( { + ...internalValue, + url: nextValue, + } ); + }; + + const setInternalTextInputValue = ( nextValue ) => { + setInternalValue( { + ...internalValue, + title: nextValue, + } ); + }; + + const createSetInternalSettingValueHandler = + ( settingsKeys ) => ( nextValue ) => { + // Only apply settings values which are defined in the settings prop. + const settingsUpdates = Object.keys( nextValue ).reduce( + ( acc, key ) => { + if ( settingsKeys.includes( key ) ) { + acc[ key ] = nextValue[ key ]; + } + return acc; + }, + {} + ); + + setInternalValue( { + ...internalValue, + ...settingsUpdates, + } ); + }; + + return [ + internalValue, + setInternalValue, + setInternalURLInputValue, + setInternalTextInputValue, + createSetInternalSettingValueHandler, + ]; +} diff --git a/packages/block-editor/src/components/link-control/use-search-handler.js b/packages/block-editor/src/components/link-control/use-search-handler.js index 47c20ed60b8e32..77b91901bac5c8 100644 --- a/packages/block-editor/src/components/link-control/use-search-handler.js +++ b/packages/block-editor/src/components/link-control/use-search-handler.js @@ -51,23 +51,16 @@ const handleEntitySearch = async ( val, suggestionsQuery, fetchSearchSuggestions, - directEntryHandler, withCreateSuggestion, - withURLSuggestion, pageOnFront ) => { const { isInitialSuggestions } = suggestionsQuery; - let resultsIncludeFrontPage = false; - let results = await Promise.all( [ - fetchSearchSuggestions( val, suggestionsQuery ), - directEntryHandler( val ), - ] ); + const results = await fetchSearchSuggestions( val, suggestionsQuery ); // Identify front page and update type to match. - results[ 0 ] = results[ 0 ].map( ( result ) => { + results.map( ( result ) => { if ( Number( result.id ) === pageOnFront ) { - resultsIncludeFrontPage = true; result.isFrontPage = true; return result; } @@ -75,22 +68,6 @@ const handleEntitySearch = async ( return result; } ); - const couldBeURL = ! val.includes( ' ' ); - - // If it's potentially a URL search then concat on a URL search suggestion - // just for good measure. That way once the actual results run out we always - // have a URL option to fallback on. - if ( - ! resultsIncludeFrontPage && - couldBeURL && - withURLSuggestion && - ! isInitialSuggestions - ) { - results = results[ 0 ].concat( results[ 1 ] ); - } else { - results = results[ 0 ]; - } - // If displaying initial suggestions just return plain results. if ( isInitialSuggestions ) { return results; @@ -150,12 +127,18 @@ export default function useSearchHandler( val, { ...suggestionsQuery, isInitialSuggestions }, fetchSearchSuggestions, - directEntryHandler, withCreateSuggestion, withURLSuggestion, pageOnFront ); }, - [ directEntryHandler, fetchSearchSuggestions, withCreateSuggestion ] + [ + directEntryHandler, + fetchSearchSuggestions, + pageOnFront, + suggestionsQuery, + withCreateSuggestion, + withURLSuggestion, + ] ); } diff --git a/packages/block-editor/src/components/list-view/appender.js b/packages/block-editor/src/components/list-view/appender.js index cb731bbf227a8b..ec46a1d211ab65 100644 --- a/packages/block-editor/src/components/list-view/appender.js +++ b/packages/block-editor/src/components/list-view/appender.js @@ -14,22 +14,22 @@ import { store as blockEditorStore } from '../../store'; import useBlockDisplayTitle from '../block-title/use-block-display-title'; import { useListViewContext } from './context'; import Inserter from '../inserter'; +import AriaReferencedText from './aria-referenced-text'; export const Appender = forwardRef( ( { nestingLevel, blockCount, clientId, ...props }, ref ) => { const { insertedBlock, setInsertedBlock } = useListViewContext(); const instanceId = useInstanceId( Appender ); - const { hideInserter } = useSelect( + const hideInserter = useSelect( ( select ) => { const { getTemplateLock, __unstableGetEditorMode } = select( blockEditorStore ); - return { - hideInserter: - !! getTemplateLock( clientId ) || - __unstableGetEditorMode() === 'zoom-out', - }; + return ( + !! getTemplateLock( clientId ) || + __unstableGetEditorMode() === 'zoom-out' + ); }, [ clientId ] ); @@ -90,12 +90,9 @@ export const Appender = forwardRef( } } } /> -
+ { description } -
+ ); } diff --git a/packages/block-editor/src/components/list-view/aria-referenced-text.js b/packages/block-editor/src/components/list-view/aria-referenced-text.js new file mode 100644 index 00000000000000..b5d7a73e8bcf52 --- /dev/null +++ b/packages/block-editor/src/components/list-view/aria-referenced-text.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { useRef, useEffect } from '@wordpress/element'; + +/** + * A component specifically designed to be used as an element referenced + * by ARIA attributes such as `aria-labelledby` or `aria-describedby`. + * + * @param {Object} props Props. + * @param {import('react').ReactNode} props.children + */ +export default function AriaReferencedText( { children, ...props } ) { + const ref = useRef(); + + useEffect( () => { + if ( ref.current ) { + // This seems like a no-op, but it fixes a bug in Firefox where + // it fails to recompute the text when only the text node changes. + // @see https://github.com/WordPress/gutenberg/pull/51035 + ref.current.textContent = ref.current.textContent; + } + }, [ children ] ); + + return ( + + ); +} diff --git a/packages/block-editor/src/components/list-view/block-contents.js b/packages/block-editor/src/components/list-view/block-contents.js index 37bc35c6a528d6..8d5b03395f3e20 100644 --- a/packages/block-editor/src/components/list-view/block-contents.js +++ b/packages/block-editor/src/components/list-view/block-contents.js @@ -44,10 +44,10 @@ const ListViewBlockContents = forwardRef( selectedBlockInBlockEditor: getSelectedBlockClientId(), }; }, - [ clientId ] + [] ); - const { renderAdditionalBlockUI, insertedBlock, setInsertedBlock } = + const { AdditionalBlockContent, insertedBlock, setInsertedBlock } = useListViewContext(); const isBlockMoveTarget = @@ -67,12 +67,13 @@ const ListViewBlockContents = forwardRef( return ( <> - { renderAdditionalBlockUI && - renderAdditionalBlockUI( - block, - insertedBlock, - setInsertedBlock - ) } + { AdditionalBlockContent && ( + + ) } { ( { draggable, onDragStart, onDragEnd } ) => ( 0 && + getSelectedBlockClientIds().length === 0; + + // If there's no previous block nor parent block, focus the first block. + if ( ! blockToFocus ) { + blockToFocus = getBlockOrder()[ 0 ]; + } + + updateFocusAndSelection( blockToFocus, shouldUpdateSelection ); + } else if ( isMatch( 'core/block-editor/duplicate', event ) ) { + if ( event.defaultPrevented ) { + return; + } + event.preventDefault(); + + const { blocksToUpdate, firstBlockRootClientId } = + getBlocksToUpdate(); + + const canDuplicate = getBlocksByClientId( blocksToUpdate ).every( + ( block ) => { + return ( + !! block && + hasBlockSupport( block.name, 'multiple', true ) && + canInsertBlockType( block.name, firstBlockRootClientId ) + ); + } + ); + + if ( canDuplicate ) { + const updatedBlocks = await duplicateBlocks( + blocksToUpdate, + false + ); + + if ( updatedBlocks?.length ) { + // If blocks have been duplicated, focus the first duplicated block. + updateFocusAndSelection( updatedBlocks[ 0 ], false ); + } + } } } @@ -71,7 +196,9 @@ function ListViewBlockSelectButton( className ) } onClick={ onClick } + onContextMenu={ onContextMenu } onKeyDown={ onKeyDownHandler } + onMouseDown={ onMouseDown } ref={ ref } tabIndex={ tabIndex } onFocus={ onFocus } @@ -108,6 +235,30 @@ function ListViewBlockSelectButton( ) } + { positionLabel && isSticky && ( + + + + + + ) } + { images.length ? ( + + { images.map( ( image, index ) => ( + + ) ) } + + ) : null } { isLocked && ( diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index dc863dd337c0c3..583a3c991d0aca 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -18,10 +18,12 @@ import { useRef, useEffect, useCallback, + useMemo, memo, } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { sprintf, __ } from '@wordpress/i18n'; +import { focus } from '@wordpress/dom'; /** * Internal dependencies @@ -38,7 +40,7 @@ import { getBlockPositionDescription } from './utils'; import { store as blockEditorStore } from '../../store'; import useBlockDisplayInformation from '../use-block-display-information'; import { useBlockLock } from '../block-lock'; -import { unlock } from '../../lock-unlock'; +import AriaReferencedText from './aria-referenced-text'; function ListViewBlock( { block: { clientId }, @@ -58,7 +60,9 @@ function ListViewBlock( { } ) { const cellRef = useRef( null ); const rowRef = useRef( null ); + const settingsRef = useRef( null ); const [ isHovered, setIsHovered ] = useState( false ); + const [ settingsAnchorRect, setSettingsAnchorRect ] = useState(); const { isLocked, canEdit } = useBlockLock( clientId ); @@ -82,12 +86,17 @@ function ListViewBlock( { ); const blockEditingMode = useSelect( ( select ) => - unlock( select( blockEditorStore ) ).getBlockEditingMode( - clientId - ), + select( blockEditorStore ).getBlockEditingMode( clientId ), [ clientId ] ); + const { allowRightClickOverrides } = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + return { + allowRightClickOverrides: getSettings().allowRightClickOverrides, + }; + } ); + const showBlockActions = // When a block hides its toolbar it also hides the block settings menu, // since that menu is part of the toolbar in the editor canvas. @@ -125,6 +134,7 @@ function ListViewBlock( { listViewInstanceId, expandedState, setInsertedBlock, + treeGridElementRef, } = useListViewContext(); const hasSiblings = siblingBlockCount > 0; @@ -165,11 +175,38 @@ function ListViewBlock( { [ clientId, selectBlock ] ); - const updateSelection = useCallback( - ( newClientId ) => { - selectBlock( undefined, newClientId ); + const updateFocusAndSelection = useCallback( + ( focusClientId, shouldSelectBlock ) => { + if ( shouldSelectBlock ) { + selectBlock( undefined, focusClientId, null, null ); + } + + const getFocusElement = () => { + const row = treeGridElementRef.current?.querySelector( + `[role=row][data-block="${ focusClientId }"]` + ); + if ( ! row ) return null; + // Focus the first focusable in the row, which is the ListViewBlockSelectButton. + return focus.focusable.find( row )[ 0 ]; + }; + + let focusElement = getFocusElement(); + if ( focusElement ) { + focusElement.focus(); + } else { + // The element hasn't been painted yet. Defer focusing on the next frame. + // This could happen when all blocks have been deleted and the default block + // hasn't been added to the editor yet. + window.requestAnimationFrame( () => { + focusElement = getFocusElement(); + // Ignore if the element still doesn't exist. + if ( focusElement ) { + focusElement.focus(); + } + } ); + } }, - [ selectBlock ] + [ selectBlock, treeGridElementRef ] ); const toggleExpanded = useCallback( @@ -186,6 +223,56 @@ function ListViewBlock( { [ clientId, expand, collapse, isExpanded ] ); + // Allow right-clicking an item in the List View to open up the block settings dropdown. + const onContextMenu = useCallback( + ( event ) => { + if ( showBlockActions && allowRightClickOverrides ) { + settingsRef.current?.click(); + // Ensure the position of the settings dropdown is at the cursor. + setSettingsAnchorRect( + new window.DOMRect( event.clientX, event.clientY, 0, 0 ) + ); + event.preventDefault(); + } + }, + [ allowRightClickOverrides, settingsRef, showBlockActions ] + ); + + const onMouseDown = useCallback( + ( event ) => { + // Prevent right-click from focusing the block, + // because focus will be handled when opening the block settings dropdown. + if ( allowRightClickOverrides && event.button === 2 ) { + event.preventDefault(); + } + }, + [ allowRightClickOverrides ] + ); + + const settingsPopoverAnchor = useMemo( () => { + const { ownerDocument } = rowRef?.current || {}; + + // If no custom position is set, the settings dropdown will be anchored to the + // DropdownMenu toggle button. + if ( ! settingsAnchorRect || ! ownerDocument ) { + return undefined; + } + + // Position the settings dropdown at the cursor when right-clicking a block. + return { + ownerDocument, + getBoundingClientRect() { + return settingsAnchorRect; + }, + }; + }, [ settingsAnchorRect ] ); + + const clearSettingsAnchorRect = useCallback( () => { + // Clear the custom position for the settings dropdown so that it is restored back + // to being anchored to the DropdownMenu toggle button. + setSettingsAnchorRect( undefined ); + }, [ setSettingsAnchorRect ] ); + let colSpan; if ( hasRenderedMovers ) { colSpan = 2; @@ -252,6 +339,8 @@ function ListViewBlock( { -
+ { blockPositionDescription } -
+ ) } @@ -312,6 +399,7 @@ function ListViewBlock( { { ( { ref, tabIndex, onFocus } ) => ( ) } diff --git a/packages/block-editor/src/components/list-view/branch.js b/packages/block-editor/src/components/list-view/branch.js index 785e6a538f715c..d3b555c055afd1 100644 --- a/packages/block-editor/src/components/list-view/branch.js +++ b/packages/block-editor/src/components/list-view/branch.js @@ -91,6 +91,7 @@ function ListViewBranch( props ) { selectedClientIds, level = 1, path = '', + isBranchDragged = false, isBranchSelected = false, listPosition = 0, fixedListWindow, @@ -167,7 +168,8 @@ function ListViewBranch( props ) { ); const isSelectedBranch = isBranchSelected || ( isSelected && hasNestedBlocks ); - const showBlock = isDragged || blockInView || isSelected; + const showBlock = + isDragged || blockInView || isSelected || isBranchDragged; return ( { showBlock && ( @@ -176,7 +178,7 @@ function ListViewBranch( props ) { selectBlock={ selectBlock } isSelected={ isSelected } isBranchSelected={ isSelectedBranch } - isDragged={ isDragged } + isDragged={ isDragged || isBranchDragged } level={ level } position={ position } rowCount={ rowCount } @@ -194,7 +196,7 @@ function ListViewBranch( props ) { ) } - { hasNestedBlocks && shouldExpand && ! isDragged && ( + { hasNestedBlocks && shouldExpand && ( { if ( ! rootBlockElement ) { @@ -55,9 +58,11 @@ export default function ListViewDropIndicator( { ); const rootBlockIconRect = rootBlockIconElement.getBoundingClientRect(); - return rootBlockIconRect.right - targetElementRect.left; + return rtl + ? targetElementRect.right - rootBlockIconRect.left + : rootBlockIconRect.right - targetElementRect.left; }, - [ rootBlockElement ] + [ rootBlockElement, rtl ] ); const getDropIndicatorWidth = useCallback( @@ -80,21 +85,56 @@ export default function ListViewDropIndicator( { 'horizontal' ); - if ( scrollContainer ) { + const ownerDocument = targetElement.ownerDocument; + const windowScroll = + scrollContainer === ownerDocument.body || + scrollContainer === ownerDocument.documentElement; + + if ( scrollContainer && ! windowScroll ) { const scrollContainerRect = scrollContainer.getBoundingClientRect(); - if ( scrollContainer.clientWidth < width ) { + const distanceBetweenContainerAndTarget = isRTL() + ? scrollContainerRect.right - targetElementRect.right + : targetElementRect.left - scrollContainerRect.left; + + const scrollContainerWidth = scrollContainer.clientWidth; + + if ( + scrollContainerWidth < + width + distanceBetweenContainerAndTarget + ) { width = - scrollContainer.clientWidth - - ( targetElementRect.left - scrollContainerRect.left ); + scrollContainerWidth - + distanceBetweenContainerAndTarget; + } + + // LTR logic for ensuring the drop indicator does not extend + // beyond the right edge of the scroll container. + if ( + ! rtl && + targetElementRect.left + indent < scrollContainerRect.left + ) { + width -= scrollContainerRect.left - targetElementRect.left; + return width; + } + + // RTL logic for ensuring the drop indicator does not extend + // beyond the right edge of the scroll container. + if ( + rtl && + targetElementRect.right - indent > scrollContainerRect.right + ) { + width -= + targetElementRect.right - scrollContainerRect.right; + return width; } } // Subtract the indent from the final width of the indicator. return width - indent; }, - [ targetElement ] + [ rtl, targetElement ] ); const style = useMemo( () => { @@ -120,14 +160,57 @@ export default function ListViewDropIndicator( { } return { - ownerDocument: targetElement.ownerDocument, + contextElement: targetElement, getBoundingClientRect() { const rect = targetElement.getBoundingClientRect(); const indent = getDropIndicatorIndent( rect ); - const left = rect.left + indent; + // In RTL languages, the drop indicator should be positioned + // to the left of the target element, with the width of the + // indicator determining the indent at the right edge of the + // target element. In LTR languages, the drop indicator should + // end at the right edge of the target element, with the indent + // added to the position of the left edge of the target element. + let left = rtl ? rect.left : rect.left + indent; let top = 0; let bottom = 0; + // In deeply nested lists, where a scrollbar is present, + // the width of the drop indicator should be the width of + // the visible area of the scroll container. Additionally, + // the left edge of the drop indicator line needs to be + // offset by the distance the left edge of the target element + // and the left edge of the scroll container. The ensures + // that the drop indicator position never breaks out of the + // visible area of the scroll container. + const scrollContainer = getScrollContainer( + targetElement, + 'horizontal' + ); + + const doc = targetElement.ownerDocument; + const windowScroll = + scrollContainer === doc.body || + scrollContainer === doc.documentElement; + + // If the scroll container is not the window, offset the left position, if need be. + if ( scrollContainer && ! windowScroll ) { + const scrollContainerRect = + scrollContainer.getBoundingClientRect(); + + // In RTL languages, a vertical scrollbar is present on the + // left edge of the scroll container. The width of the + // scrollbar needs to be accounted for when positioning the + // drop indicator. + const scrollbarWidth = rtl + ? scrollContainer.offsetWidth - + scrollContainer.clientWidth + : 0; + + if ( left < scrollContainerRect.left + scrollbarWidth ) { + left = scrollContainerRect.left + scrollbarWidth; + } + } + if ( dropPosition === 'top' ) { top = rect.top; bottom = rect.top; @@ -148,6 +231,7 @@ export default function ListViewDropIndicator( { dropPosition, getDropIndicatorIndent, getDropIndicatorWidth, + rtl, ] ); if ( ! targetElement ) { diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 90d85ae8422def..917ebd883aa8d1 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -57,19 +57,19 @@ export const BLOCK_LIST_ITEM_HEIGHT = 36; /** * Show a hierarchical list of blocks. * - * @param {Object} props Components props. - * @param {string} props.id An HTML element id for the root element of ListView. - * @param {Array} props.blocks _deprecated_ Custom subset of block client IDs to be used instead of the default hierarchy. - * @param {?HTMLElement} props.dropZoneElement Optional element to be used as the drop zone. - * @param {?boolean} props.showBlockMovers Flag to enable block movers. Defaults to `false`. - * @param {?boolean} props.isExpanded Flag to determine whether nested levels are expanded by default. Defaults to `false`. - * @param {?boolean} props.showAppender Flag to show or hide the block appender. Defaults to `false`. - * @param {?ComponentType} props.blockSettingsMenu Optional more menu substitution. Defaults to the standard `BlockSettingsDropdown` component. - * @param {string} props.rootClientId The client id of the root block from which we determine the blocks to show in the list. - * @param {string} props.description Optional accessible description for the tree grid component. - * @param {?Function} props.onSelect Optional callback to be invoked when a block is selected. Receives the block object that was selected. - * @param {Function} props.renderAdditionalBlockUI Function that renders additional block content UI. - * @param {Ref} ref Forwarded ref + * @param {Object} props Components props. + * @param {string} props.id An HTML element id for the root element of ListView. + * @param {Array} props.blocks _deprecated_ Custom subset of block client IDs to be used instead of the default hierarchy. + * @param {?HTMLElement} props.dropZoneElement Optional element to be used as the drop zone. + * @param {?boolean} props.showBlockMovers Flag to enable block movers. Defaults to `false`. + * @param {?boolean} props.isExpanded Flag to determine whether nested levels are expanded by default. Defaults to `false`. + * @param {?boolean} props.showAppender Flag to show or hide the block appender. Defaults to `false`. + * @param {?ComponentType} props.blockSettingsMenu Optional more menu substitution. Defaults to the standard `BlockSettingsDropdown` component. + * @param {string} props.rootClientId The client id of the root block from which we determine the blocks to show in the list. + * @param {string} props.description Optional accessible description for the tree grid component. + * @param {?Function} props.onSelect Optional callback to be invoked when a block is selected. Receives the block object that was selected. + * @param {?ComponentType} props.additionalBlockContent Component that renders additional block content UI. + * @param {Ref} ref Forwarded ref */ function ListViewComponent( { @@ -83,7 +83,7 @@ function ListViewComponent( rootClientId, description, onSelect, - renderAdditionalBlockUI, + additionalBlockContent: AdditionalBlockContent, }, ref ) { @@ -141,8 +141,13 @@ function ListViewComponent( setExpandedState, } ); const selectEditorBlock = useCallback( - ( event, blockClientId ) => { - updateBlockSelection( event, blockClientId ); + /** + * @param {MouseEvent | KeyboardEvent | undefined} event + * @param {string} blockClientId + * @param {null | undefined | -1 | 1} focusPosition + */ + ( event, blockClientId, focusPosition ) => { + updateBlockSelection( event, blockClientId, null, focusPosition ); setSelectedTreeId( blockClientId ); if ( onSelect ) { onSelect( getBlock( blockClientId ) ); @@ -154,19 +159,6 @@ function ListViewComponent( isMounted.current = true; }, [] ); - // List View renders a fixed number of items and relies on each having a fixed item height of 36px. - // If this value changes, we should also change the itemHeight value set in useFixedWindowList. - // See: https://github.com/WordPress/gutenberg/pull/35230 for additional context. - const [ fixedListWindow ] = useFixedWindowList( - elementRef, - BLOCK_LIST_ITEM_HEIGHT, - visibleBlockCount, - { - useWindowing: true, - windowOverscan: 40, - } - ); - const expand = useCallback( ( clientId ) => { if ( ! clientId ) { @@ -219,9 +211,10 @@ function ListViewComponent( collapse, BlockSettingsMenu, listViewInstanceId: instanceId, - renderAdditionalBlockUI, + AdditionalBlockContent, insertedBlock, setInsertedBlock, + treeGridElementRef: elementRef, } ), [ draggedClientIds, @@ -230,12 +223,31 @@ function ListViewComponent( collapse, BlockSettingsMenu, instanceId, - renderAdditionalBlockUI, + AdditionalBlockContent, insertedBlock, setInsertedBlock, ] ); + // List View renders a fixed number of items and relies on each having a fixed item height of 36px. + // If this value changes, we should also change the itemHeight value set in useFixedWindowList. + // See: https://github.com/WordPress/gutenberg/pull/35230 for additional context. + const [ fixedListWindow ] = useFixedWindowList( + elementRef, + BLOCK_LIST_ITEM_HEIGHT, + visibleBlockCount, + { + // Ensure that the windowing logic is recalculated when the expanded state changes. + // This is necessary because expanding a collapsed block in a short list view can + // switch the list view to a tall list view with a scrollbar, and vice versa. + // When this happens, the windowing logic needs to be recalculated to ensure that + // the correct number of blocks are rendered, by rechecking for a scroll container. + expandedState, + useWindowing: true, + windowOverscan: 40, + } + ); + // If there are no blocks to show and we're not showing the appender, do not render the list view. if ( ! clientIdsTree.length && ! showAppender ) { return null; @@ -291,7 +303,7 @@ export default forwardRef( ( props, ref ) => { showAppender={ false } rootClientId={ null } onSelect={ null } - renderAdditionalBlockUI={ null } + additionalBlockContent={ null } blockSettingsMenu={ undefined } /> ); diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 5a4e80fa926fa2..49d404cfc817c6 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -27,6 +27,17 @@ } } + &:not(.is-selected) .block-editor-list-view-block-select-button { + // While components are being dragged, ensure that hover styles are not applied. + // This resolves a bug where while dragging, the hover styles would be applied to + // the wrong list item while scrolling long lists, particularly in Chrome. + .is-dragging-components-draggable & { + &:hover { + color: inherit; + } + } + } + // The background has to be applied to the td, not tr, or border-radius won't work. &.is-selected td { background: var(--wp-admin-theme-color); @@ -51,13 +62,6 @@ &.is-selected .components-button.has-icon { color: $white; } - &.is-selected .block-editor-list-view-block-contents { - // Hide selection styles while a user is dragging blocks/files etc. - .is-dragging-components-draggable & { - background: none; - color: $gray-900; - } - } &.is-selected .block-editor-list-view-block-contents:focus { &::after { box-shadow: @@ -76,10 +80,6 @@ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $white; } - &.is-dragging { - display: none; - } - // Border radius for corners of the selected item. &.is-first-selected td:first-child { border-top-left-radius: $radius-block-ui; @@ -335,14 +335,35 @@ background: rgba($black, 0.3); } - .block-editor-list-view-block-select-button__lock { + .block-editor-list-view-block-select-button__lock, + .block-editor-list-view-block-select-button__sticky { line-height: 0; } -} -.block-editor-list-view-block-select-button__description, -.block-editor-list-view-appender__description { - display: none; + .block-editor-list-view-block-select-button__images { + display: flex; + } + + .block-editor-list-view-block-select-button__image { + background-size: cover; + width: 18px; + height: 18px; + border-radius: $radius-block-ui; + + &:not(:only-child) { + box-shadow: 0 0 0 $radius-block-ui $white; + } + + &:not(:first-child) { + margin-left: #{$grid-unit-15 * -0.5}; + } + } + + &.is-selected .block-editor-list-view-block-select-button__image { + &:not(:only-child) { + box-shadow: 0 0 0 $radius-block-ui var(--wp-admin-theme-color); + } + } } .block-editor-list-view-block__contents-cell, @@ -412,8 +433,7 @@ $block-navigation-max-indent: 8; .block-editor-list-view-drop-indicator__line { background: var(--wp-admin-theme-color); - height: 6px; - border: 1px solid $white; + height: 4px; border-radius: 4px; } } diff --git a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js index c77feae4d291f1..98c2b31132c60e 100644 --- a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js +++ b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js @@ -103,78 +103,118 @@ describe( 'getListViewDropTarget', () => { ]; it( 'should return the correct target when dragging a block over the top half of the first block', () => { - const position = { x: 50, y: 70 }; - const target = getListViewDropTarget( blocksData, position ); - - expect( target ).toEqual( { - blockIndex: 0, - clientId: 'block-1', - dropPosition: 'top', - rootClientId: '', + [ + { position: { x: 50, y: 70 }, rtl: false }, + { position: { x: 250, y: 70 }, rtl: true }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( blocksData, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 0, + clientId: 'block-1', + dropPosition: 'top', + rootClientId: '', + } ); } ); } ); it( 'should nest when dragging a block over the bottom half of an expanded block', () => { - const position = { x: 50, y: 90 }; - const target = getListViewDropTarget( blocksData, position ); - - expect( target ).toEqual( { - blockIndex: 0, - dropPosition: 'inside', - rootClientId: 'block-1', + [ + { position: { x: 50, y: 90 }, rtl: false }, + { position: { x: 250, y: 90 }, rtl: true }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( blocksData, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 0, + dropPosition: 'inside', + rootClientId: 'block-1', + } ); } ); } ); it( 'should nest when dragging a block over the right side of the bottom half of a block nested to three levels', () => { - const position = { x: 250, y: 180 }; - const target = getListViewDropTarget( blocksData, position ); - - expect( target ).toEqual( { - blockIndex: 0, - dropPosition: 'inside', - rootClientId: 'block-3', + [ + { position: { x: 250, y: 180 }, rtl: false }, + { position: { x: 50, y: 180 }, rtl: true }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( blocksData, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 0, + dropPosition: 'inside', + rootClientId: 'block-3', + } ); } ); } ); it( 'should drag below when positioned at the bottom half of a block nested to three levels, and over the third level horizontally', () => { - const position = { x: 10 + NESTING_LEVEL_INDENTATION * 3, y: 180 }; - const target = getListViewDropTarget( blocksData, position ); - - expect( target ).toEqual( { - blockIndex: 1, - clientId: 'block-3', - dropPosition: 'bottom', - rootClientId: 'block-2', + [ + { + position: { x: 10 + NESTING_LEVEL_INDENTATION * 3, y: 180 }, + rtl: false, + }, + { + position: { x: 300 - NESTING_LEVEL_INDENTATION * 3, y: 180 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( blocksData, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-3', + dropPosition: 'bottom', + rootClientId: 'block-2', + } ); } ); } ); - it( 'should drag one level up below when positioned at the bottom half of a block nested to three levels, and over the second level horizontally', () => { - const position = { x: 10 + NESTING_LEVEL_INDENTATION * 2, y: 180 }; - const target = getListViewDropTarget( blocksData, position ); - - expect( target ).toEqual( { - blockIndex: 1, - clientId: 'block-3', - dropPosition: 'bottom', - rootClientId: 'block-1', + it( 'should drag one level up when positioned at the bottom half of a block nested to three levels, and over the second level horizontally', () => { + [ + { + position: { x: 10 + NESTING_LEVEL_INDENTATION * 2, y: 180 }, + rtl: false, + }, + { + position: { x: 300 - NESTING_LEVEL_INDENTATION * 2, y: 180 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( blocksData, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-3', + dropPosition: 'bottom', + rootClientId: 'block-1', + } ); } ); } ); it( 'should drag two levels up below when positioned at the bottom half of a block nested to three levels, and over the first level horizontally', () => { - const position = { x: 10 + NESTING_LEVEL_INDENTATION, y: 180 }; - const target = getListViewDropTarget( blocksData, position ); - - expect( target ).toEqual( { - blockIndex: 1, - clientId: 'block-3', - dropPosition: 'bottom', - rootClientId: '', + [ + { + position: { x: 10 + NESTING_LEVEL_INDENTATION, y: 180 }, + rtl: false, + }, + { + position: { x: 300 - NESTING_LEVEL_INDENTATION, y: 180 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( blocksData, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-3', + dropPosition: 'bottom', + rootClientId: '', + } ); } ); } ); - it( 'should nest when dragging a block over the right side and bottom half of a collapsed block with children', () => { - const position = { x: 160, y: 90 }; - + it( 'should nest and append to end when dragging a block over the right side and bottom half of a collapsed block with children', () => { const collapsedBlockData = [ ...blocksData ]; // Set the first block to be collapsed. @@ -186,18 +226,64 @@ describe( 'getListViewDropTarget', () => { // Hide the first block's children. collapsedBlockData.splice( 1, 1 ); - const target = getListViewDropTarget( collapsedBlockData, position ); + [ + { + position: { x: 250, y: 90 }, + rtl: false, + }, + { + position: { x: 50, y: 90 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( + collapsedBlockData, + position, + rtl + ); + + expect( target ).toEqual( { + blockIndex: 1, + dropPosition: 'inside', + rootClientId: 'block-1', + } ); + } ); + } ); - expect( target ).toEqual( { - blockIndex: 0, - dropPosition: 'inside', - rootClientId: 'block-1', + it( 'should nest and prepend when dragging a block over the right side and bottom half of an expanded block with children', () => { + const collapsedBlockData = [ ...blocksData ]; + + // Set the first block to be expanded. + collapsedBlockData[ 0 ] = { + ...collapsedBlockData[ 0 ], + isExpanded: true, + }; + + [ + { + position: { x: 250, y: 90 }, + rtl: false, + }, + { + position: { x: 50, y: 90 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( + collapsedBlockData, + position, + rtl + ); + + expect( target ).toEqual( { + blockIndex: 0, + dropPosition: 'inside', + rootClientId: 'block-1', + } ); } ); } ); it( 'should drag below when dragging a block over the left side and bottom half of a collapsed block with children', () => { - const position = { x: 30, y: 90 }; - const collapsedBlockData = [ ...blocksData ]; // Set the first block to be collapsed. @@ -209,19 +295,32 @@ describe( 'getListViewDropTarget', () => { // Hide the first block's children. collapsedBlockData.splice( 1, 1 ); - const target = getListViewDropTarget( collapsedBlockData, position ); - - expect( target ).toEqual( { - blockIndex: 1, - clientId: 'block-1', - dropPosition: 'bottom', - rootClientId: '', + [ + { + position: { x: 30, y: 90 }, + rtl: false, + }, + { + position: { x: 270, y: 90 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( + collapsedBlockData, + position, + rtl + ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-1', + dropPosition: 'bottom', + rootClientId: '', + } ); } ); } ); it( 'should drag below when attempting to nest but the dragged block is not allowed as a child', () => { - const position = { x: 70, y: 90 }; - const childNotAllowedBlockData = [ ...blocksData ]; // Set the first block to not be allowed as a child. @@ -230,16 +329,28 @@ describe( 'getListViewDropTarget', () => { canInsertDraggedBlocksAsChild: false, }; - const target = getListViewDropTarget( - childNotAllowedBlockData, - position - ); - - expect( target ).toEqual( { - blockIndex: 1, - clientId: 'block-1', - dropPosition: 'bottom', - rootClientId: '', + [ + { + position: { x: 70, y: 90 }, + rtl: false, + }, + { + position: { x: 230, y: 90 }, + rtl: true, + }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( + childNotAllowedBlockData, + position, + rtl + ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-1', + dropPosition: 'bottom', + rootClientId: '', + } ); } ); } ); @@ -268,14 +379,18 @@ describe( 'getListViewDropTarget', () => { // This position is to the right of the block, but below the bottom of the block. // This should result in the block being moved below the bottom-most block, and // not being treated as a nesting gesture. - const position = { x: 160, y: 250 }; - const target = getListViewDropTarget( singleBlock, position ); - - expect( target ).toEqual( { - blockIndex: 1, - clientId: 'block-1', - dropPosition: 'bottom', - rootClientId: '', + [ + { position: { x: 160, y: 250 }, rtl: false }, + { position: { x: 140, y: 250 }, rtl: true }, + ].forEach( ( { position, rtl } ) => { + const target = getListViewDropTarget( singleBlock, position, rtl ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-1', + dropPosition: 'bottom', + rootClientId: '', + } ); } ); } ); } ); diff --git a/packages/block-editor/src/components/list-view/use-block-selection.js b/packages/block-editor/src/components/list-view/use-block-selection.js index 716995edbdd53f..d1bf465d10a9c2 100644 --- a/packages/block-editor/src/components/list-view/use-block-selection.js +++ b/packages/block-editor/src/components/list-view/use-block-selection.js @@ -29,9 +29,9 @@ export default function useBlockSelection() { const { getBlockType } = useSelect( blocksStore ); const updateBlockSelection = useCallback( - async ( event, clientId, destinationClientId ) => { + async ( event, clientId, destinationClientId, focusPosition ) => { if ( ! event?.shiftKey ) { - selectBlock( clientId ); + selectBlock( clientId, focusPosition ); return; } diff --git a/packages/block-editor/src/components/list-view/use-list-view-client-ids.js b/packages/block-editor/src/components/list-view/use-list-view-client-ids.js index 8b989591802fa7..8a1ccfcede4c12 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-client-ids.js +++ b/packages/block-editor/src/components/list-view/use-list-view-client-ids.js @@ -16,31 +16,14 @@ export default function useListViewClientIds( { blocks, rootClientId } ) { const { getDraggedBlockClientIds, getSelectedBlockClientIds, - __unstableGetClientIdsTree, - getBlockEditingMode, + getEnabledClientIdsTree, } = unlock( select( blockEditorStore ) ); - const removeDisabledBlocks = ( tree ) => { - return tree.flatMap( ( { clientId, innerBlocks, ...rest } ) => { - if ( getBlockEditingMode( clientId ) === 'disabled' ) { - return removeDisabledBlocks( innerBlocks ); - } - return [ - { - clientId, - innerBlocks: removeDisabledBlocks( innerBlocks ), - ...rest, - }, - ]; - } ); - }; - return { selectedClientIds: getSelectedBlockClientIds(), draggedClientIds: getDraggedBlockClientIds(), - clientIdsTree: removeDisabledBlocks( - blocks ?? __unstableGetClientIdsTree( rootClientId ) - ), + clientIdsTree: + blocks ?? getEnabledClientIdsTree( rootClientId ), }; }, [ blocks, rootClientId ] diff --git a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js index ba98da78433567..a1a369d3f94084 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js +++ b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js @@ -7,6 +7,7 @@ import { useThrottle, __experimentalUseDropZone as useDropZone, } from '@wordpress/compose'; +import { isRTL } from '@wordpress/i18n'; /** * Internal dependencies @@ -71,14 +72,16 @@ export const NESTING_LEVEL_INDENTATION = 28; * @param {WPPoint} point The point representing the cursor position when dragging. * @param {DOMRect} rect The rectangle. * @param {number} nestingLevel The nesting level of the block. + * @param {boolean} rtl Whether the editor is in RTL mode. * @return {boolean} Whether the gesture is an upward gesture. */ -function isUpGesture( point, rect, nestingLevel = 1 ) { +function isUpGesture( point, rect, nestingLevel = 1, rtl = false ) { // If the block is nested, and the user is dragging to the bottom - // left of the block, then it is an upward gesture. - const blockIndentPosition = - rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; - return point.x < blockIndentPosition; + // left of the block (or bottom right in RTL languages), then it is an upward gesture. + const blockIndentPosition = rtl + ? rect.right - nestingLevel * NESTING_LEVEL_INDENTATION + : rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; + return rtl ? point.x > blockIndentPosition : point.x < blockIndentPosition; } /** @@ -96,14 +99,29 @@ function isUpGesture( point, rect, nestingLevel = 1 ) { * @param {WPPoint} point The point representing the cursor position when dragging. * @param {DOMRect} rect The rectangle. * @param {number} nestingLevel The nesting level of the block. + * @param {boolean} rtl Whether the editor is in RTL mode. * @return {number} The desired relative parent level. */ -function getDesiredRelativeParentLevel( point, rect, nestingLevel = 1 ) { - const blockIndentPosition = - rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; +function getDesiredRelativeParentLevel( + point, + rect, + nestingLevel = 1, + rtl = false +) { + // In RTL languages, the block indent position is from the right edge of the block. + // In LTR languages, the block indent position is from the left edge of the block. + const blockIndentPosition = rtl + ? rect.right - nestingLevel * NESTING_LEVEL_INDENTATION + : rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; + + const distanceBetweenPointAndBlockIndentPosition = rtl + ? blockIndentPosition - point.x + : point.x - blockIndentPosition; + const desiredParentLevel = Math.round( - ( point.x - blockIndentPosition ) / NESTING_LEVEL_INDENTATION + distanceBetweenPointAndBlockIndentPosition / NESTING_LEVEL_INDENTATION ); + return Math.abs( desiredParentLevel ); } @@ -158,14 +176,18 @@ function getNextNonDraggedBlock( blocksData, index ) { * @param {WPPoint} point The point representing the cursor position when dragging. * @param {DOMRect} rect The rectangle. * @param {number} nestingLevel The nesting level of the block. + * @param {boolean} rtl Whether the editor is in RTL mode. */ -function isNestingGesture( point, rect, nestingLevel = 1 ) { - const blockIndentPosition = - rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; - return ( - point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION && - point.y < rect.bottom - ); +function isNestingGesture( point, rect, nestingLevel = 1, rtl = false ) { + const blockIndentPosition = rtl + ? rect.right - nestingLevel * NESTING_LEVEL_INDENTATION + : rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; + + const isNestingHorizontalGesture = rtl + ? point.x < blockIndentPosition - NESTING_LEVEL_INDENTATION + : point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION; + + return isNestingHorizontalGesture && point.y < rect.bottom; } // Block navigation is always a vertical list, so only allow dropping @@ -177,10 +199,11 @@ const ALLOWED_DROP_EDGES = [ 'top', 'bottom' ]; * * @param {WPListViewDropZoneBlocks} blocksData Data about the blocks in list view. * @param {WPPoint} position The point representing the cursor position when dragging. + * @param {boolean} rtl Whether the editor is in RTL mode. * * @return {WPListViewDropZoneTarget | undefined} An object containing data about the drop target. */ -export function getListViewDropTarget( blocksData, position ) { +export function getListViewDropTarget( blocksData, position, rtl = false ) { let candidateEdge; let candidateBlockData; let candidateDistance; @@ -255,12 +278,48 @@ export function getListViewDropTarget( blocksData, position ) { const isDraggingBelow = candidateEdge === 'bottom'; + // If the user is dragging towards the bottom of the block check whether + // they might be trying to nest the block as a child. + // If the block already has inner blocks, and is expanded, this should be treated + // as nesting since the next block in the tree will be the first child. + // However, if the block is collapsed, dragging beneath the block should + // still be allowed, as the next visible block in the tree will be a sibling. + if ( + isDraggingBelow && + candidateBlockData.canInsertDraggedBlocksAsChild && + ( ( candidateBlockData.innerBlockCount > 0 && + candidateBlockData.isExpanded ) || + isNestingGesture( + position, + candidateRect, + candidateBlockParents.length, + rtl + ) ) + ) { + // If the block is expanded, insert the block as the first child. + // Otherwise, for collapsed blocks, insert the block as the last child. + const newBlockIndex = candidateBlockData.isExpanded + ? 0 + : candidateBlockData.innerBlockCount || 0; + + return { + rootClientId: candidateBlockData.clientId, + blockIndex: newBlockIndex, + dropPosition: 'inside', + }; + } + // If the user is dragging towards the bottom of the block check whether // they might be trying to move the block to be at a parent level. if ( isDraggingBelow && candidateBlockData.rootClientId && - isUpGesture( position, candidateRect, candidateBlockParents.length ) + isUpGesture( + position, + candidateRect, + candidateBlockParents.length, + rtl + ) ) { const nextBlock = getNextNonDraggedBlock( blocksData, @@ -274,7 +333,8 @@ export function getListViewDropTarget( blocksData, position ) { const desiredRelativeLevel = getDesiredRelativeParentLevel( position, candidateRect, - candidateBlockParents.length + candidateBlockParents.length, + rtl ); const targetParentIndex = Math.max( @@ -321,30 +381,6 @@ export function getListViewDropTarget( blocksData, position ) { } } - // If the user is dragging towards the bottom of the block check whether - // they might be trying to nest the block as a child. - // If the block already has inner blocks, and is expanded, this should be treated - // as nesting since the next block in the tree will be the first child. - // However, if the block is collapsed, dragging beneath the block should - // still be allowed, as the next visible block in the tree will be a sibling. - if ( - isDraggingBelow && - candidateBlockData.canInsertDraggedBlocksAsChild && - ( ( candidateBlockData.innerBlockCount > 0 && - candidateBlockData.isExpanded ) || - isNestingGesture( - position, - candidateRect, - candidateBlockParents.length - ) ) - ) { - return { - rootClientId: candidateBlockData.clientId, - blockIndex: 0, - dropPosition: 'inside', - }; - } - // If dropping as a sibling, but block cannot be inserted in // this context, return early. if ( ! candidateBlockData.canInsertDraggedBlocksAsSibling ) { @@ -382,6 +418,8 @@ export default function useListViewDropZone( { dropZoneElement } ) { const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex ); + const rtl = isRTL(); + const draggedBlockClientIds = getDraggedBlockClientIds(); const throttled = useThrottle( useCallback( @@ -396,6 +434,8 @@ export default function useListViewDropZone( { dropZoneElement } ) { const blocksData = blockElements.map( ( blockElement ) => { const clientId = blockElement.dataset.block; const isExpanded = blockElement.dataset.expanded === 'true'; + const isDraggedBlock = + blockElement.classList.contains( 'is-dragging' ); // Get nesting level from `aria-level` attribute because Firefox does not support `element.ariaLevel`. const nestingLevel = parseInt( @@ -411,9 +451,7 @@ export default function useListViewDropZone( { dropZoneElement } ) { blockIndex: getBlockIndex( clientId ), element: blockElement, nestingLevel: nestingLevel || undefined, - isDraggedBlock: isBlockDrag - ? draggedBlockClientIds.includes( clientId ) - : false, + isDraggedBlock: isBlockDrag ? isDraggedBlock : false, innerBlockCount: getBlockCount( clientId ), canInsertDraggedBlocksAsSibling: isBlockDrag ? canInsertBlocks( @@ -427,20 +465,35 @@ export default function useListViewDropZone( { dropZoneElement } ) { }; } ); - const newTarget = getListViewDropTarget( blocksData, position ); + const newTarget = getListViewDropTarget( + blocksData, + position, + rtl + ); if ( newTarget ) { setTarget( newTarget ); } }, - [ draggedBlockClientIds ] + [ + canInsertBlocks, + draggedBlockClientIds, + getBlockCount, + getBlockIndex, + getBlockRootClientId, + rtl, + ] ), 200 ); const ref = useDropZone( { dropZoneElement, - onDrop: onBlockDrop, + onDrop( event ) { + if ( target ) { + onBlockDrop( event ); + } + }, onDragLeave() { throttled.cancel(); setTarget( null ); diff --git a/packages/block-editor/src/components/list-view/use-list-view-images.js b/packages/block-editor/src/components/list-view/use-list-view-images.js new file mode 100644 index 00000000000000..97633349b6b028 --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-list-view-images.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +// Maximum number of images to display in a list view row. +const MAX_IMAGES = 3; + +function getImage( block ) { + if ( block.name !== 'core/image' ) { + return; + } + + if ( block.attributes?.url ) { + return { + url: block.attributes.url, + alt: block.attributes.alt, + clientId: block.clientId, + }; + } +} + +function getImagesFromGallery( block ) { + if ( block.name !== 'core/gallery' || ! block.innerBlocks ) { + return []; + } + + const images = []; + + for ( const innerBlock of block.innerBlocks ) { + const img = getImage( innerBlock ); + if ( img ) { + images.push( img ); + } + if ( images.length >= MAX_IMAGES ) { + return images; + } + } + + return images; +} + +function getImagesFromBlock( block, isExpanded ) { + const img = getImage( block ); + if ( img ) { + return [ img ]; + } + return isExpanded ? [] : getImagesFromGallery( block ); +} + +/** + * Get a block's preview images for display within a list view row. + * + * TODO: Currently only supports images from the core/image and core/gallery + * blocks. This should be expanded to support other blocks that have images, + * potentially via an API that blocks can opt into / provide their own logic. + * + * @param {Object} props Hook properties. + * @param {string} props.clientId The block's clientId. + * @param {boolean} props.isExpanded Whether or not the block is expanded in the list view. + * @return {Array} Images. + */ +export default function useListViewImages( { clientId, isExpanded } ) { + const { block } = useSelect( + ( select ) => { + const _block = select( blockEditorStore ).getBlock( clientId ); + return { block: _block }; + }, + [ clientId ] + ); + + const images = useMemo( () => { + return getImagesFromBlock( block, isExpanded ); + }, [ block, isExpanded ] ); + + return images; +} diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js index 476753a27c06a4..4208e5665cfd4f 100644 --- a/packages/block-editor/src/components/media-placeholder/index.js +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -30,8 +30,14 @@ import { store as blockEditorStore } from '../../store'; const noop = () => {}; -const InsertFromURLPopover = ( { src, onChange, onSubmit, onClose } ) => ( - +const InsertFromURLPopover = ( { + src, + onChange, + onSubmit, + onClose, + popoverAnchor, +} ) => ( +
( ); +const URLSelectionUI = ( { + isURLInputVisible, + src, + onChangeSrc, + onSubmitSrc, + openURLInput, + closeURLInput, +} ) => { + // Use internal state instead of a ref to make sure that the component + // re-renders when the popover's anchor updates. + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + + return ( +
+ + { isURLInputVisible && ( + + ) } +
+ ); +}; + export function MediaPlaceholder( { value = {}, allowedTypes, @@ -359,24 +403,14 @@ export function MediaPlaceholder( { const renderUrlSelectionUI = () => { return ( onSelectURL && ( -
- - { isURLInputVisible && ( - - ) } -
+ ) ); }; diff --git a/packages/block-editor/src/components/media-placeholder/index.native.js b/packages/block-editor/src/components/media-placeholder/index.native.js index 25f6571fd80f3a..dd36feae1e4152 100644 --- a/packages/block-editor/src/components/media-placeholder/index.native.js +++ b/packages/block-editor/src/components/media-placeholder/index.native.js @@ -1,7 +1,8 @@ /** * External dependencies */ -import { View, Text, TouchableWithoutFeedback } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { sentenceCase } from 'change-case'; /** * WordPress dependencies @@ -13,14 +14,15 @@ import { MEDIA_TYPE_VIDEO, MEDIA_TYPE_AUDIO, } from '@wordpress/block-editor'; -import { withPreferredColorScheme } from '@wordpress/compose'; -import { useRef } from '@wordpress/element'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; +import { cloneElement, useCallback, useRef } from '@wordpress/element'; import { Icon, plusCircleFilled } from '@wordpress/icons'; /** * Internal dependencies */ import styles from './styles.scss'; +import { useBlockEditContext } from '../block-edit/context'; const isMediaEqual = ( media1, media2 ) => media1.id === media2.id || media1.url === media2.url; @@ -35,10 +37,13 @@ const dedupMedia = ( media ) => [] ); +const hitSlop = { top: 22, bottom: 22, left: 22, right: 22 }; + function MediaPlaceholder( props ) { const { addToGallery, allowedTypes = [], + className = '', labels = {}, icon, onSelect, @@ -46,7 +51,6 @@ function MediaPlaceholder( props ) { __experimentalOnlyMediaLibrary, isAppender, disableMediaButtons, - getStylesFromColorScheme, multiple, value = [], children, @@ -61,6 +65,16 @@ function MediaPlaceholder( props ) { const mediaRef = useRef( value ); mediaRef.current = value; + const blockEditContext = useBlockEditContext(); + + const onButtonPress = useCallback( + ( open ) => ( event ) => { + onFocus?.( event ); + open(); + }, + [ onFocus ] + ); + // Append and deduplicate media array for gallery use case. const setMedia = multiple && addToGallery @@ -109,55 +123,104 @@ function MediaPlaceholder( props ) { accessibilityHint = __( 'Double tap to select an audio file' ); } - const emptyStateTitleStyle = getStylesFromColorScheme( - styles.emptyStateTitle, - styles.emptyStateTitleDark + const titleStyles = usePreferredColorSchemeStyle( + styles[ 'media-placeholder__header-title' ], + styles[ 'media-placeholder__header-title--dark' ] ); - const addMediaButtonStyle = getStylesFromColorScheme( + const addMediaButtonStyle = usePreferredColorSchemeStyle( styles.addMediaButton, styles.addMediaButtonDark ); + const buttonStyles = usePreferredColorSchemeStyle( + styles[ 'media-placeholder__button' ], + styles[ 'media-placeholder__button--dark' ] + ); + const emptyStateDescriptionStyles = usePreferredColorSchemeStyle( + styles.emptyStateDescription, + styles.emptyStateDescriptionDark + ); + const iconStyles = usePreferredColorSchemeStyle( + styles[ 'media-placeholder__header-icon' ], + styles[ 'media-placeholder__header-icon--dark' ] + ); + const placeholderIcon = cloneElement( icon, { + fill: iconStyles.fill, + } ); + const accessibilityLabel = sprintf( + /* translators: accessibility text for the media block empty state. %s: media type */ + __( '%s block. Empty' ), + placeholderTitle + ); - const renderContent = () => { + const renderContent = ( open ) => { if ( isAppender === undefined || ! isAppender ) { return ( <> - { icon } - - { placeholderTitle } - + + { placeholderIcon } + { placeholderTitle } + { children } - - { instructions } - + + + { sentenceCase( instructions ) } + + ); } else if ( isAppender && ! disableMediaButtons ) { return ( - - - + + + + + ); } }; - if ( isAppender && disableMediaButtons ) { - return null; - } - - const appenderStyle = getStylesFromColorScheme( + const appenderStyle = usePreferredColorSchemeStyle( styles.appender, styles.appenderDark ); - const emptyStateContainerStyle = getStylesFromColorScheme( - styles.emptyStateContainer, - styles.emptyStateContainerDark + const containerSelectedStyle = usePreferredColorSchemeStyle( + styles[ 'media-placeholder__container-selected' ], + styles[ 'media-placeholder__container-selected--dark' ] ); + const containerStyle = [ + usePreferredColorSchemeStyle( + styles[ 'media-placeholder__container' ], + styles[ 'media-placeholder__container--dark' ] + ), + blockEditContext?.isSelected && + ! className.includes( 'no-block-outline' ) && + containerSelectedStyle, + ]; + + if ( isAppender && disableMediaButtons ) { + return null; + } return ( @@ -173,33 +236,19 @@ function MediaPlaceholder( props ) { autoOpen={ autoOpenMediaUpload } render={ ( { open, getMediaOptions } ) => { return ( - { - onFocus?.( event ); - open(); - } } + - - { getMediaOptions() } - { ! hideContent && renderContent() } - - + { getMediaOptions() } + { ! hideContent && renderContent( open ) } + ); } } /> @@ -207,4 +256,4 @@ function MediaPlaceholder( props ) { ); } -export default withPreferredColorScheme( MediaPlaceholder ); +export default MediaPlaceholder; diff --git a/packages/block-editor/src/components/media-placeholder/styles.native.scss b/packages/block-editor/src/components/media-placeholder/styles.native.scss index 708c2f07755e38..93b4dd945e044e 100644 --- a/packages/block-editor/src/components/media-placeholder/styles.native.scss +++ b/packages/block-editor/src/components/media-placeholder/styles.native.scss @@ -1,50 +1,63 @@ -.emptyStateContainer { +.media-placeholder__container { flex: 1; height: 142; flex-direction: column; align-items: center; justify-content: center; - background-color: $gray-lighten-30; + background-color: #e0e0e0; // $light-dim padding-left: 12; padding-right: 12; padding-top: 12; padding-bottom: 12; - border-top-left-radius: 4; - border-top-right-radius: 4; - border-bottom-left-radius: 4; - border-bottom-right-radius: 4; + border-top-left-radius: 2; + border-top-right-radius: 2; + border-bottom-left-radius: 2; + border-bottom-right-radius: 2; } -.emptyStateContainerDark { - background-color: $background-dark-secondary; +.media-placeholder__container--dark { + background-color: #1f1f1f; // $dark-dim } -.emptyStateTitle { - text-align: center; - margin-top: 4; - margin-bottom: 16; - font-size: 14; - color: #2e4453; +.media-placeholder__container-selected { + border-width: 2px; + border-color: $blue-40; } -.emptyStateTitleDark { - color: $white; +.media-placeholder__container-selected--dark { + border-color: $blue-50; } .emptyStateDescription { - width: 100%; text-align: center; - color: $blue-wordpress; - font-size: 14; - font-weight: 500; + color: $white; + font-size: 16; + font-weight: 400; +} + +.emptyStateDescriptionDark { + color: $black; } -.modalIcon { +.media-placeholder__header-icon { width: 24px; height: 24px; - justify-content: center; - align-items: center; - fill: $gray-dark; + margin-right: $grid-unit; + fill: $light-secondary; +} + +.media-placeholder__header-icon--dark { + fill: $dark-tertiary; +} + +.media-placeholder__header-title { + text-align: center; + font-size: 16; + color: $light-secondary; +} + +.media-placeholder__header-title--dark { + color: $dark-tertiary; } .appender { @@ -71,3 +84,25 @@ color: $background-dark-secondary; background-color: $gray-20; } + +.media-placeholder__button { + background-color: $light-primary; + border-radius: 3px; + padding: $grid-unit $grid-unit-20; +} + +.media-placeholder__button--dark { + background-color: $dark-primary; +} + +.media-placeholder__header { + flex-direction: row; + align-items: center; + margin-top: 4; + margin-bottom: 16; +} + +.media-placeholder__appender { + width: 100%; + align-items: center; +} diff --git a/packages/block-editor/src/components/media-replace-flow/index.js b/packages/block-editor/src/components/media-replace-flow/index.js index ec6ef82bb53f9a..9aa36b1b88c852 100644 --- a/packages/block-editor/src/components/media-replace-flow/index.js +++ b/packages/block-editor/src/components/media-replace-flow/index.js @@ -59,9 +59,7 @@ const MediaReplaceFlow = ( { multiple = false, addToGallery, handleUpload = true, - popoverProps = { - variant: 'toolbar', - }, + popoverProps, } ) => { const mediaUpload = useSelect( ( select ) => { return select( blockEditorStore ).getSettings().mediaUpload; diff --git a/packages/block-editor/src/components/media-replace-flow/index.native.js b/packages/block-editor/src/components/media-replace-flow/index.native.js index 49e98a3b3b9ca5..ca2ce4ee78c637 100644 --- a/packages/block-editor/src/components/media-replace-flow/index.native.js +++ b/packages/block-editor/src/components/media-replace-flow/index.native.js @@ -1,3 +1,12 @@ -// MediaReplaceFlow component is not yet implemented in the native version, -// so we return an empty component instead. -export default () => null; +/** + * External dependencies + */ +import { View } from 'react-native'; + +// MediaReplaceFlow component is not yet implemented in the native version. +// For testing purposes, we are using an empty View component with a testID prop. +const MediaReplaceFlow = () => { + return ; +}; + +export default MediaReplaceFlow; diff --git a/packages/block-editor/src/components/media-replace-flow/test/index.js b/packages/block-editor/src/components/media-replace-flow/test/index.js index 9d1aef6df76203..cef747ce6be639 100644 --- a/packages/block-editor/src/components/media-replace-flow/test/index.js +++ b/packages/block-editor/src/components/media-replace-flow/test/index.js @@ -128,7 +128,7 @@ describe( 'General media replace flow', () => { ); const mediaURLInput = screen.getByRole( 'combobox', { - name: 'URL', + name: 'Link', expanded: false, } ); @@ -137,7 +137,7 @@ describe( 'General media replace flow', () => { await user.click( screen.getByRole( 'button', { - name: 'Apply', + name: 'Save', } ) ); diff --git a/packages/block-editor/src/components/media-upload/test/index.native.js b/packages/block-editor/src/components/media-upload/test/index.native.js index 32b5a595b522b5..75268aef6de68c 100644 --- a/packages/block-editor/src/components/media-upload/test/index.native.js +++ b/packages/block-editor/src/components/media-upload/test/index.native.js @@ -44,6 +44,7 @@ describe( 'MediaUpload component', () => { const wrapper = render( { return ( <> diff --git a/packages/block-editor/src/components/navigable-toolbar/index.js b/packages/block-editor/src/components/navigable-toolbar/index.js index cc2b6e67b57825..3e531c93c11989 100644 --- a/packages/block-editor/src/components/navigable-toolbar/index.js +++ b/packages/block-editor/src/components/navigable-toolbar/index.js @@ -120,16 +120,18 @@ function useToolbarFocus( }, [ isAccessibleToolbar, initialFocusOnMount, focusToolbar ] ); useEffect( () => { + // Store ref so we have access on useEffect cleanup: https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#effect-cleanup-timing + const navigableToolbarRef = ref.current; // If initialIndex is passed, we focus on that toolbar item when the // toolbar gets mounted and initial focus is not forced. // We have to wait for the next browser paint because block controls aren't // rendered right away when the toolbar gets mounted. let raf = 0; - if ( initialIndex && ! initialFocusOnMount ) { + if ( ! initialFocusOnMount ) { raf = window.requestAnimationFrame( () => { - const items = getAllToolbarItemsIn( ref.current ); + const items = getAllToolbarItemsIn( navigableToolbarRef ); const index = initialIndex || 0; - if ( items[ index ] && hasFocusWithin( ref.current ) ) { + if ( items[ index ] && hasFocusWithin( navigableToolbarRef ) ) { items[ index ].focus( { // When focusing newly mounted toolbars, // the position of the popover is often not right on the first render @@ -141,10 +143,10 @@ function useToolbarFocus( } return () => { window.cancelAnimationFrame( raf ); - if ( ! onIndexChange || ! ref.current ) return; + if ( ! onIndexChange || ! navigableToolbarRef ) return; // When the toolbar element is unmounted and onIndexChange is passed, we // pass the focused toolbar item index so it can be hydrated later. - const items = getAllToolbarItemsIn( ref.current ); + const items = getAllToolbarItemsIn( navigableToolbarRef ); const index = items.findIndex( ( item ) => item.tabIndex === 0 ); onIndexChange( index ); }; diff --git a/packages/block-editor/src/components/observe-typing/README.md b/packages/block-editor/src/components/observe-typing/README.md index c589c6903416d7..e44c612a144155 100644 --- a/packages/block-editor/src/components/observe-typing/README.md +++ b/packages/block-editor/src/components/observe-typing/README.md @@ -1,6 +1,6 @@ # Observe Typing -`` is a component used in managing the editor's internal typing flag. When used to wrap content — typically the top-level block list — it observes keyboard and mouse events to set and unset the typing flag. The typing flag is used in considering whether the block border and controls should be visible. While typing, these elements are hidden for a distraction-free experience. +`` is a component used in managing the editor's internal typing flag. When used to wrap content, it observes keyboard and mouse events to set and unset the typing flag. The typing flag is used in considering whether the block border and controls should be visible. While typing, these elements are hidden for a distraction-free experience. ## Usage @@ -10,7 +10,7 @@ Wrap the component where blocks are to be rendered with ``: function VisualEditor() { return ( - + ); } diff --git a/packages/block-editor/src/components/panel-color-settings/README.md b/packages/block-editor/src/components/panel-color-settings/README.md new file mode 100644 index 00000000000000..94b82eb3869b1a --- /dev/null +++ b/packages/block-editor/src/components/panel-color-settings/README.md @@ -0,0 +1,98 @@ +# PanelColorSettings + +`PanelColorSettings` is a React component that renders a UI for managing various color settings. +It is essentially a wrapper around the `PanelColorGradientSettings` component, but specifically disables the gradient features. + +## Usage + +```jsx +/** + * WordPress dependencies + */ +import { PanelColorSettings } from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +// ... + +const MyPanelColorSettings = () => { + const [ textColor, setTextColor ] = useState( { color: '#000' } ); + const [ backgroundColor, setBackgroundColor ] = useState( { + color: '#fff', + } ); + const [ overlayTextColor, setOverlayTextColor ] = useState( { + color: '#000', + } ); + const [ overlayBackgroundColor, setOverlayBackgroundColor ] = useState( { + color: '#eee', + } ); + + return ( + + ); +}; + +/// ... + +; +``` + +## Props + +The component accepts the following props: + +### colorSettings + +A user-provided set of color settings. + +- Type: `Array` +- Required: No + +Colors settings are provided as an array of objects with the following schema: + +| Property | Description | Type | +| -------- | --------------------------------- | -------- | +| value | The current color of the setting | string | +| onChange | Callback on change of the setting | Function | +| label | Label of the setting | string | + +Additionally, the following `PanelColorGradientSettings` props are supported and directly passed down to the underlying `PanelColorGradientSettings` instance: + +- `className` - added to the underlying `ToolsPanel` instance. +- `colors` - array of colors to be used. +- `gradients` - not recommended to be used since `PanelColorSettings` resets it. +- `disableCustomColors` - whether addition of custom colors is enabled +- `disableCustomGradients` - not recommended to be used since `PanelColorSettings` sets it. +- `children` - displayed below the underlying `PanelColorGradientSettings` instance. +- `settings` - not recommended to be used, since `PanelColorSettings` builds it from the `colorSettings` prop. +- `title` - title of the underlying `ToolsPanel`. +- `showTitle` - whether to show the title of the `ToolsPanel`. +- `__experimentalIsRenderedInSidebar` +- `enableAlpha` - whether to enable setting opacity when specifying a color. + +Please refer to the `PanelColorGradientSettings` component for more information. diff --git a/packages/block-editor/src/components/preview-options/README.md b/packages/block-editor/src/components/preview-options/README.md index 0a2e89a70c7d44..6e9a029fa83a57 100644 --- a/packages/block-editor/src/components/preview-options/README.md +++ b/packages/block-editor/src/components/preview-options/README.md @@ -28,23 +28,24 @@ const MyPreviewOptions = () => ( className="edit-post-post-preview-dropdown" deviceType={ deviceType } setDeviceType={ setPreviewDeviceType } - > - -
- - { __( 'Preview in new tab' ) } - - - } - /> -
-
+ > { ( { onClose } ) => ( + +
+ + { __( 'Preview in new tab' ) } + + + } + onPreview={ onClose } + /> +
+
+ ) } ); ``` diff --git a/packages/block-editor/src/components/preview-options/index.js b/packages/block-editor/src/components/preview-options/index.js index d173542e02570a..9f5f820c4edcb2 100644 --- a/packages/block-editor/src/components/preview-options/index.js +++ b/packages/block-editor/src/components/preview-options/index.js @@ -18,6 +18,7 @@ export default function PreviewOptions( { isEnabled = true, deviceType, setDeviceType, + label, } ) { const isMobile = useViewportMatch( 'small', '<' ); if ( isMobile ) return null; @@ -27,12 +28,12 @@ export default function PreviewOptions( { className, 'block-editor-post-preview__dropdown-content' ), - position: 'bottom left', + placement: 'bottom-end', }; const toggleProps = { - variant: 'tertiary', className: 'block-editor-post-preview__button-toggle', disabled: ! isEnabled, + __experimentalIsFocusable: ! isEnabled, children: viewLabel, }; const menuProps = { @@ -52,8 +53,10 @@ export default function PreviewOptions( { toggleProps={ toggleProps } menuProps={ menuProps } icon={ deviceIcons[ deviceType.toLowerCase() ] } + label={ label || __( 'Preview' ) } + disableOpenOnArrowDown={ ! isEnabled } > - { () => ( + { ( renderProps ) => ( <> - { children } + { children( renderProps ) } ) } diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index dbd646426718de..0fa3f042053d08 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -3,6 +3,7 @@ */ import { useDispatch } from '@wordpress/data'; import { useEffect } from '@wordpress/element'; +import { SlotFillProvider } from '@wordpress/components'; /** * Internal dependencies @@ -12,6 +13,7 @@ import useBlockSync from './use-block-sync'; import { store as blockEditorStore } from '../../store'; import { BlockRefsProvider } from './block-refs-provider'; import { unlock } from '../../lock-unlock'; +import KeyboardShortcuts from '../keyboard-shortcuts'; /** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */ @@ -28,14 +30,26 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider( ...settings, __internalIsInitialized: true, }, - stripExperimentalSettings + { + stripExperimentalSettings, + reset: true, + } ); - }, [ settings ] ); + }, [ + settings, + stripExperimentalSettings, + __experimentalUpdateSettings, + ] ); // Syncs the entity provider with changes in the block-editor store. useBlockSync( props ); - return { children }; + return ( + + + { children } + + ); } ); diff --git a/packages/block-editor/src/components/provider/test/experimental-provider.js b/packages/block-editor/src/components/provider/test/experimental-provider.js index 74f746a2c1d9ea..2bb2aaadce95cc 100644 --- a/packages/block-editor/src/components/provider/test/experimental-provider.js +++ b/packages/block-editor/src/components/provider/test/experimental-provider.js @@ -29,18 +29,21 @@ describe( 'BlockEditorProvider', () => { beforeEach( () => { registry = undefined; } ); - it( 'should strip experimental settings', async () => { + it( 'should not allow updating experimental settings', async () => { render( ); const settings = registry.select( blockEditorStore ).getSettings(); - expect( settings ).not.toHaveProperty( 'inserterMediaCategories' ); + // `blockInspectorAnimation` setting is one of the block editor's + // default settings, so it has a value. We're testing that its + // value was not updated. + expect( settings.blockInspectorAnimation ).not.toBe( true ); } ); it( 'should preserve stable settings', async () => { render( @@ -65,18 +68,18 @@ describe( 'ExperimentalBlockEditorProvider', () => { beforeEach( () => { registry = undefined; } ); - it( 'should preserve experimental settings', async () => { + it( 'should allow updating/adding experimental settings', async () => { render( ); const settings = registry.select( blockEditorStore ).getSettings(); - expect( settings ).toHaveProperty( 'inserterMediaCategories' ); + expect( settings.blockInspectorAnimation ).toBe( true ); } ); it( 'should preserve stable settings', async () => { render( diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index 7901c3d98f3a92..b1e6a97d137c91 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -48,7 +48,7 @@ describe( 'useBlockSync hook', () => { jest.clearAllMocks(); } ); - it( 'resets the block-editor blocks when the controll value changes', async () => { + it( 'resets the block-editor blocks when the controlled value changes', async () => { const fakeBlocks = []; const resetBlocks = jest.spyOn( blockEditorActions, 'resetBlocks' ); const replaceInnerBlocks = jest.spyOn( @@ -58,7 +58,7 @@ describe( 'useBlockSync hook', () => { const onChange = jest.fn(); const onInput = jest.fn(); - const { rerender } = render( + const { rerender, unmount } = render( { expect( onInput ).not.toHaveBeenCalled(); expect( replaceInnerBlocks ).not.toHaveBeenCalled(); expect( resetBlocks ).toHaveBeenCalledWith( testBlocks ); + + unmount(); + + expect( onChange ).not.toHaveBeenCalled(); + expect( onInput ).not.toHaveBeenCalled(); + expect( replaceInnerBlocks ).not.toHaveBeenCalled(); + expect( resetBlocks ).toHaveBeenCalledWith( [] ); } ); - it( 'replaces the inner blocks of a block when the control value changes if a clientId is passed', async () => { + it( 'replaces the inner blocks of a block when the controlled value changes if a clientId is passed', async () => { const fakeBlocks = []; const replaceInnerBlocks = jest.spyOn( blockEditorActions, @@ -100,7 +107,7 @@ describe( 'useBlockSync hook', () => { const onChange = jest.fn(); const onInput = jest.fn(); - const { rerender } = render( + const { rerender, unmount } = render( { expect( onChange ).not.toHaveBeenCalled(); expect( onInput ).not.toHaveBeenCalled(); expect( resetBlocks ).not.toHaveBeenCalled(); - // We can't check the args because the blocks are cloned. - expect( replaceInnerBlocks ).toHaveBeenCalled(); + expect( replaceInnerBlocks ).toHaveBeenCalledWith( 'test', [ + expect.objectContaining( { name: 'test/test-block' } ), + ] ); + + unmount(); + + expect( onChange ).not.toHaveBeenCalled(); + expect( onInput ).not.toHaveBeenCalled(); + expect( resetBlocks ).not.toHaveBeenCalled(); + expect( replaceInnerBlocks ).toHaveBeenCalledWith( 'test', [] ); } ); it( 'does not add the controlled blocks to the block-editor store if the store already contains them', async () => { diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index f7392f99035a95..d788c7b4442304 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -134,6 +134,19 @@ export default function useBlockSync( { } }; + // Clean up the changes made by setControlledBlocks() when the component + // containing useBlockSync() unmounts. + const unsetControlledBlocks = () => { + __unstableMarkNextChangeAsNotPersistent(); + if ( clientId ) { + setHasControlledInnerBlocks( clientId, false ); + __unstableMarkNextChangeAsNotPersistent(); + replaceInnerBlocks( clientId, [] ); + } else { + resetBlocks( [] ); + } + }; + // Add a subscription to the block-editor registry to detect when changes // have been made. This lets us inform the data source of changes. This // is an effect so that the subscriber can run synchronously without @@ -287,4 +300,10 @@ export default function useBlockSync( { unsubscribe(); }; }, [ registry, clientId ] ); + + useEffect( () => { + return () => { + unsetControlledBlocks(); + }; + }, [] ); } diff --git a/packages/block-editor/src/components/recursion-provider/README.md b/packages/block-editor/src/components/recursion-provider/README.md new file mode 100644 index 00000000000000..4538fd6a7d3507 --- /dev/null +++ b/packages/block-editor/src/components/recursion-provider/README.md @@ -0,0 +1,101 @@ +# RecursionProvider + +According to Gutenberg's block rendering architecture, any block type capable of recursion should be responsible for handling its own infinite loops. + +To help with detecting infinite loops on the client, the `RecursionProvider` component and the `useHasRecursion()` hook are used to identify if a block has already been rendered. + +## Usage + +```jsx +/** + * WordPress dependencies + */ +import { + __experimentalRecursionProvider as RecursionProvider, + __experimentalUseHasRecursion as useHasRecursion, + useBlockProps, + Warning, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +export default function MyRecursiveBlockEdit( { attributes: { ref } } ) { + const hasAlreadyRendered = useHasRecursion( ref ); + const blockProps = useBlockProps( { + className: 'my-block__custom-class', + } ); + + if ( hasAlreadyRendered ) { + return ( +
+ + { __( 'Block cannot be rendered inside itself.' ) } + +
+ ); + } + + return ( + + Block editing code here.... + + ); +} + +/// ... + +; +``` + +## Props + +The component accepts the following props: + +### uniqueId + +Any value that acts as a unique identifier for a block instance. + +- Type: `any` +- Required: Yes + +### children + +Components to be rendered as content. + +- Type: `Element` +- Required: Yes. + +### blockName + +Optional block name. + +- Type: `String` +- Required: No +- Default: '' + +# `useHasRecursion()` + +Used in conjunction with `RecursionProvider`, this hook is used to identify if a block has already been rendered. + +## Usage + +For example usage, refer to the example above. + +## Props + +The component accepts the following props: + +### uniqueId + +Any value that acts as a unique identifier for a block instance. + +- Type: `any` +- Required: Yes + +### blockName + +Optional block name. + +- Type: `String` +- Required: No +- Default: '' + diff --git a/packages/block-editor/src/components/resolution-tool/index.js b/packages/block-editor/src/components/resolution-tool/index.js new file mode 100644 index 00000000000000..71c7e508ca3edb --- /dev/null +++ b/packages/block-editor/src/components/resolution-tool/index.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { + SelectControl, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { __, _x } from '@wordpress/i18n'; + +const DEFAULT_SIZE_OPTIONS = [ + { + label: _x( 'Thumbnail', 'Image size option for resolution control' ), + value: 'thumbnail', + }, + { + label: _x( 'Medium', 'Image size option for resolution control' ), + value: 'medium', + }, + { + label: _x( 'Large', 'Image size option for resolution control' ), + value: 'large', + }, + { + label: _x( 'Full Size', 'Image size option for resolution control' ), + value: 'full', + }, +]; + +export default function ResolutionTool( { + panelId, + value, + onChange, + options = DEFAULT_SIZE_OPTIONS, + defaultValue = DEFAULT_SIZE_OPTIONS[ 0 ].value, + isShownByDefault = true, +} ) { + const displayValue = value ?? defaultValue; + return ( + displayValue !== defaultValue } + label={ __( 'Resolution' ) } + onDeselect={ () => onChange( defaultValue ) } + isShownByDefault={ isShownByDefault } + panelId={ panelId } + > + + + ); +} diff --git a/packages/block-editor/src/components/resolution-tool/stories/index.story.js b/packages/block-editor/src/components/resolution-tool/stories/index.story.js new file mode 100644 index 00000000000000..ed598acd4df98f --- /dev/null +++ b/packages/block-editor/src/components/resolution-tool/stories/index.story.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { + Panel, + __experimentalToolsPanel as ToolsPanel, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ResolutionTool from '..'; + +export default { + title: 'BlockEditor (Private APIs)/ResolutionControl', + component: ResolutionTool, + argTypes: { + panelId: { control: { type: null } }, + onChange: { action: 'changed' }, + }, +}; + +export const Default = ( { panelId, onChange: onChangeProp, ...props } ) => { + const [ resolution, setResolution ] = useState( undefined ); + const resetAll = () => { + setResolution( undefined ); + onChangeProp( undefined ); + }; + return ( + + + { + setResolution( newValue ); + onChangeProp( newValue ); + } } + value={ resolution } + { ...props } + /> + + + ); +}; +Default.args = { + panelId: 'panel-id', +}; diff --git a/packages/block-editor/src/components/rich-text/content.js b/packages/block-editor/src/components/rich-text/content.js new file mode 100644 index 00000000000000..9762582f86f141 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/content.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; +import { children as childrenSource } from '@wordpress/blocks'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import { getMultilineTag } from './utils'; + +export const Content = ( { value, tagName: Tag, multiline, ...props } ) => { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + deprecated( 'wp.blockEditor.RichText value prop as children type', { + since: '6.1', + version: '6.3', + alternative: 'value prop as string', + link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', + } ); + + value = childrenSource.toHTML( value ); + } + + const MultilineTag = getMultilineTag( multiline ); + + if ( ! value && MultilineTag ) { + value = `<${ MultilineTag }>`; + } + + const content = { value }; + + if ( Tag ) { + const { format, ...restProps } = props; + return { content }; + } + + return content; +}; diff --git a/packages/block-editor/src/components/rich-text/format-edit.js b/packages/block-editor/src/components/rich-text/format-edit.js index 75b077ab321d43..a70b9f8f778815 100644 --- a/packages/block-editor/src/components/rich-text/format-edit.js +++ b/packages/block-editor/src/components/rich-text/format-edit.js @@ -2,43 +2,67 @@ * WordPress dependencies */ import { getActiveFormat, getActiveObject } from '@wordpress/rich-text'; +import { useContext, useMemo } from '@wordpress/element'; -export default function FormatEdit( { - formatTypes, - onChange, - onFocus, - value, - forwardedRef, -} ) { - return formatTypes.map( ( settings ) => { - const { name, edit: Edit } = settings; - - if ( ! Edit ) { - return null; - } - - const activeFormat = getActiveFormat( value, name ); - const isActive = activeFormat !== undefined; - const activeObject = getActiveObject( value ); - const isObjectActive = - activeObject !== undefined && activeObject.type === name; - - return ( - - ); - } ); +/** + * Internal dependencies + */ +import BlockContext from '../block-context'; + +const DEFAULT_BLOCK_CONTEXT = {}; + +export const usesContextKey = Symbol( 'usesContext' ); + +function Edit( { onChange, onFocus, value, forwardedRef, settings } ) { + const { + name, + edit: EditFunction, + [ usesContextKey ]: usesContext, + } = settings; + + const blockContext = useContext( BlockContext ); + + // Assign context values using the block type's declared context needs. + const context = useMemo( () => { + return usesContext + ? Object.fromEntries( + Object.entries( blockContext ).filter( ( [ key ] ) => + usesContext.includes( key ) + ) + ) + : DEFAULT_BLOCK_CONTEXT; + }, [ usesContext, blockContext ] ); + + if ( ! EditFunction ) { + return null; + } + + const activeFormat = getActiveFormat( value, name ); + const isActive = activeFormat !== undefined; + const activeObject = getActiveObject( value ); + const isObjectActive = + activeObject !== undefined && activeObject.type === name; + + return ( + + ); +} + +export default function FormatEdit( { formatTypes, ...props } ) { + return formatTypes.map( ( settings ) => ( + + ) ); } diff --git a/packages/block-editor/src/components/rich-text/format-toolbar/index.js b/packages/block-editor/src/components/rich-text/format-toolbar/index.js index 445cd38b4cc1de..2a8a7a753211f8 100644 --- a/packages/block-editor/src/components/rich-text/format-toolbar/index.js +++ b/packages/block-editor/src/components/rich-text/format-toolbar/index.js @@ -16,8 +16,7 @@ import { chevronDown } from '@wordpress/icons'; import { orderBy } from '../../../utils/sorting'; const POPOVER_PROPS = { - position: 'bottom right', - variant: 'toolbar', + placement: 'bottom-start', }; const FormatToolbar = () => { diff --git a/packages/block-editor/src/components/rich-text/get-rich-text-values.js b/packages/block-editor/src/components/rich-text/get-rich-text-values.js new file mode 100644 index 00000000000000..bd1c62ea5e6f61 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/get-rich-text-values.js @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ +import { RawHTML, StrictMode, Fragment } from '@wordpress/element'; +import { + getSaveElement, + __unstableGetBlockProps as getBlockProps, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import InnerBlocks from '../inner-blocks'; +import { Content } from './content'; + +/* + * This function is similar to `@wordpress/element`'s `renderToString` function, + * except that it does not render the elements to a string, but instead collects + * the values of all rich text `Content` elements. + */ +function addValuesForElement( element, values, innerBlocks ) { + if ( null === element || undefined === element || false === element ) { + return; + } + + if ( Array.isArray( element ) ) { + return addValuesForElements( element, values, innerBlocks ); + } + + switch ( typeof element ) { + case 'string': + case 'number': + return; + } + + const { type, props } = element; + + switch ( type ) { + case StrictMode: + case Fragment: + return addValuesForElements( props.children, values, innerBlocks ); + case RawHTML: + return; + case InnerBlocks.Content: + return addValuesForBlocks( values, innerBlocks ); + case Content: + values.push( props.value ); + return; + } + + switch ( typeof type ) { + case 'string': + if ( typeof props.children !== 'undefined' ) { + return addValuesForElements( + props.children, + values, + innerBlocks + ); + } + return; + case 'function': + const el = + type.prototype && typeof type.prototype.render === 'function' + ? new type( props ).render() + : type( props ); + return addValuesForElement( el, values, innerBlocks ); + } +} + +function addValuesForElements( children, ...args ) { + children = Array.isArray( children ) ? children : [ children ]; + + for ( let i = 0; i < children.length; i++ ) { + addValuesForElement( children[ i ], ...args ); + } +} + +function addValuesForBlocks( values, blocks ) { + for ( let i = 0; i < blocks.length; i++ ) { + const { name, attributes, innerBlocks } = blocks[ i ]; + const saveElement = getSaveElement( + name, + attributes, + // Instead of letting save elements use `useInnerBlocksProps.save`, + // force them to use InnerBlocks.Content instead so we can intercept + // a single component. + + ); + addValuesForElement( saveElement, values, innerBlocks ); + } +} + +export function getRichTextValues( blocks = [] ) { + getBlockProps.skipFilters = true; + const values = []; + addValuesForBlocks( values, blocks ); + getBlockProps.skipFilters = false; + return values; +} diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 4217d6de588995..0e5ff3847547a7 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -7,7 +7,6 @@ import classnames from 'classnames'; * WordPress dependencies */ import { - RawHTML, useRef, useCallback, forwardRef, @@ -46,6 +45,7 @@ import { useInsertReplacementText } from './use-insert-replacement-text'; import { useFirefoxCompat } from './use-firefox-compat'; import FormatEdit from './format-edit'; import { getMultilineTag, getAllowedFormats } from './utils'; +import { Content } from './content'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -412,6 +412,13 @@ function RichTextWrapper( props.className, 'rich-text' ) } + // Setting tabIndex to 0 is unnecessary, the element is already + // focusable because it's contentEditable. This also fixes a + // Safari bug where it's not possible to Shift+Click multi + // select blocks when Shift Clicking into an element with + // tabIndex because Safari will focus the element. However, + // Safari will correctly ignore nested contentEditable elements. + tabIndex={ props.tabIndex === 0 ? null : props.tabIndex } /> ); @@ -419,40 +426,7 @@ function RichTextWrapper( const ForwardedRichTextContainer = forwardRef( RichTextWrapper ); -ForwardedRichTextContainer.Content = ( { - value, - tagName: Tag, - multiline, - ...props -} ) => { - // Handle deprecated `children` and `node` sources. - if ( Array.isArray( value ) ) { - deprecated( 'wp.blockEditor.RichText value prop as children type', { - since: '6.1', - version: '6.3', - alternative: 'value prop as string', - link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', - } ); - - value = childrenSource.toHTML( value ); - } - - const MultilineTag = getMultilineTag( multiline ); - - if ( ! value && MultilineTag ) { - value = `<${ MultilineTag }>`; - } - - const content = { value }; - - if ( Tag ) { - const { format, ...restProps } = props; - return { content }; - } - - return content; -}; - +ForwardedRichTextContainer.Content = Content; ForwardedRichTextContainer.isEmpty = ( value ) => { return ! value || value.length === 0; }; diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index 82e7a96ec8733a..b0c82848db6876 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -6,13 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - RawHTML, - Platform, - useRef, - useCallback, - forwardRef, -} from '@wordpress/element'; +import { Platform, useRef, useCallback, forwardRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { pasteHandler, @@ -55,6 +49,7 @@ import { createLinkInParagraph, } from './utils'; import EmbedHandlerPicker from './embed-handler-picker'; +import { Content } from './content'; const classes = 'block-editor-rich-text__editable'; @@ -707,32 +702,7 @@ function RichTextWrapper( const ForwardedRichTextContainer = forwardRef( RichTextWrapper ); -ForwardedRichTextContainer.Content = ( { - value, - tagName: Tag, - multiline, - ...props -} ) => { - // Handle deprecated `children` and `node` sources. - if ( Array.isArray( value ) ) { - value = childrenSource.toHTML( value ); - } - - const MultilineTag = getMultilineTag( multiline ); - - if ( ! value && MultilineTag ) { - value = `<${ MultilineTag }>`; - } - - const content = { value }; - - if ( Tag ) { - const { format, ...restProps } = props; - return { content }; - } - - return content; -}; +ForwardedRichTextContainer.Content = Content; ForwardedRichTextContainer.isEmpty = ( value ) => { return ! value || value.length === 0; diff --git a/packages/block-editor/src/components/rich-text/use-format-types.js b/packages/block-editor/src/components/rich-text/use-format-types.js index f798e41ede4dfb..9d26d619432496 100644 --- a/packages/block-editor/src/components/rich-text/use-format-types.js +++ b/packages/block-editor/src/components/rich-text/use-format-types.js @@ -66,21 +66,21 @@ export function useFormatTypes( { } ) { const allFormatTypes = useSelect( formatTypesSelector, [] ); const formatTypes = useMemo( () => { - return allFormatTypes.filter( ( { name, tagName } ) => { + return allFormatTypes.filter( ( { name, interactive, tagName } ) => { if ( allowedFormats && ! allowedFormats.includes( name ) ) { return false; } if ( withoutInteractiveFormatting && - interactiveContentTags.has( tagName ) + ( interactive || interactiveContentTags.has( tagName ) ) ) { return false; } return true; } ); - }, [ allFormatTypes, allowedFormats, interactiveContentTags ] ); + }, [ allFormatTypes, allowedFormats, withoutInteractiveFormatting ] ); const keyedSelected = useSelect( ( select ) => formatTypes.reduce( ( accumulator, type ) => { diff --git a/packages/block-editor/src/components/rich-text/use-paste-handler.js b/packages/block-editor/src/components/rich-text/use-paste-handler.js index 67c932aceddcc1..d86691ced70978 100644 --- a/packages/block-editor/src/components/rich-text/use-paste-handler.js +++ b/packages/block-editor/src/components/rich-text/use-paste-handler.js @@ -129,29 +129,6 @@ export function usePasteHandler( props ) { } const files = [ ...getFilesFromDataTransfer( clipboardData ) ]; - const isInternal = clipboardData.getData( 'rich-text' ) === 'true'; - - // If the data comes from a rich text instance, we can directly use it - // without filtering the data. The filters are only meant for externally - // pasted content and remove inline styles. - if ( isInternal ) { - const pastedMultilineTag = - clipboardData.getData( 'rich-text-multi-line-tag' ) || - undefined; - let pastedValue = create( { - html, - multilineTag: pastedMultilineTag, - multilineWrapperTags: - pastedMultilineTag === 'li' - ? [ 'ul', 'ol' ] - : undefined, - preserveWhiteSpace, - } ); - pastedValue = adjustLines( pastedValue, !! multilineTag ); - addActiveFormats( pastedValue, value.activeFormats ); - onChange( insert( value, pastedValue ) ); - return; - } if ( pastePlainText ) { onChange( insert( value, create( { text: plainText } ) ) ); @@ -238,6 +215,10 @@ export function usePasteHandler( props ) { mode, tagName, preserveWhiteSpace, + // If the data comes from a rich text instance, we can directly + // use it without filtering the data. The filters are only meant + // for externally pasted content and remove inline styles. + disableFilters: !! clipboardData.getData( 'rich-text' ), } ); if ( typeof content === 'string' ) { diff --git a/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js b/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js index 22a6c47401aecf..a7e1b6ec48097d 100644 --- a/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js +++ b/packages/block-editor/src/components/rich-text/use-remove-browser-shortcuts.js @@ -23,7 +23,7 @@ export function useRemoveBrowserShortcuts() { } node.addEventListener( 'keydown', onKeydown ); return () => { - node.addEventListener( 'keydown', onKeydown ); + node.removeEventListener( 'keydown', onKeydown ); }; }, [] ); } diff --git a/packages/block-editor/src/components/spacing-sizes-control/all-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/all-input-control.js deleted file mode 100644 index f7f9686daee33a..00000000000000 --- a/packages/block-editor/src/components/spacing-sizes-control/all-input-control.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * WordPress dependencies - */ -import { __experimentalApplyValueToSides as applyValueToSides } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import SpacingInputControl from './spacing-input-control'; -import { getAllRawValue, isValuesMixed, isValuesDefined } from './utils'; - -export default function AllInputControl( { - onChange, - values, - sides, - spacingSizes, - type, - minimumCustomValue, - onMouseOver, - onMouseOut, -} ) { - const allValue = getAllRawValue( values ); - const hasValues = isValuesDefined( values ); - const isMixed = hasValues && isValuesMixed( values, sides ); - - const handleOnChange = ( next ) => { - const nextValues = applyValueToSides( values, next, sides ); - onChange( nextValues ); - }; - - return ( - - ); -} diff --git a/packages/block-editor/src/components/spacing-sizes-control/axial-input-controls.js b/packages/block-editor/src/components/spacing-sizes-control/axial-input-controls.js deleted file mode 100644 index 3551e20ede7596..00000000000000 --- a/packages/block-editor/src/components/spacing-sizes-control/axial-input-controls.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Internal dependencies - */ -import SpacingInputControl from './spacing-input-control'; -import { LABELS } from './utils'; - -const groupedSides = [ 'vertical', 'horizontal' ]; - -export default function AxialInputControls( { - onChange, - values, - sides, - spacingSizes, - type, - minimumCustomValue, - onMouseOver, - onMouseOut, -} ) { - const createHandleOnChange = ( side ) => ( next ) => { - if ( ! onChange ) { - return; - } - const nextValues = { ...values }; - - if ( side === 'vertical' ) { - nextValues.top = next; - nextValues.bottom = next; - } - - if ( side === 'horizontal' ) { - nextValues.left = next; - nextValues.right = next; - } - - onChange( nextValues ); - }; - - // Filter sides if custom configuration provided, maintaining default order. - const filteredSides = sides?.length - ? groupedSides.filter( ( side ) => sides.includes( side ) ) - : groupedSides; - - return ( - <> - { filteredSides.map( ( side ) => { - const axisValue = - side === 'vertical' ? values.top : values.left; - return ( - - ); - } ) } - - ); -} diff --git a/packages/block-editor/src/components/spacing-sizes-control/hooks/use-spacing-sizes.js b/packages/block-editor/src/components/spacing-sizes-control/hooks/use-spacing-sizes.js new file mode 100644 index 00000000000000..4a24482f3b1e42 --- /dev/null +++ b/packages/block-editor/src/components/spacing-sizes-control/hooks/use-spacing-sizes.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import useSetting from '../../use-setting'; + +export default function useSpacingSizes() { + const spacingSizes = [ + { name: 0, slug: '0', size: 0 }, + ...( useSetting( 'spacing.spacingSizes' ) || [] ), + ]; + + if ( spacingSizes.length > 8 ) { + spacingSizes.unshift( { + name: __( 'Default' ), + slug: 'default', + size: undefined, + } ); + } + + return spacingSizes; +} diff --git a/packages/block-editor/src/components/spacing-sizes-control/index.js b/packages/block-editor/src/components/spacing-sizes-control/index.js index 4ec1285db52bb9..5c26305331dd2d 100644 --- a/packages/block-editor/src/components/spacing-sizes-control/index.js +++ b/packages/block-editor/src/components/spacing-sizes-control/index.js @@ -1,61 +1,50 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ +import { + BaseControl, + __experimentalHStack as HStack, +} from '@wordpress/components'; import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { BaseControl } from '@wordpress/components'; +import { __, _x, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import AllInputControl from './all-input-control'; -import InputControls from './input-controls'; -import AxialInputControls from './axial-input-controls'; -import LinkedButton from './linked-button'; -import { DEFAULT_VALUES, isValuesMixed, isValuesDefined } from './utils'; -import useSetting from '../use-setting'; +import AxialInputControls from './input-controls/axial'; +import SeparatedInputControls from './input-controls/separated'; +import SingleInputControl from './input-controls/single'; +import SidesDropdown from './sides-dropdown'; +import useSpacingSizes from './hooks/use-spacing-sizes'; +import { + ALL_SIDES, + DEFAULT_VALUES, + LABELS, + VIEWS, + getInitialView, +} from './utils'; export default function SpacingSizesControl( { inputProps, - onChange, - label = __( 'Spacing Control' ), - values, - sides, - splitOnAxis = false, - useSelect, + label: labelProp, minimumCustomValue = 0, - onMouseOver, + onChange, onMouseOut, + onMouseOver, + showSideInLabel = true, + sides = ALL_SIDES, + useSelect, + values, } ) { - const spacingSizes = [ - { name: 0, slug: '0', size: 0 }, - ...( useSetting( 'spacing.spacingSizes' ) || [] ), - ]; - - if ( spacingSizes.length > 8 ) { - spacingSizes.unshift( { - name: __( 'Default' ), - slug: 'default', - size: undefined, - } ); - } - + const spacingSizes = useSpacingSizes(); const inputValues = values || DEFAULT_VALUES; - const hasInitialValue = isValuesDefined( values ); const hasOneSide = sides?.length === 1; + const hasOnlyAxialSides = + sides?.includes( 'horizontal' ) && + sides?.includes( 'vertical' ) && + sides?.length === 2; - const [ isLinked, setIsLinked ] = useState( - ! hasInitialValue || ! isValuesMixed( inputValues, sides ) || hasOneSide - ); - - const toggleLinked = () => { - setIsLinked( ! isLinked ); - }; + const [ view, setView ] = useState( getInitialView( inputValues, sides ) ); const handleOnChange = ( nextValue ) => { const newValues = { ...values, ...nextValue }; @@ -64,43 +53,68 @@ export default function SpacingSizesControl( { const inputControlProps = { ...inputProps, + minimumCustomValue, onChange: handleOnChange, - isLinked, + onMouseOut, + onMouseOver, sides, - values: inputValues, spacingSizes, + type: labelProp, useSelect, - type: label, - minimumCustomValue, - onMouseOver, - onMouseOut, + values: inputValues, }; - return ( -
- - { label } - - { ! hasOneSide && ( - - ) } - { isLinked && ( - - ) } + const renderControls = () => { + if ( view === VIEWS.axial ) { + return ; + } + if ( view === VIEWS.custom ) { + return ; + } + return ( + + ); + }; + + const sideLabel = + ALL_SIDES.includes( view ) && showSideInLabel ? LABELS[ view ] : ''; + + const label = sprintf( + // translators: 2. Type of spacing being modified (Padding, margin, etc). 1: The side of the block being modified (top, bottom, left etc.). + __( '%1$s %2$s' ), + labelProp, + sideLabel + ).trim(); - { ! isLinked && splitOnAxis && ( - - ) } - { ! isLinked && ! splitOnAxis && ( - - ) } + const dropdownLabelText = sprintf( + // translators: %s: The current spacing property e.g. "Padding", "Margin". + _x( '%s options', 'Button label to reveal side configuration options' ), + labelProp + ); + + return ( +
+ + + { label } + + { ! hasOneSide && ! hasOnlyAxialSides && ( + + ) } + + { renderControls() }
); } diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls.js deleted file mode 100644 index 412dbad2030c99..00000000000000 --- a/packages/block-editor/src/components/spacing-sizes-control/input-controls.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Internal dependencies - */ -import SpacingInputControl from './spacing-input-control'; -import { ALL_SIDES, LABELS } from './utils'; - -export default function BoxInputControls( { - values, - sides, - onChange, - spacingSizes, - type, - minimumCustomValue, - onMouseOver, - onMouseOut, -} ) { - // Filter sides if custom configuration provided, maintaining default order. - const filteredSides = sides?.length - ? ALL_SIDES.filter( ( side ) => sides.includes( side ) ) - : ALL_SIDES; - - const createHandleOnChange = ( side ) => ( next ) => { - const nextValues = { ...values }; - nextValues[ side ] = next; - - onChange( nextValues ); - }; - - return ( - <> - { filteredSides.map( ( side ) => { - return ( - - ); - } ) } - - ); -} diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls/axial.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/axial.js new file mode 100644 index 00000000000000..6811349b74ad74 --- /dev/null +++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/axial.js @@ -0,0 +1,82 @@ +/** + * Internal dependencies + */ +import SpacingInputControl from './spacing-input-control'; +import { + LABELS, + ICONS, + getPresetValueFromCustomValue, + hasAxisSupport, +} from '../utils'; + +const groupedSides = [ 'vertical', 'horizontal' ]; + +export default function AxialInputControls( { + minimumCustomValue, + onChange, + onMouseOut, + onMouseOver, + sides, + spacingSizes, + type, + values, +} ) { + const createHandleOnChange = ( side ) => ( next ) => { + if ( ! onChange ) { + return; + } + + // Encode the existing value into the preset value if the passed in value matches the value of one of the spacingSizes. + const nextValues = { + ...Object.keys( values ).reduce( ( acc, key ) => { + acc[ key ] = getPresetValueFromCustomValue( + values[ key ], + spacingSizes + ); + return acc; + }, {} ), + }; + + if ( side === 'vertical' ) { + nextValues.top = next; + nextValues.bottom = next; + } + + if ( side === 'horizontal' ) { + nextValues.left = next; + nextValues.right = next; + } + + onChange( nextValues ); + }; + + // Filter sides if custom configuration provided, maintaining default order. + const filteredSides = sides?.length + ? groupedSides.filter( ( side ) => hasAxisSupport( sides, side ) ) + : groupedSides; + + return ( + <> + { filteredSides.map( ( side ) => { + const axisValue = + side === 'vertical' ? values.top : values.left; + return ( + + ); + } ) } + + ); +} diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls/separated.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/separated.js new file mode 100644 index 00000000000000..e9e2f828808549 --- /dev/null +++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/separated.js @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import SpacingInputControl from './spacing-input-control'; +import { + ALL_SIDES, + LABELS, + ICONS, + getPresetValueFromCustomValue, +} from '../utils'; + +export default function SeparatedInputControls( { + minimumCustomValue, + onChange, + onMouseOut, + onMouseOver, + sides, + spacingSizes, + type, + values, +} ) { + // Filter sides if custom configuration provided, maintaining default order. + const filteredSides = sides?.length + ? ALL_SIDES.filter( ( side ) => sides.includes( side ) ) + : ALL_SIDES; + + const createHandleOnChange = ( side ) => ( next ) => { + // Encode the existing value into the preset value if the passed in value matches the value of one of the spacingSizes. + const nextValues = { + ...Object.keys( values ).reduce( ( acc, key ) => { + acc[ key ] = getPresetValueFromCustomValue( + values[ key ], + spacingSizes + ); + return acc; + }, {} ), + }; + + nextValues[ side ] = next; + + onChange( nextValues ); + }; + + return ( + <> + { filteredSides.map( ( side ) => { + return ( + + ); + } ) } + + ); +} diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls/single.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/single.js new file mode 100644 index 00000000000000..df6beb0f8f7b73 --- /dev/null +++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/single.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import SpacingInputControl from './spacing-input-control'; +import { LABELS, getPresetValueFromCustomValue } from '../utils'; + +export default function SingleInputControl( { + minimumCustomValue, + onChange, + onMouseOut, + onMouseOver, + showSideInLabel, + side, + spacingSizes, + type, + values, +} ) { + const createHandleOnChange = ( currentSide ) => ( next ) => { + // Encode the existing value into the preset value if the passed in value matches the value of one of the spacingSizes. + const nextValues = { + ...Object.keys( values ).reduce( ( acc, key ) => { + acc[ key ] = getPresetValueFromCustomValue( + values[ key ], + spacingSizes + ); + return acc; + }, {} ), + }; + + nextValues[ currentSide ] = next; + + onChange( nextValues ); + }; + + return ( + + ); +} diff --git a/packages/block-editor/src/components/spacing-sizes-control/spacing-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js similarity index 80% rename from packages/block-editor/src/components/spacing-sizes-control/spacing-input-control.js rename to packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js index 8831b06b1eebca..f423596daaa4a5 100644 --- a/packages/block-editor/src/components/spacing-sizes-control/spacing-input-control.js +++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js @@ -1,39 +1,35 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ -import { useState, useMemo } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; import { - BaseControl, Button, - RangeControl, CustomSelectControl, - __experimentalUnitControl as UnitControl, + Icon, + RangeControl, __experimentalHStack as HStack, + __experimentalUnitControl as UnitControl, __experimentalUseCustomUnits as useCustomUnits, __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useState, useMemo } from '@wordpress/element'; +import { usePrevious } from '@wordpress/compose'; import { __, sprintf } from '@wordpress/i18n'; import { settings } from '@wordpress/icons'; -import { usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ -import useSetting from '../use-setting'; -import { store as blockEditorStore } from '../../store'; +import useSetting from '../../use-setting'; +import { store as blockEditorStore } from '../../../store'; import { + ALL_SIDES, LABELS, getSliderValueFromPreset, getCustomValueFromPreset, getPresetValueFromCustomValue, isValueSpacingPreset, -} from './utils'; +} from '../utils'; const CUSTOM_VALUE_SETTINGS = { px: { max: 300, steps: 1 }, @@ -45,15 +41,17 @@ const CUSTOM_VALUE_SETTINGS = { }; export default function SpacingInputControl( { - spacingSizes, - value, - side, - onChange, + icon, isMixed = false, - type, minimumCustomValue, - onMouseOver, + onChange, onMouseOut, + onMouseOver, + showSideInLabel = true, + side, + spacingSizes, + type, + value, } ) { // Treat value as a preset value if the passed in value matches the value of one of the spacingSizes. value = getPresetValueFromCustomValue( value, spacingSizes ); @@ -159,73 +157,34 @@ export default function SpacingInputControl( { const allPlaceholder = isMixed ? __( 'Mixed' ) : null; - const currentValueHint = ! isMixed - ? customTooltipContent( currentValue ) - : __( 'Mixed' ); - const options = selectListSizes.map( ( size, index ) => ( { key: index, name: size.name, } ) ); - const marks = spacingSizes.map( ( newValue, index ) => ( { + const marks = spacingSizes.map( ( _newValue, index ) => ( { value: index, label: undefined, } ) ); + const sideLabel = + ALL_SIDES.includes( side ) && showSideInLabel ? LABELS[ side ] : ''; + const typeLabel = showSideInLabel ? type?.toLowerCase() : type; + const ariaLabel = sprintf( - // translators: 1: The side of the block being modified (top, bottom, left, etc.). 2. Type of spacing being modified (Padding, margin, etc) + // translators: 1: The side of the block being modified (top, bottom, left, All sides etc.). 2. Type of spacing being modified (Padding, margin, etc) __( '%1$s %2$s' ), - LABELS[ side ], - type?.toLowerCase() - ); - - const showHint = - showRangeControl && - ! showCustomValueControl && - currentValueHint !== undefined; + sideLabel, + typeLabel + ).trim(); return ( - <> - { side !== 'all' && ( - - - { LABELS[ side ] } - - - { showHint && ( - - { currentValueHint } - - ) } - - ) } - { side === 'all' && showHint && ( - - { currentValueHint } - - ) } - - { ! disableCustomSpacingSizes && ( - - - - - - - -`; - -exports[`URLPopover matches the snapshot when the settings are toggled open 1`] = ` -
- -
-
-
-
-
- Editor -
- -
-
-
- Settings -
-
-
-
-
-
-
-`; - -exports[`URLPopover matches the snapshot when there are no settings 1`] = ` -
- -
-
-
-
-
- Editor -
-
-
-
-
-
-
-`; diff --git a/packages/block-editor/src/components/url-popover/test/index.js b/packages/block-editor/src/components/url-popover/test/index.js deleted file mode 100644 index 1a60d846b2e904..00000000000000 --- a/packages/block-editor/src/components/url-popover/test/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * External dependencies - */ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -/** - * Internal dependencies - */ -import URLPopover from '../'; - -/** - * Returns the first found popover element up the DOM tree. - * - * @param {HTMLElement} element Element to start with. - * @return {HTMLElement|null} Popover element, or `null` if not found. - */ -function getWrappingPopoverElement( element ) { - return element.closest( '.components-popover' ); -} - -describe( 'URLPopover', () => { - it( 'matches the snapshot in its default state', async () => { - const { container } = render( -
Settings
} - > -
Editor
-
- ); - - await waitFor( () => - expect( - getWrappingPopoverElement( screen.getByText( 'Editor' ) ) - ).toBePositionedPopover() - ); - - expect( container ).toMatchSnapshot(); - } ); - - it( 'matches the snapshot when the settings are toggled open', async () => { - const user = userEvent.setup(); - const { container } = render( -
Settings
} - > -
Editor
-
- ); - - await user.click( - screen.getByRole( 'button', { name: 'Link settings' } ) - ); - - expect( container ).toMatchSnapshot(); - } ); - - it( 'matches the snapshot when there are no settings', async () => { - const { container } = render( - -
Editor
-
- ); - - await waitFor( () => - expect( - getWrappingPopoverElement( screen.getByText( 'Editor' ) ) - ).toBePositionedPopover() - ); - - expect( container ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/block-editor/src/components/use-block-commands/index.js b/packages/block-editor/src/components/use-block-commands/index.js new file mode 100644 index 00000000000000..4c3dfc71ea4f27 --- /dev/null +++ b/packages/block-editor/src/components/use-block-commands/index.js @@ -0,0 +1,288 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + hasBlockSupport, + store as blocksStore, + switchToBlockType, + isTemplatePart, +} from '@wordpress/blocks'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCommandLoader } from '@wordpress/commands'; +import { + copy, + edit as remove, + create as add, + group, + ungroup, + moveTo as move, +} from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +export const useTransformCommands = () => { + const { clientIds } = useSelect( ( select ) => { + const { getSelectedBlockClientIds } = select( blockEditorStore ); + const selectedBlockClientIds = getSelectedBlockClientIds(); + + return { + clientIds: selectedBlockClientIds, + }; + }, [] ); + const blocks = useSelect( + ( select ) => + select( blockEditorStore ).getBlocksByClientId( clientIds ), + [ clientIds ] + ); + const { replaceBlocks, multiSelect } = useDispatch( blockEditorStore ); + const { possibleBlockTransformations, canRemove } = useSelect( + ( select ) => { + const { + getBlockRootClientId, + getBlockTransformItems, + canRemoveBlocks, + } = select( blockEditorStore ); + const rootClientId = getBlockRootClientId( + Array.isArray( clientIds ) ? clientIds[ 0 ] : clientIds + ); + return { + possibleBlockTransformations: getBlockTransformItems( + blocks, + rootClientId + ), + canRemove: canRemoveBlocks( clientIds, rootClientId ), + }; + }, + [ clientIds, blocks ] + ); + + const isTemplate = blocks.length === 1 && isTemplatePart( blocks[ 0 ] ); + + function selectForMultipleBlocks( insertedBlocks ) { + if ( insertedBlocks.length > 1 ) { + multiSelect( + insertedBlocks[ 0 ].clientId, + insertedBlocks[ insertedBlocks.length - 1 ].clientId + ); + } + } + + // Simple block tranformation based on the `Block Transforms` API. + function onBlockTransform( name ) { + const newBlocks = switchToBlockType( blocks, name ); + replaceBlocks( clientIds, newBlocks ); + selectForMultipleBlocks( newBlocks ); + } + + /** + * The `isTemplate` check is a stopgap solution here. + * Ideally, the Transforms API should handle this + * by allowing to exclude blocks from wildcard transformations. + */ + const hasPossibleBlockTransformations = + !! possibleBlockTransformations.length && canRemove && ! isTemplate; + + if ( + ! clientIds || + clientIds.length < 1 || + ! hasPossibleBlockTransformations + ) { + return { isLoading: false, commands: [] }; + } + + const commands = possibleBlockTransformations.map( ( transformation ) => { + const { name, title, icon } = transformation; + return { + name: 'core/block-editor/transform-to-' + name.replace( '/', '-' ), + // translators: %s: block title/name. + label: sprintf( __( 'Transform to %s' ), title ), + icon: icon.src, + callback: ( { close } ) => { + onBlockTransform( name ); + close(); + }, + }; + } ); + + return { isLoading: false, commands }; +}; + +const useActionsCommands = () => { + const { clientIds } = useSelect( ( select ) => { + const { getSelectedBlockClientIds } = select( blockEditorStore ); + const selectedBlockClientIds = getSelectedBlockClientIds(); + + return { + clientIds: selectedBlockClientIds, + }; + }, [] ); + const { + canInsertBlockType, + getBlockRootClientId, + getBlocksByClientId, + canMoveBlocks, + canRemoveBlocks, + getBlockCount, + } = useSelect( blockEditorStore ); + const { getDefaultBlockName, getGroupingBlockName } = + useSelect( blocksStore ); + + const blocks = getBlocksByClientId( clientIds ); + + const { + removeBlocks, + replaceBlocks, + duplicateBlocks, + insertAfterBlock, + insertBeforeBlock, + setBlockMovingClientId, + setNavigationMode, + selectBlock, + } = useDispatch( blockEditorStore ); + + const onGroup = () => { + if ( ! blocks.length ) { + return; + } + + const groupingBlockName = getGroupingBlockName(); + + // Activate the `transform` on `core/group` which does the conversion. + const newBlocks = switchToBlockType( blocks, groupingBlockName ); + + if ( ! newBlocks ) { + return; + } + replaceBlocks( clientIds, newBlocks ); + }; + const onUngroup = () => { + if ( ! blocks.length ) { + return; + } + + const innerBlocks = blocks[ 0 ].innerBlocks; + + if ( ! innerBlocks.length ) { + return; + } + + replaceBlocks( clientIds, innerBlocks ); + }; + + if ( ! clientIds || clientIds.length < 1 ) { + return { isLoading: false, commands: [] }; + } + + const rootClientId = getBlockRootClientId( clientIds[ 0 ] ); + const canInsertDefaultBlock = canInsertBlockType( + getDefaultBlockName(), + rootClientId + ); + const canDuplicate = blocks.every( ( block ) => { + return ( + !! block && + hasBlockSupport( block.name, 'multiple', true ) && + canInsertBlockType( block.name, rootClientId ) + ); + } ); + const canRemove = canRemoveBlocks( clientIds, rootClientId ); + const canMove = + canMoveBlocks( clientIds, rootClientId ) && + getBlockCount( rootClientId ) !== 1; + + const commands = [ + { + name: 'ungroup', + label: __( 'Ungroup' ), + callback: onUngroup, + icon: ungroup, + }, + { + name: 'Group', + label: __( 'Group' ), + callback: onGroup, + icon: group, + }, + ]; + if ( canInsertDefaultBlock ) { + commands.push( + { + name: 'add-after', + label: __( 'Add after' ), + callback: () => { + const clientId = Array.isArray( clientIds ) + ? clientIds[ clientIds.length - 1 ] + : clientId; + insertAfterBlock( clientId ); + }, + icon: add, + }, + { + name: 'add-before', + label: __( 'Add before' ), + callback: () => { + const clientId = Array.isArray( clientIds ) + ? clientIds[ 0 ] + : clientId; + insertBeforeBlock( clientId ); + }, + icon: add, + } + ); + } + if ( canRemove ) { + commands.push( { + name: 'remove', + label: __( 'Remove' ), + callback: () => removeBlocks( clientIds, true ), + icon: remove, + } ); + } + if ( canDuplicate ) { + commands.push( { + name: 'duplicate', + label: __( 'Duplicate' ), + callback: () => duplicateBlocks( clientIds, true ), + icon: copy, + } ); + } + if ( canMove ) { + commands.push( { + name: 'move-to', + label: __( 'Move to' ), + callback: () => { + setNavigationMode( true ); + selectBlock( clientIds[ 0 ] ); + setBlockMovingClientId( clientIds[ 0 ] ); + }, + icon: move, + } ); + } + + return { + isLoading: false, + commands: commands.map( ( command ) => ( { + ...command, + name: 'core/block-editor/action-' + command.name, + callback: ( { close } ) => { + command.callback(); + close(); + }, + } ) ), + }; +}; + +export const useBlockCommands = () => { + useCommandLoader( { + name: 'core/block-editor/blockTransforms', + hook: useTransformCommands, + } ); + useCommandLoader( { + name: 'core/block-editor/blockActions', + hook: useActionsCommands, + } ); +}; diff --git a/packages/block-editor/src/components/use-block-display-information/index.js b/packages/block-editor/src/components/use-block-display-information/index.js index 950cb9c905b24c..1cff9da4bc04a9 100644 --- a/packages/block-editor/src/components/use-block-display-information/index.js +++ b/packages/block-editor/src/components/use-block-display-information/index.js @@ -7,6 +7,7 @@ import { isReusableBlock, isTemplatePart, } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -27,6 +28,26 @@ import { store as blockEditorStore } from '../../store'; * @property {string} anchor HTML anchor. */ +/** + * Get the display label for a block's position type. + * + * @param {Object} attributes Block attributes. + * @return {string} The position type label. + */ +function getPositionTypeLabel( attributes ) { + const positionType = attributes?.style?.position?.type; + + if ( positionType === 'sticky' ) { + return __( 'Sticky' ); + } + + if ( positionType === 'fixed' ) { + return __( 'Fixed' ); + } + + return null; +} + /** * Hook used to try to find a matching block variation and return * the appropriate information for display reasons. In order to @@ -46,8 +67,11 @@ export default function useBlockDisplayInformation( clientId ) { return useSelect( ( select ) => { if ( ! clientId ) return null; - const { getBlockName, getBlockAttributes } = - select( blockEditorStore ); + const { + getBlockName, + getBlockAttributes, + __experimentalGetReusableBlockTitle, + } = select( blockEditorStore ); const { getBlockType, getActiveBlockVariation } = select( blocksStore ); const blockName = getBlockName( clientId ); @@ -55,14 +79,21 @@ export default function useBlockDisplayInformation( clientId ) { if ( ! blockType ) return null; const attributes = getBlockAttributes( clientId ); const match = getActiveBlockVariation( blockName, attributes ); - const isSynced = - isReusableBlock( blockType ) || isTemplatePart( blockType ); + const isReusable = isReusableBlock( blockType ); + const resusableTitle = isReusable + ? __experimentalGetReusableBlockTitle( attributes.ref ) + : undefined; + const title = resusableTitle || blockType.title; + const isSynced = isReusable || isTemplatePart( blockType ); + const positionLabel = getPositionTypeLabel( attributes ); const blockTypeInfo = { isSynced, - title: blockType.title, + title, icon: blockType.icon, description: blockType.description, anchor: attributes?.anchor, + positionLabel, + positionType: attributes?.style?.position?.type, }; if ( ! match ) return blockTypeInfo; @@ -72,6 +103,8 @@ export default function useBlockDisplayInformation( clientId ) { icon: match.icon || blockType.icon, description: match.description || blockType.description, anchor: attributes?.anchor, + positionLabel, + positionType: attributes?.style?.position?.type, }; }, [ clientId ] diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index 867423991a1d8c..ba33e8ef8e74f3 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -19,7 +19,6 @@ import { isPointContainedByRect, } from '../../utils/math'; import { store as blockEditorStore } from '../../store'; -import { unlock } from '../../lock-unlock'; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ /** @typedef {import('../use-on-block-drop/types').WPDropOperation} WPDropOperation */ @@ -155,7 +154,7 @@ export default function useBlockDropZone( { __unstableIsWithinBlockOverlay, __unstableHasActiveBlockOverlayActive, getBlockEditingMode, - } = unlock( select( blockEditorStore ) ); + } = select( blockEditorStore ); const blockEditingMode = getBlockEditingMode( targetRootClientId ); return ( blockEditingMode !== 'default' || diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.native.js b/packages/block-editor/src/components/use-block-drop-zone/index.native.js index 5a64803aa4bc20..4f00880873c2fa 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.native.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.native.js @@ -1,14 +1,17 @@ /** * External dependencies */ -import { useSharedValue } from 'react-native-reanimated'; +import { + runOnJS, + useDerivedValue, + useSharedValue, +} from 'react-native-reanimated'; /** * WordPress dependencies */ import { useSelect } from '@wordpress/data'; import { useCallback } from '@wordpress/element'; -import { useThrottle } from '@wordpress/compose'; /** * Internal dependencies @@ -18,6 +21,8 @@ import { useBlockListContext } from '../block-list/block-list-context'; import { getDistanceToNearestEdge } from '../../utils/math'; import useOnBlockDrop from '../use-on-block-drop'; +const UPDATE_TARGET_BLOCK_INDEX_THRESHOLD = 20; // In pixels + /** @typedef {import('../../utils/math').WPPoint} WPPoint */ /** @@ -111,6 +116,14 @@ export default function useBlockDropZone( { rootClientId: targetRootClientId = '', } = {} ) { const targetBlockIndex = useSharedValue( null ); + const dragPosition = { + x: useSharedValue( 0 ), + y: useSharedValue( 0 ), + }; + const prevDragPosition = { + x: useSharedValue( 0 ), + y: useSharedValue( 0 ), + }; const { getBlockListSettings, getSettings } = useSelect( blockEditorStore ); const { blocksLayouts, getBlockLayoutsOrderedByYCoord } = @@ -118,43 +131,67 @@ export default function useBlockDropZone( { const getSortedBlocksLayouts = useCallback( () => { return getBlockLayoutsOrderedByYCoord( blocksLayouts.current ); + // We use the value of `blocksLayouts` as the dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ blocksLayouts.current ] ); const isRTL = getSettings().isRTL; const onBlockDrop = useOnBlockDrop(); - const throttled = useThrottle( - useCallback( - ( event ) => { - const sortedBlockLayouts = getSortedBlocksLayouts(); - - const targetIndex = getNearestBlockIndex( - sortedBlockLayouts, - { x: event.x, y: event.y }, - getBlockListSettings( targetRootClientId )?.orientation, - isRTL - ); - if ( targetIndex !== null ) { - targetBlockIndex.value = targetIndex ?? 0; - } - }, - [ - getSortedBlocksLayouts, - getNearestBlockIndex, - getBlockListSettings, - targetBlockIndex, - ] - ), - 200 + const updateTargetBlockIndex = useCallback( + ( event ) => { + const sortedBlockLayouts = getSortedBlocksLayouts(); + + const targetIndex = getNearestBlockIndex( + sortedBlockLayouts, + { x: event.x, y: event.y }, + getBlockListSettings( targetRootClientId )?.orientation, + isRTL + ); + if ( targetIndex !== null ) { + targetBlockIndex.value = targetIndex ?? 0; + } + }, + [ + getSortedBlocksLayouts, + getBlockListSettings, + targetRootClientId, + isRTL, + targetBlockIndex, + ] ); + useDerivedValue( () => { + const x = dragPosition.x.value; + const y = dragPosition.y.value; + const prevX = prevDragPosition.x.value; + const prevY = prevDragPosition.y.value; + // `updateTargetBlockIndex` performs expensive calculations, so we throttle + // the call using a offset threshold based on the dragging position. + if ( + Math.abs( x - prevX ) >= UPDATE_TARGET_BLOCK_INDEX_THRESHOLD || + Math.abs( y - prevY ) >= UPDATE_TARGET_BLOCK_INDEX_THRESHOLD + ) { + runOnJS( updateTargetBlockIndex )( { x, y } ); + prevDragPosition.x.value = x; + prevDragPosition.y.value = y; + return true; + } + return false; + } ); + return { - onBlockDragOver( event ) { - throttled( event ); + onBlockDragOver( { x, y } ) { + dragPosition.x.value = x; + dragPosition.y.value = y; + }, + onBlockDragOverWorklet( { x, y } ) { + 'worklet'; + dragPosition.x.value = x; + dragPosition.y.value = y; }, onBlockDragEnd() { - throttled.cancel(); targetBlockIndex.value = null; }, onBlockDrop: ( event ) => { diff --git a/packages/block-editor/src/components/use-setting/README.md b/packages/block-editor/src/components/use-setting/README.md new file mode 100644 index 00000000000000..96f3c68fbcfade --- /dev/null +++ b/packages/block-editor/src/components/use-setting/README.md @@ -0,0 +1,39 @@ +## Use Setting + +`useSetting` is a hook that will retrive the setting for the block instance that's in use. + +It does the lookup of the setting in the following order: + +1. Third parties can provide the settings for the block using the filter `blockEditor.useSetting.before`. +2. If no third parties have provided this setting, then it looks up in the block instance hierachy starting from the current block and working its way upwards to its ancestors. +3. If that doesn't prove to be successful in getting a value, then it falls back to the settings from the block editor store. +4. If none of the above steps prove to be successful, then it's likely to be a deprecated setting and the deprecated setting is used instead. + +## Table of contents + +1. [Development guidelines](#development-guidelines) + +## Development guidelines + +### Usage + +This will fetch the default color palette based on the block instance. + +```jsx +import { useSetting } from '@wordpress/block-editor'; + +const defaultColorPalette = useSetting( 'color.palette.default' ); +``` + +Refer [here](https://github.com/WordPress/gutenberg/blob/HEAD/docs/how-to-guides/curating-the-editor-experience.md?plain=1#L330) in order to understand how the filter mentioned above `blockEditor.useSetting.before` can be used. + +### Props + +This hooks accepts the following props. + +#### `path` + +- **Type:** `String` +- **Default:** `undefined` + +The path to the setting that is to be used for a block. Ex: `typography.fontSizes` \ No newline at end of file diff --git a/packages/block-editor/src/components/use-setting/index.js b/packages/block-editor/src/components/use-setting/index.js index add0b7ff6e08db..c1222c9116ae67 100644 --- a/packages/block-editor/src/components/use-setting/index.js +++ b/packages/block-editor/src/components/use-setting/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { get } from 'lodash'; - /** * WordPress dependencies */ @@ -18,6 +13,7 @@ import { applyFilters } from '@wordpress/hooks'; */ import { useBlockEditContext } from '../block-edit'; import { store as blockEditorStore } from '../../store'; +import { getValueFromObjectPath } from '../../utils/object'; const blockedPaths = [ 'color', @@ -165,11 +161,14 @@ export default function useSetting( path ) { candidateClientId ); result = - get( + getValueFromObjectPath( candidateAtts, `settings.blocks.${ blockName }.${ normalizedPath }` ) ?? - get( candidateAtts, `settings.${ normalizedPath }` ); + getValueFromObjectPath( + candidateAtts, + `settings.${ normalizedPath }` + ); if ( result !== undefined ) { // Stop the search for more distant ancestors and move on. break; @@ -183,7 +182,8 @@ export default function useSetting( path ) { const defaultsPath = `__experimentalFeatures.${ normalizedPath }`; const blockPath = `__experimentalFeatures.blocks.${ blockName }.${ normalizedPath }`; result = - get( settings, blockPath ) ?? get( settings, defaultsPath ); + getValueFromObjectPath( settings, blockPath ) ?? + getValueFromObjectPath( settings, defaultsPath ); } // Return if the setting was found in either the block instance or the store. diff --git a/packages/block-editor/src/components/warning/index.js b/packages/block-editor/src/components/warning/index.js index 4891c0a90df98f..914f04e4fc80e8 100644 --- a/packages/block-editor/src/components/warning/index.js +++ b/packages/block-editor/src/components/warning/index.js @@ -9,7 +9,7 @@ import classnames from 'classnames'; import { Children } from '@wordpress/element'; import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { moreHorizontal } from '@wordpress/icons'; +import { moreVertical } from '@wordpress/icons'; function Warning( { className, actions, children, secondaryActions } ) { return ( @@ -34,7 +34,7 @@ function Warning( { className, actions, children, secondaryActions } ) { { secondaryActions && ( + + { __( 'Orientation' ) } + +
+ { WRITING_MODES.map( ( writingMode ) => { + return ( +
+
+ ); +} diff --git a/packages/block-editor/src/components/writing-mode-control/style.scss b/packages/block-editor/src/components/writing-mode-control/style.scss new file mode 100644 index 00000000000000..4b865dc0282c08 --- /dev/null +++ b/packages/block-editor/src/components/writing-mode-control/style.scss @@ -0,0 +1,18 @@ +.block-editor-writing-mode-control { + border: 0; + margin: 0; + padding: 0; + + .block-editor-writing-mode-control__buttons { + // 4px of padding makes the row 40px high, same as an input. + padding: $grid-unit-05 0; + display: flex; + } + + .components-button.has-icon { + height: $grid-unit-40; + margin-right: $grid-unit-05; + min-width: $grid-unit-40; + padding: 0; + } +} diff --git a/packages/block-editor/src/hooks/anchor.js b/packages/block-editor/src/hooks/anchor.js index c8f7998afb57d4..b3fdcd65c541b7 100644 --- a/packages/block-editor/src/hooks/anchor.js +++ b/packages/block-editor/src/hooks/anchor.js @@ -12,6 +12,7 @@ import { Platform } from '@wordpress/element'; * Internal dependencies */ import { InspectorControls } from '../components'; +import { useBlockEditingMode } from '../components/block-editing-mode'; /** * Regular expression matching invalid anchor characters for replacement. @@ -63,6 +64,7 @@ export const withInspectorControl = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { const hasAnchor = hasBlockSupport( props.name, 'anchor' ); + const blockEditingMode = useBlockEditingMode(); if ( hasAnchor && props.isSelected ) { const isWeb = Platform.OS === 'web'; @@ -104,7 +106,7 @@ export const withInspectorControl = createHigherOrderComponent( return ( <> - { isWeb && ( + { isWeb && blockEditingMode === 'default' && ( { textControl } diff --git a/packages/block-editor/src/hooks/auto-inserting-blocks.js b/packages/block-editor/src/hooks/auto-inserting-blocks.js new file mode 100644 index 00000000000000..266d9dd55fa674 --- /dev/null +++ b/packages/block-editor/src/hooks/auto-inserting-blocks.js @@ -0,0 +1,232 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addFilter } from '@wordpress/hooks'; +import { Fragment } from '@wordpress/element'; +import { PanelBody, ToggleControl } from '@wordpress/components'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { createBlock, store as blocksStore } from '@wordpress/blocks'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { InspectorControls } from '../components'; +import { store as blockEditorStore } from '../store'; + +function AutoInsertingBlocksControl( props ) { + const { autoInsertedBlocksForCurrentBlock, groupedAutoInsertedBlocks } = + useSelect( + ( select ) => { + const { getBlockTypes } = select( blocksStore ); + const _autoInsertedBlocksForCurrentBlock = + getBlockTypes()?.filter( + ( { autoInsert } ) => + autoInsert && props.blockName in autoInsert + ); + + // Group by block namespace (i.e. prefix before the slash). + const _groupedAutoInsertedBlocks = + _autoInsertedBlocksForCurrentBlock?.reduce( + ( groups, block ) => { + const [ namespace ] = block.name.split( '/' ); + if ( ! groups[ namespace ] ) { + groups[ namespace ] = []; + } + groups[ namespace ].push( block ); + return groups; + }, + {} + ); + + return { + autoInsertedBlocksForCurrentBlock: + _autoInsertedBlocksForCurrentBlock, + groupedAutoInsertedBlocks: _groupedAutoInsertedBlocks, + }; + }, + [ props.blockName ] + ); + + const { + autoInsertedBlockClientIds, + blockIndex, + rootClientId, + innerBlocksLength, + } = useSelect( + ( select ) => { + const { getBlock, getBlockIndex, getBlockRootClientId } = + select( blockEditorStore ); + const _rootClientId = getBlockRootClientId( props.clientId ); + + const _autoInsertedBlockClientIds = + autoInsertedBlocksForCurrentBlock.reduce( + ( clientIds, block ) => { + const relativePosition = + block?.autoInsert?.[ props.blockName ]; + let candidates; + + switch ( relativePosition ) { + case 'before': + case 'after': + // Any of the current block's siblings (with the right block type) qualifies + // as an auto-inserted block (inserted `before` or `after` the current one), + // as the block might've been auto-inserted and then moved around a bit by the user. + candidates = + getBlock( _rootClientId )?.innerBlocks; + break; + + case 'first_child': + case 'last_child': + // Any of the current block's child blocks (with the right block type) qualifies + // as an auto-inserted first or last child block, as the block might've been + // auto-inserted and then moved around a bit by the user. + candidates = getBlock( + props.clientId + ).innerBlocks; + break; + } + + const autoInsertedBlock = candidates?.find( + ( { name } ) => name === block.name + ); + + if ( autoInsertedBlock ) { + clientIds[ block.name ] = + autoInsertedBlock.clientId; + } + + // TOOD: If no auto-inserted block was found in any of its designated locations, + // we want to check if it's present elsewhere in the block tree. + // If it is, we'd consider it manually inserted and would want to remove the + // corresponding toggle from the block inspector panel. + + return clientIds; + }, + {} + ); + + return { + blockIndex: getBlockIndex( props.clientId ), + innerBlocksLength: getBlock( props.clientId )?.innerBlocks + ?.length, + rootClientId: _rootClientId, + autoInsertedBlockClientIds: _autoInsertedBlockClientIds, + }; + }, + [ autoInsertedBlocksForCurrentBlock, props.blockName, props.clientId ] + ); + + const { insertBlock, removeBlock } = useDispatch( blockEditorStore ); + + if ( ! autoInsertedBlocksForCurrentBlock.length ) { + return null; + } + + const insertBlockIntoDesignatedLocation = ( block, relativePosition ) => { + switch ( relativePosition ) { + case 'before': + case 'after': + insertBlock( + block, + relativePosition === 'after' ? blockIndex + 1 : blockIndex, + rootClientId, // Insert as a child of the current block's parent + false + ); + break; + + case 'first_child': + case 'last_child': + insertBlock( + block, + // TODO: It'd be great if insertBlock() would accept negative indices for insertion. + relativePosition === 'first_child' ? 0 : innerBlocksLength, + props.clientId, // Insert as a child of the current block. + false + ); + break; + } + }; + + return ( + + + { Object.keys( groupedAutoInsertedBlocks ).map( ( vendor ) => { + return ( + +

{ vendor }

+ { groupedAutoInsertedBlocks[ vendor ].map( + ( block ) => { + // TODO: Display block icon. + // + + const checked = + block.name in + autoInsertedBlockClientIds; + + return ( + { + if ( ! checked ) { + // Create and insert block. + const relativePosition = + block.autoInsert[ + props.blockName + ]; + insertBlockIntoDesignatedLocation( + createBlock( + block.name + ), + relativePosition + ); + return; + } + + // Remove block. + const clientId = + autoInsertedBlockClientIds[ + block.name + ]; + removeBlock( clientId, false ); + } } + /> + ); + } + ) } +
+ ); + } ) } +
+
+ ); +} + +export const withAutoInsertingBlocks = createHigherOrderComponent( + ( BlockEdit ) => { + return ( props ) => { + const blockEdit = ; + return ( + <> + { blockEdit } + + + ); + }; + }, + 'withAutoInsertingBlocks' +); + +if ( window?.__experimentalAutoInsertingBlocks ) { + addFilter( + 'editor.BlockEdit', + 'core/auto-inserting-blocks/with-inspector-control', + withAutoInsertingBlocks + ); +} diff --git a/packages/block-editor/src/hooks/behaviors.js b/packages/block-editor/src/hooks/behaviors.js index 01cc20ff625b90..ab44edbfa8fd86 100644 --- a/packages/block-editor/src/hooks/behaviors.js +++ b/packages/block-editor/src/hooks/behaviors.js @@ -4,19 +4,134 @@ import { addFilter } from '@wordpress/hooks'; import { SelectControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +import { hasBlockSupport } from '@wordpress/blocks'; import { createHigherOrderComponent } from '@wordpress/compose'; -import { select } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { InspectorControls } from '../components'; import { store as blockEditorStore } from '../store'; +import { InspectorControls } from '../components'; -/** - * External dependencies - */ -import merge from 'deepmerge'; +function BehaviorsControl( { + blockName, + blockBehaviors, + onChangeBehavior, + onChangeAnimation, + disabled = false, +} ) { + const { settings } = useSelect( + ( select ) => { + const { getSettings } = select( blockEditorStore ); + return { + settings: + getSettings()?.__experimentalFeatures?.blocks?.[ blockName ] + ?.behaviors || {}, + }; + }, + [ blockName ] + ); + + const defaultBehaviors = { + default: { + value: 'default', + label: __( 'Default' ), + }, + noBehaviors: { + value: '', + label: __( 'No behaviors' ), + }, + }; + const behaviorsOptions = Object.entries( settings ) + .filter( + ( [ behaviorName, behaviorValue ] ) => + hasBlockSupport( blockName, `behaviors.${ behaviorName }` ) && + behaviorValue + ) // Filter out behaviors that are disabled. + .map( ( [ behaviorName ] ) => ( { + value: behaviorName, + // Capitalize the first letter of the behavior name. + label: `${ behaviorName.charAt( 0 ).toUpperCase() }${ behaviorName + .slice( 1 ) + .toLowerCase() }`, + } ) ); + const options = [ + ...Object.values( defaultBehaviors ), + ...behaviorsOptions, + ]; + + const { behaviors, behaviorsValue } = useMemo( () => { + const mergedBehaviors = { + ...( blockBehaviors || {} ), + }; + + let value = ''; + if ( blockBehaviors === undefined ) { + value = 'default'; + } + if ( blockBehaviors?.lightbox.enabled ) { + value = 'lightbox'; + } + return { + behaviors: mergedBehaviors, + behaviorsValue: value, + }; + }, [ blockBehaviors ] ); + + // If every behavior is disabled, do not show the behaviors inspector control. + if ( behaviorsOptions.length === 0 ) { + return null; + } + + const helpText = disabled + ? __( 'The lightbox behavior is disabled for linked images.' ) + : ''; + + return ( + +
+ + { behaviorsValue === 'lightbox' && ( + + ) } +
+
+ ); +} /** * Override the default edit UI to include a new block inspector control for @@ -30,68 +145,55 @@ import merge from 'deepmerge'; */ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { - // Only add behaviors to the core/image block. - if ( props.name !== 'core/image' ) { - return ; - } - - const settings = - select( blockEditorStore ).getSettings()?.__experimentalFeatures - ?.blocks?.[ props.name ]?.behaviors; - - if ( - ! settings || - // If every behavior is disabled, do not show the behaviors inspector control. - Object.entries( settings ).every( ( [ , value ] ) => ! value ) - ) { - return ; + const blockEdit = ; + // Only add behaviors to blocks with support. + if ( ! hasBlockSupport( props.name, 'behaviors' ) ) { + return blockEdit; } - - const { behaviors: blockBehaviors } = props.attributes; - - // Get the theme behaviors for the block from the theme.json. - const themeBehaviors = - select( blockEditorStore ).getBehaviors()?.blocks?.[ props.name ]; - - // Block behaviors take precedence over theme behaviors. - const behaviors = merge( themeBehaviors, blockBehaviors || {} ); - + const blockHasLink = + typeof props.attributes?.linkDestination !== 'undefined' && + props.attributes?.linkDestination !== 'none'; return ( <> - - - behaviorValue ) // Filter out behaviors that are disabled. - .map( ( [ behaviorName ] ) => ( { - value: behaviorName, - label: - // Capitalize the first letter of the behavior name. - behaviorName[ 0 ].toUpperCase() + - behaviorName.slice( 1 ).toLowerCase(), - } ) ) - .concat( { - value: '', - label: __( 'No behaviors' ), - } ) } - onChange={ ( nextValue ) => { + { blockEdit } + { + if ( nextValue === 'default' ) { + props.setAttributes( { + behaviors: undefined, + } ); + } else { // If the user selects something, it means that they want to // change the default value (true) so we save it in the attributes. props.setAttributes( { behaviors: { - lightbox: nextValue === 'lightbox', + lightbox: { + enabled: nextValue === 'lightbox', + animation: + nextValue === 'lightbox' + ? 'zoom' + : '', + }, }, } ); - } } - hideCancelButton={ true } - help={ __( 'Add behaviors.' ) } - size="__unstable-large" - /> - + } + } } + onChangeAnimation={ ( nextValue ) => { + props.setAttributes( { + behaviors: { + lightbox: { + enabled: + props.attributes.behaviors.lightbox + .enabled, + animation: nextValue, + }, + }, + } ); + } } + disabled={ blockHasLink } + /> ); }; diff --git a/packages/block-editor/src/hooks/border.js b/packages/block-editor/src/hooks/border.js index 0e0bdb34261c8e..c2d30d5501576b 100644 --- a/packages/block-editor/src/hooks/border.js +++ b/packages/block-editor/src/hooks/border.js @@ -377,13 +377,14 @@ export const withBorderColorPaletteStyles = createHigherOrderComponent( borderBottomColor: borderBottomColor || borderColorValue, borderLeftColor: borderLeftColor || borderColorValue, }; + const cleanedExtraStyles = cleanEmptyObject( extraStyles ) || {}; let wrapperProps = props.wrapperProps; wrapperProps = { ...props.wrapperProps, style: { ...props.wrapperProps?.style, - ...extraStyles, + ...cleanedExtraStyles, }, }; diff --git a/packages/block-editor/src/hooks/custom-class-name.js b/packages/block-editor/src/hooks/custom-class-name.js index fa1a1cadc5712a..5505c5fcae2cca 100644 --- a/packages/block-editor/src/hooks/custom-class-name.js +++ b/packages/block-editor/src/hooks/custom-class-name.js @@ -16,6 +16,7 @@ import { createHigherOrderComponent } from '@wordpress/compose'; * Internal dependencies */ import { InspectorControls } from '../components'; +import { useBlockEditingMode } from '../components/block-editing-mode'; /** * Filters registered block settings, extending attributes to include `className`. @@ -50,6 +51,7 @@ export function addAttribute( settings ) { export const withInspectorControl = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { + const blockEditingMode = useBlockEditingMode(); const hasCustomClassName = hasBlockSupport( props.name, 'customClassName', @@ -59,25 +61,27 @@ export const withInspectorControl = createHigherOrderComponent( return ( <> - - { - props.setAttributes( { - className: - nextValue !== '' - ? nextValue - : undefined, - } ); - } } - help={ __( - 'Separate multiple classes with spaces.' - ) } - /> - + { blockEditingMode === 'default' && ( + + { + props.setAttributes( { + className: + nextValue !== '' + ? nextValue + : undefined, + } ); + } } + help={ __( + 'Separate multiple classes with spaces.' + ) } + /> + + ) } ); } diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js new file mode 100644 index 00000000000000..dbc8c3ec2c089f --- /dev/null +++ b/packages/block-editor/src/hooks/custom-fields.js @@ -0,0 +1,139 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { PanelBody, TextControl } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { hasBlockSupport } from '@wordpress/blocks'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { InspectorControls } from '../components'; +import { useBlockEditingMode } from '../components/block-editing-mode'; + +/** + * Filters registered block settings, extending attributes to include `connections`. + * + * @param {Object} settings Original block settings. + * + * @return {Object} Filtered block settings. + */ +function addAttribute( settings ) { + if ( hasBlockSupport( settings, '__experimentalConnections', true ) ) { + // Gracefully handle if settings.attributes.connections is undefined. + settings.attributes = { + ...settings.attributes, + connections: { + type: 'object', + }, + }; + } + + return settings; +} + +/** + * Override the default edit UI to include a new block inspector control for + * assigning a connection to blocks that has support for connections. + * Currently, only the `core/paragraph` block is supported and there is only a relation + * between paragraph content and a custom field. + * + * @param {WPComponent} BlockEdit Original component. + * + * @return {WPComponent} Wrapped component. + */ +const withInspectorControl = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const blockEditingMode = useBlockEditingMode(); + const hasCustomFieldsSupport = hasBlockSupport( + props.name, + '__experimentalConnections', + false + ); + + // Check if the current block is a paragraph or image block. + // Currently, only these two blocks are supported. + if ( ! [ 'core/paragraph', 'core/image' ].includes( props.name ) ) { + return ; + } + + // If the block is a paragraph or image block, we need to know which + // attribute to use for the connection. Only the `content` attribute + // of the paragraph block and the `url` attribute of the image block are supported. + let attributeName; + if ( props.name === 'core/paragraph' ) attributeName = 'content'; + if ( props.name === 'core/image' ) attributeName = 'url'; + + if ( hasCustomFieldsSupport && props.isSelected ) { + return ( + <> + + { blockEditingMode === 'default' && ( + + + { + if ( nextValue === '' ) { + props.setAttributes( { + connections: undefined, + [ attributeName ]: undefined, + placeholder: undefined, + } ); + } else { + props.setAttributes( { + connections: { + attributes: { + // The attributeName will be either `content` or `url`. + [ attributeName ]: { + // Source will be variable, could be post_meta, user_meta, term_meta, etc. + // Could even be a custom source like a social media attribute. + source: 'meta_fields', + value: nextValue, + }, + }, + }, + [ attributeName ]: undefined, + placeholder: sprintf( + 'This content will be replaced on the frontend by the value of "%s" custom field.', + nextValue + ), + } ); + } + } } + /> + + + ) } + + ); + } + + return ; + }; +}, 'withInspectorControl' ); + +if ( window.__experimentalConnections ) { + addFilter( + 'blocks.registerBlockType', + 'core/connections/attribute', + addAttribute + ); + addFilter( + 'editor.BlockEdit', + 'core/connections/with-inspector-control', + withInspectorControl + ); +} diff --git a/packages/block-editor/src/hooks/font-family.js b/packages/block-editor/src/hooks/font-family.js index 754bcdd1b5bffc..0988b285564d3e 100644 --- a/packages/block-editor/src/hooks/font-family.js +++ b/packages/block-editor/src/hooks/font-family.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { kebabCase } from 'lodash'; - /** * WordPress dependencies */ @@ -15,6 +10,7 @@ import TokenList from '@wordpress/token-list'; */ import { shouldSkipSerialization } from './utils'; import { TYPOGRAPHY_SUPPORT_KEY } from './typography'; +import { kebabCase } from '../utils/object'; export const FONT_FAMILY_SUPPORT_KEY = 'typography.__experimentalFontFamily'; diff --git a/packages/block-editor/src/hooks/font-size.js b/packages/block-editor/src/hooks/font-size.js index e03b73c56331db..54d6836a1ef0ad 100644 --- a/packages/block-editor/src/hooks/font-size.js +++ b/packages/block-editor/src/hooks/font-size.js @@ -15,7 +15,6 @@ import { getFontSizeClass, getFontSizeObjectByValue, FontSizePicker, - getComputedFluidTypographyValue, } from '../components/font-sizes'; import { TYPOGRAPHY_SUPPORT_KEY } from './typography'; import { @@ -25,6 +24,10 @@ import { } from './utils'; import useSetting from '../components/use-setting'; import { store as blockEditorStore } from '../store'; +import { + getTypographyFontSizeValue, + getFluidTypographyOptionsFromSettings, +} from '../components/global-styles/typography-utils'; export const FONT_SIZE_SUPPORT_KEY = 'typography.fontSize'; @@ -289,23 +292,15 @@ function addEditPropsForFluidCustomFontSizes( blockType ) { // BlockListContext.Provider. If we set fontSize using editor. // BlockListBlock instead of using getEditWrapperProps then the value is // clobbered when the core/style/addEditProps filter runs. - const fluidTypographyConfig = + const fluidTypographySettings = getFluidTypographyOptionsFromSettings( select( blockEditorStore ).getSettings().__experimentalFeatures - ?.typography?.fluid; - - const fluidTypographySettings = - typeof fluidTypographyConfig === 'object' - ? fluidTypographyConfig - : {}; - - const newFontSize = - fontSize && !! fluidTypographyConfig - ? getComputedFluidTypographyValue( { - fontSize, - minimumFontSizeLimit: - fluidTypographySettings?.minFontSize, - } ) - : null; + ); + const newFontSize = fontSize + ? getTypographyFontSizeValue( + { size: fontSize }, + fluidTypographySettings + ) + : null; if ( newFontSize === null ) { return wrapperProps; diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index a66aa0a73ed411..5e18c6a309d693 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -21,6 +21,8 @@ import './content-lock-ui'; import './metadata'; import './metadata-name'; import './behaviors'; +import './custom-fields'; +import './auto-inserting-blocks'; export { useCustomSides } from './dimensions'; export { useLayoutClasses, useLayoutStyles } from './layout'; diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js index 815b36e785a81d..6238225cc1d36b 100644 --- a/packages/block-editor/src/hooks/layout.js +++ b/packages/block-editor/src/hooks/layout.js @@ -2,7 +2,6 @@ * External dependencies */ import classnames from 'classnames'; -import { kebabCase } from 'lodash'; /** * WordPress dependencies @@ -30,8 +29,18 @@ import { LayoutStyle } from '../components/block-list/layout'; import BlockList from '../components/block-list'; import { getLayoutType, getLayoutTypes } from '../layouts'; import { useBlockEditingMode } from '../components/block-editing-mode'; +import { LAYOUT_DEFINITIONS } from '../layouts/definitions'; +import { kebabCase } from '../utils/object'; +import { useBlockSettings } from './utils'; -const layoutBlockSupportKey = '__experimentalLayout'; +const layoutBlockSupportKey = 'layout'; + +function hasLayoutBlockSupport( blockName ) { + return ( + hasBlockSupport( blockName, 'layout' ) || + hasBlockSupport( blockName, '__experimentalLayout' ) + ); +} /** * Generates the utility classnames for the given block's layout attributes. @@ -47,8 +56,6 @@ export function useLayoutClasses( blockAttributes = {}, blockName = '' ) { return getSettings().__experimentalFeatures ?.useRootPaddingAwareAlignments; }, [] ); - const globalLayoutSettings = useSetting( 'layout' ) || {}; - const { layout } = blockAttributes; const { default: defaultBlockLayout } = @@ -60,16 +67,15 @@ export function useLayoutClasses( blockAttributes = {}, blockName = '' ) { const layoutClassnames = []; - if ( - globalLayoutSettings?.definitions?.[ usedLayout?.type || 'default' ] - ?.className - ) { + if ( LAYOUT_DEFINITIONS[ usedLayout?.type || 'default' ]?.className ) { const baseClassName = - globalLayoutSettings?.definitions?.[ usedLayout?.type || 'default' ] - ?.className; - const compoundClassName = `wp-block-${ blockName - .split( '/' ) - .pop() }-${ baseClassName }`; + LAYOUT_DEFINITIONS[ usedLayout?.type || 'default' ]?.className; + const splitBlockName = blockName.split( '/' ); + const fullBlockName = + splitBlockName[ 0 ] === 'core' + ? splitBlockName.pop() + : splitBlockName.join( '-' ); + const compoundClassName = `wp-block-${ fullBlockName }-${ baseClassName }`; layoutClassnames.push( baseClassName, compoundClassName ); } @@ -118,14 +124,12 @@ export function useLayoutStyles( blockAttributes = {}, blockName, selector ) { ? { ...layout, type: 'constrained' } : layout || {}; const fullLayoutType = getLayoutType( usedLayout?.type || 'default' ); - const globalLayoutSettings = useSetting( 'layout' ) || {}; const blockGapSupport = useSetting( 'spacing.blockGap' ); const hasBlockGapSupport = blockGapSupport !== null; const css = fullLayoutType?.getLayoutStyle?.( { blockName, selector, layout, - layoutDefinitions: globalLayoutSettings?.definitions, style, hasBlockGapSupport, } ); @@ -133,6 +137,11 @@ export function useLayoutStyles( blockAttributes = {}, blockName, selector ) { } function LayoutPanel( { setAttributes, attributes, name: blockName } ) { + const settings = useBlockSettings( blockName ); + const { + layout: { allowEditing: allowEditingSetting }, + } = settings; + const { layout } = attributes; const defaultThemeLayout = useSetting( 'layout' ); const { themeSupportsLayout } = useSelect( ( select ) => { @@ -150,7 +159,7 @@ function LayoutPanel( { setAttributes, attributes, name: blockName } ) { ); const { allowSwitching, - allowEditing = true, + allowEditing = allowEditingSetting ?? true, allowInheriting = true, default: defaultBlockLayout, } = layoutBlockSupport; @@ -302,7 +311,7 @@ export function addAttribute( settings ) { if ( 'type' in ( settings.attributes?.layout ?? {} ) ) { return settings; } - if ( hasBlockSupport( settings, layoutBlockSupportKey ) ) { + if ( hasLayoutBlockSupport( settings ) ) { settings.attributes = { ...settings.attributes, layout: { @@ -324,13 +333,13 @@ export function addAttribute( settings ) { export const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const { name: blockName } = props; - const supportLayout = hasBlockSupport( - blockName, - layoutBlockSupportKey - ); + const supportLayout = hasLayoutBlockSupport( blockName ); + const blockEditingMode = useBlockEditingMode(); return [ - supportLayout && , + supportLayout && blockEditingMode === 'default' && ( + + ), , ]; }, @@ -347,18 +356,14 @@ export const withInspectorControls = createHigherOrderComponent( export const withLayoutStyles = createHigherOrderComponent( ( BlockListBlock ) => ( props ) => { const { name, attributes } = props; - const hasLayoutBlockSupport = hasBlockSupport( - name, - layoutBlockSupportKey - ); + const blockSupportsLayout = hasLayoutBlockSupport( name ); const disableLayoutStyles = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); return !! getSettings().disableLayoutStyles; } ); const shouldRenderLayoutStyles = - hasLayoutBlockSupport && ! disableLayoutStyles; + blockSupportsLayout && ! disableLayoutStyles; const id = useInstanceId( BlockListBlock ); - const defaultThemeLayout = useSetting( 'layout' ) || {}; const element = useContext( BlockList.__unstableElementContext ); const { layout } = attributes; const { default: defaultBlockLayout } = @@ -367,7 +372,7 @@ export const withLayoutStyles = createHigherOrderComponent( layout?.inherit || layout?.contentSize || layout?.wideSize ? { ...layout, type: 'constrained' } : layout || defaultBlockLayout || {}; - const layoutClasses = hasLayoutBlockSupport + const layoutClasses = blockSupportsLayout ? useLayoutClasses( attributes, name ) : null; // Higher specificity to override defaults from theme.json. @@ -386,7 +391,6 @@ export const withLayoutStyles = createHigherOrderComponent( blockName: name, selector, layout: usedLayout, - layoutDefinitions: defaultThemeLayout?.definitions, style: attributes?.style, hasBlockGapSupport, } ); diff --git a/packages/block-editor/src/hooks/margin.js b/packages/block-editor/src/hooks/margin.js index bdf5cabea6a9df..8f723b3f8c97de 100644 --- a/packages/block-editor/src/hooks/margin.js +++ b/packages/block-editor/src/hooks/margin.js @@ -23,7 +23,10 @@ export function MarginVisualizer( { clientId, attributes, forceShow } ) { const margin = attributes?.style?.spacing?.margin; useEffect( () => { - if ( ! blockElement ) { + if ( + ! blockElement || + null === blockElement.ownerDocument.defaultView + ) { return; } diff --git a/packages/block-editor/src/hooks/padding.js b/packages/block-editor/src/hooks/padding.js index f451f2cb4262ea..b6e4e50e30f9cf 100644 --- a/packages/block-editor/src/hooks/padding.js +++ b/packages/block-editor/src/hooks/padding.js @@ -23,7 +23,10 @@ export function PaddingVisualizer( { clientId, attributes, forceShow } ) { const padding = attributes?.style?.spacing?.padding; useEffect( () => { - if ( ! blockElement ) { + if ( + ! blockElement || + null === blockElement.ownerDocument.defaultView + ) { return; } diff --git a/packages/block-editor/src/hooks/position.js b/packages/block-editor/src/hooks/position.js index 81f29fd6a41447..32e8dc8530d370 100644 --- a/packages/block-editor/src/hooks/position.js +++ b/packages/block-editor/src/hooks/position.js @@ -28,6 +28,7 @@ import { addFilter } from '@wordpress/hooks'; import BlockList from '../components/block-list'; import useSetting from '../components/use-setting'; import InspectorControls from '../components/inspector-controls'; +import useBlockDisplayInformation from '../components/use-block-display-information'; import { cleanEmptyObject } from './utils'; import { unlock } from '../lock-unlock'; import { store as blockEditorStore } from '../store'; @@ -222,32 +223,39 @@ export function PositionPanel( props ) { const allowSticky = hasStickyPositionSupport( blockName ); const value = style?.position?.type; - const { hasParents } = useSelect( + const { firstParentClientId } = useSelect( ( select ) => { const { getBlockParents } = select( blockEditorStore ); const parents = getBlockParents( clientId ); - return { - hasParents: parents.length, - }; + return { firstParentClientId: parents[ parents.length - 1 ] }; }, [ clientId ] ); + const blockInformation = useBlockDisplayInformation( firstParentClientId ); + const stickyHelpText = + allowSticky && value === STICKY_OPTION.value && blockInformation + ? sprintf( + /* translators: %s: the name of the parent block. */ + __( + 'The block will stick to the scrollable area of the parent %s block.' + ), + blockInformation.title + ) + : null; + const options = useMemo( () => { const availableOptions = [ DEFAULT_OPTION ]; - // Only display sticky option if the block has no parents (is at the root of the document), - // or if the block already has a sticky position value set. - if ( - ( allowSticky && ! hasParents ) || - value === STICKY_OPTION.value - ) { + // Display options if they are allowed, or if a block already has a valid value set. + // This allows for a block to be switched off from a position type that is not allowed. + if ( allowSticky || value === STICKY_OPTION.value ) { availableOptions.push( STICKY_OPTION ); } if ( allowFixed || value === FIXED_OPTION.value ) { availableOptions.push( FIXED_OPTION ); } return availableOptions; - }, [ allowFixed, allowSticky, hasParents, value ] ); + }, [ allowFixed, allowSticky, value ] ); const onChangeType = ( next ) => { // For now, use a hard-coded `0px` value for the position. @@ -281,7 +289,11 @@ export function PositionPanel( props ) { web: options.length > 1 ? ( - + - styleSupportKeys.some( ( key ) => hasBlockSupport( blockType, key ) ); +const hasStyleSupport = ( nameOrType ) => + styleSupportKeys.some( ( key ) => hasBlockSupport( nameOrType, key ) ); /** * Returns the inline styles to add depending on the style object @@ -346,11 +347,16 @@ export function addEditProps( settings ) { */ export const withBlockControls = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { + if ( ! hasStyleSupport( props.name ) ) { + return ; + } + const shouldDisplayControls = useDisplayBlockControls(); + const blockEditingMode = useBlockEditingMode(); return ( <> - { shouldDisplayControls && ( + { shouldDisplayControls && blockEditingMode === 'default' && ( <> @@ -358,7 +364,7 @@ export const withBlockControls = createHigherOrderComponent( ) } - + ); }, diff --git a/packages/block-editor/src/hooks/supports.js b/packages/block-editor/src/hooks/supports.js index 7c4906e1d54efb..2cf08d46fa8fe2 100644 --- a/packages/block-editor/src/hooks/supports.js +++ b/packages/block-editor/src/hooks/supports.js @@ -30,17 +30,23 @@ const TEXT_COLUMNS_SUPPORT_KEY = 'typography.textColumns'; * decorations e.g. settings found in `block.json`. */ const TEXT_DECORATION_SUPPORT_KEY = 'typography.__experimentalTextDecoration'; +/** + * Key within block settings' supports array indicating support for writing mode + * e.g. settings found in `block.json`. + */ +const WRITING_MODE_SUPPORT_KEY = 'typography.__experimentalWritingMode'; /** * Key within block settings' supports array indicating support for text * transforms e.g. settings found in `block.json`. */ const TEXT_TRANSFORM_SUPPORT_KEY = 'typography.__experimentalTextTransform'; + /** * Key within block settings' supports array indicating support for letter-spacing * e.g. settings found in `block.json`. */ const LETTER_SPACING_SUPPORT_KEY = 'typography.__experimentalLetterSpacing'; -const LAYOUT_SUPPORT_KEY = '__experimentalLayout'; +const LAYOUT_SUPPORT_KEY = 'layout'; const TYPOGRAPHY_SUPPORT_KEYS = [ LINE_HEIGHT_SUPPORT_KEY, FONT_SIZE_SUPPORT_KEY, @@ -50,6 +56,7 @@ const TYPOGRAPHY_SUPPORT_KEYS = [ TEXT_COLUMNS_SUPPORT_KEY, TEXT_DECORATION_SUPPORT_KEY, TEXT_TRANSFORM_SUPPORT_KEY, + WRITING_MODE_SUPPORT_KEY, LETTER_SPACING_SUPPORT_KEY, ]; const SPACING_SUPPORT_KEY = 'spacing'; diff --git a/packages/block-editor/src/hooks/typography.js b/packages/block-editor/src/hooks/typography.js index cb98c4098c4774..c7d1a6ba3b1443 100644 --- a/packages/block-editor/src/hooks/typography.js +++ b/packages/block-editor/src/hooks/typography.js @@ -30,6 +30,7 @@ const TEXT_DECORATION_SUPPORT_KEY = 'typography.__experimentalTextDecoration'; const TEXT_COLUMNS_SUPPORT_KEY = 'typography.textColumns'; const FONT_STYLE_SUPPORT_KEY = 'typography.__experimentalFontStyle'; const FONT_WEIGHT_SUPPORT_KEY = 'typography.__experimentalFontWeight'; +const WRITING_MODE_SUPPORT_KEY = 'typography.__experimentalWritingMode'; export const TYPOGRAPHY_SUPPORT_KEY = 'typography'; export const TYPOGRAPHY_SUPPORT_KEYS = [ LINE_HEIGHT_SUPPORT_KEY, @@ -39,6 +40,7 @@ export const TYPOGRAPHY_SUPPORT_KEYS = [ FONT_FAMILY_SUPPORT_KEY, TEXT_COLUMNS_SUPPORT_KEY, TEXT_DECORATION_SUPPORT_KEY, + WRITING_MODE_SUPPORT_KEY, TEXT_TRANSFORM_SUPPORT_KEY, LETTER_SPACING_SUPPORT_KEY, ]; diff --git a/packages/block-editor/src/hooks/use-typography-props.js b/packages/block-editor/src/hooks/use-typography-props.js index d6a6b0629143db..1ed02d4a5835f2 100644 --- a/packages/block-editor/src/hooks/use-typography-props.js +++ b/packages/block-editor/src/hooks/use-typography-props.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import { kebabCase } from 'lodash'; import classnames from 'classnames'; /** @@ -9,7 +8,11 @@ import classnames from 'classnames'; */ import { getInlineStyles } from './style'; import { getFontSizeClass } from '../components/font-sizes'; -import { getComputedFluidTypographyValue } from '../components/font-sizes/fluid-utils'; +import { + getTypographyFontSizeValue, + getFluidTypographyOptionsFromSettings, +} from '../components/global-styles/typography-utils'; +import { kebabCase } from '../utils/object'; /* * This utility is intended to assist where the serialization of the typography @@ -27,24 +30,16 @@ import { getComputedFluidTypographyValue } from '../components/font-sizes/fluid- */ export function getTypographyClassesAndStyles( attributes, settings ) { let typographyStyles = attributes?.style?.typography || {}; - const fluidTypographySettings = settings?.typography?.fluid; + const fluidTypographySettings = + getFluidTypographyOptionsFromSettings( settings ); - if ( - !! fluidTypographySettings && - ( true === fluidTypographySettings || - Object.keys( fluidTypographySettings ).length !== 0 ) - ) { - const newFontSize = - getComputedFluidTypographyValue( { - fontSize: attributes?.style?.typography?.fontSize, - minimumFontSizeLimit: fluidTypographySettings?.minFontSize, - maximumViewPortWidth: settings?.layout?.wideSize, - } ) || attributes?.style?.typography?.fontSize; - typographyStyles = { - ...typographyStyles, - fontSize: newFontSize, - }; - } + typographyStyles = { + ...typographyStyles, + fontSize: getTypographyFontSizeValue( + { size: attributes?.style?.typography?.fontSize }, + fluidTypographySettings + ), + }; const style = getInlineStyles( { typography: typographyStyles } ); const fontFamilyClassName = !! attributes?.fontFamily diff --git a/packages/block-editor/src/hooks/utils.js b/packages/block-editor/src/hooks/utils.js index e38eedc80c6358..9c6bf957d61c51 100644 --- a/packages/block-editor/src/hooks/utils.js +++ b/packages/block-editor/src/hooks/utils.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { isEmpty, get } from 'lodash'; - /** * WordPress dependencies */ @@ -14,7 +9,7 @@ import { useMemo } from '@wordpress/element'; */ import { useSetting } from '../components'; import { useSettingsForBlockElement } from '../components/global-styles/hooks'; -import { setImmutably } from '../utils/object'; +import { getValueFromObjectPath, setImmutably } from '../utils/object'; /** * Removed falsy values from nested object. @@ -30,12 +25,13 @@ export const cleanEmptyObject = ( object ) => { ) { return object; } - const cleanedNestedObjects = Object.fromEntries( - Object.entries( object ) - .map( ( [ key, value ] ) => [ key, cleanEmptyObject( value ) ] ) - .filter( ( [ , value ] ) => value !== undefined ) - ); - return isEmpty( cleanedNestedObjects ) ? undefined : cleanedNestedObjects; + + const cleanedNestedObjects = Object.entries( object ) + .map( ( [ key, value ] ) => [ key, cleanEmptyObject( value ) ] ) + .filter( ( [ , value ] ) => value !== undefined ); + return ! cleanedNestedObjects.length + ? undefined + : Object.fromEntries( cleanedNestedObjects ); }; export function transformStyles( @@ -78,7 +74,10 @@ export function transformStyles( Object.entries( activeSupports ).forEach( ( [ support, isActive ] ) => { if ( isActive ) { migrationPaths[ support ].forEach( ( path ) => { - const styleValue = get( referenceBlockAttributes, path ); + const styleValue = getValueFromObjectPath( + referenceBlockAttributes, + path + ); if ( styleValue ) { returnBlock = { ...returnBlock, @@ -135,6 +134,7 @@ export function useBlockSettings( name, parentLayout ) { const lineHeight = useSetting( 'typography.lineHeight' ); const textColumns = useSetting( 'typography.textColumns' ); const textDecoration = useSetting( 'typography.textDecoration' ); + const writingMode = useSetting( 'typography.writingMode' ); const textTransform = useSetting( 'typography.textTransform' ); const letterSpacing = useSetting( 'typography.letterSpacing' ); const padding = useSetting( 'spacing.padding' ); @@ -210,6 +210,7 @@ export function useBlockSettings( name, parentLayout ) { textDecoration, textTransform, letterSpacing, + writingMode, }, spacing: { spacingSizes: { @@ -243,6 +244,7 @@ export function useBlockSettings( name, parentLayout ) { textDecoration, textTransform, letterSpacing, + writingMode, padding, margin, blockGap, diff --git a/packages/block-editor/src/index.js b/packages/block-editor/src/index.js index e272043c7ebab5..1dbc4501e92180 100644 --- a/packages/block-editor/src/index.js +++ b/packages/block-editor/src/index.js @@ -12,8 +12,6 @@ export { getSpacingClassesAndStyles as __experimentalGetSpacingClassesAndStyles, getGapCSSValue as __experimentalGetGapCSSValue, useCachedTruthy, - useLayoutClasses as __experimentaluseLayoutClasses, - useLayoutStyles as __experimentaluseLayoutStyles, } from './hooks'; export * from './components'; export * from './elements'; diff --git a/packages/block-editor/src/layouts/constrained.js b/packages/block-editor/src/layouts/constrained.js index c8c6d741199427..9e4bdee1dfa96a 100644 --- a/packages/block-editor/src/layouts/constrained.js +++ b/packages/block-editor/src/layouts/constrained.js @@ -25,6 +25,7 @@ import useSetting from '../components/use-setting'; import { appendSelectors, getBlockGapCSS, getAlignmentsInfo } from './utils'; import { getGapCSSValue } from '../hooks/gap'; import { shouldSkipSerialization } from '../hooks/utils'; +import { LAYOUT_DEFINITIONS } from './definitions'; export default { name: 'constrained', @@ -152,7 +153,7 @@ export default { style, blockName, hasBlockGapSupport, - layoutDefinitions, + layoutDefinitions = LAYOUT_DEFINITIONS, } ) { const { contentSize, wideSize, justifyContent } = layout; const blockGapStyleValue = getGapCSSValue( style?.spacing?.blockGap ); diff --git a/packages/block-editor/src/layouts/definitions.js b/packages/block-editor/src/layouts/definitions.js new file mode 100644 index 00000000000000..3b1e5c7ab5896a --- /dev/null +++ b/packages/block-editor/src/layouts/definitions.js @@ -0,0 +1,174 @@ +// Layout definitions keyed by layout type. +// Provides a common definition of slugs, classnames, base styles, and spacing styles for each layout type. +// If making changes or additions to layout definitions, be sure to update the corresponding PHP definitions in +// `block-supports/layout.php` so that the server-side and client-side definitions match. +export const LAYOUT_DEFINITIONS = { + default: { + name: 'default', + slug: 'flow', + className: 'is-layout-flow', + baseStyles: [ + { + selector: ' > .alignleft', + rules: { + float: 'left', + 'margin-inline-start': '0', + 'margin-inline-end': '2em', + }, + }, + { + selector: ' > .alignright', + rules: { + float: 'right', + 'margin-inline-start': '2em', + 'margin-inline-end': '0', + }, + }, + { + selector: ' > .aligncenter', + rules: { + 'margin-left': 'auto !important', + 'margin-right': 'auto !important', + }, + }, + ], + spacingStyles: [ + { + selector: ' > :first-child:first-child', + rules: { + 'margin-block-start': '0', + }, + }, + { + selector: ' > :last-child:last-child', + rules: { + 'margin-block-end': '0', + }, + }, + { + selector: ' > *', + rules: { + 'margin-block-start': null, + 'margin-block-end': '0', + }, + }, + ], + }, + constrained: { + name: 'constrained', + slug: 'constrained', + className: 'is-layout-constrained', + baseStyles: [ + { + selector: ' > .alignleft', + rules: { + float: 'left', + 'margin-inline-start': '0', + 'margin-inline-end': '2em', + }, + }, + { + selector: ' > .alignright', + rules: { + float: 'right', + 'margin-inline-start': '2em', + 'margin-inline-end': '0', + }, + }, + { + selector: ' > .aligncenter', + rules: { + 'margin-left': 'auto !important', + 'margin-right': 'auto !important', + }, + }, + { + selector: + ' > :where(:not(.alignleft):not(.alignright):not(.alignfull))', + rules: { + 'max-width': 'var(--wp--style--global--content-size)', + 'margin-left': 'auto !important', + 'margin-right': 'auto !important', + }, + }, + { + selector: ' > .alignwide', + rules: { + 'max-width': 'var(--wp--style--global--wide-size)', + }, + }, + ], + spacingStyles: [ + { + selector: ' > :first-child:first-child', + rules: { + 'margin-block-start': '0', + }, + }, + { + selector: ' > :last-child:last-child', + rules: { + 'margin-block-end': '0', + }, + }, + { + selector: ' > *', + rules: { + 'margin-block-start': null, + 'margin-block-end': '0', + }, + }, + ], + }, + flex: { + name: 'flex', + slug: 'flex', + className: 'is-layout-flex', + displayMode: 'flex', + baseStyles: [ + { + selector: '', + rules: { + 'flex-wrap': 'wrap', + 'align-items': 'center', + }, + }, + { + selector: ' > *', + rules: { + margin: '0', + }, + }, + ], + spacingStyles: [ + { + selector: '', + rules: { + gap: null, + }, + }, + ], + }, + grid: { + name: 'grid', + slug: 'grid', + className: 'is-layout-grid', + displayMode: 'grid', + baseStyles: [ + { + selector: ' > *', + rules: { + margin: '0', + }, + }, + ], + spacingStyles: [ + { + selector: '', + rules: { + gap: null, + }, + }, + ], + }, +}; diff --git a/packages/block-editor/src/layouts/flex.js b/packages/block-editor/src/layouts/flex.js index fd6f377ea591d1..f628a9bf3c3f66 100644 --- a/packages/block-editor/src/layouts/flex.js +++ b/packages/block-editor/src/layouts/flex.js @@ -31,6 +31,7 @@ import { BlockVerticalAlignmentControl, } from '../components'; import { shouldSkipSerialization } from '../hooks/utils'; +import { LAYOUT_DEFINITIONS } from './definitions'; // Used with the default, horizontal flex orientation. const justifyContentMap = { @@ -121,7 +122,7 @@ export default { style, blockName, hasBlockGapSupport, - layoutDefinitions, + layoutDefinitions = LAYOUT_DEFINITIONS, } ) { const { orientation = 'horizontal' } = layout; @@ -258,6 +259,10 @@ function FlexLayoutVerticalAlignmentControl( { ); } +const POPOVER_PROPS = { + placement: 'bottom-start', +}; + function FlexLayoutJustifyContentControl( { layout, onChange, @@ -282,10 +287,7 @@ function FlexLayoutJustifyContentControl( { allowedControls={ allowedControls } value={ justifyContent } onChange={ onJustificationChange } - popoverProps={ { - position: 'bottom right', - variant: 'toolbar', - } } + popoverProps={ POPOVER_PROPS } /> ); } diff --git a/packages/block-editor/src/layouts/flow.js b/packages/block-editor/src/layouts/flow.js index d064edce65fedc..de76e430eac888 100644 --- a/packages/block-editor/src/layouts/flow.js +++ b/packages/block-editor/src/layouts/flow.js @@ -9,6 +9,7 @@ import { __ } from '@wordpress/i18n'; import { getBlockGapCSS, getAlignmentsInfo } from './utils'; import { getGapCSSValue } from '../hooks/gap'; import { shouldSkipSerialization } from '../hooks/utils'; +import { LAYOUT_DEFINITIONS } from './definitions'; export default { name: 'default', @@ -24,7 +25,7 @@ export default { style, blockName, hasBlockGapSupport, - layoutDefinitions, + layoutDefinitions = LAYOUT_DEFINITIONS, } ) { const blockGapStyleValue = getGapCSSValue( style?.spacing?.blockGap ); diff --git a/packages/block-editor/src/layouts/grid.js b/packages/block-editor/src/layouts/grid.js index 69347123fd4213..55ac1e53bcd87e 100644 --- a/packages/block-editor/src/layouts/grid.js +++ b/packages/block-editor/src/layouts/grid.js @@ -18,6 +18,7 @@ import { import { appendSelectors, getBlockGapCSS } from './utils'; import { getGapCSSValue } from '../hooks/gap'; import { shouldSkipSerialization } from '../hooks/utils'; +import { LAYOUT_DEFINITIONS } from './definitions'; const RANGE_CONTROL_MAX_VALUES = { px: 600, @@ -35,7 +36,9 @@ export default { layout = {}, onChange, } ) { - return ( + return layout?.columnCount ? ( + + ) : ( ); } + +// Enables setting number of grid columns +function GridLayoutColumnsControl( { layout, onChange } ) { + const { columnCount = 3 } = layout; + + return ( + + onChange( { + ...layout, + columnCount: value, + } ) + } + min={ 1 } + max={ 6 } + /> + ); +} diff --git a/packages/block-editor/src/layouts/test/utils.js b/packages/block-editor/src/layouts/test/utils.js index 529e1bf74e24c9..a2a3ac644d7ba7 100644 --- a/packages/block-editor/src/layouts/test/utils.js +++ b/packages/block-editor/src/layouts/test/utils.js @@ -75,15 +75,10 @@ describe( 'getBlockGapCSS', () => { expect( result ).toBe( expected ); } ); - it( 'should return an empty string if layout definitions cannot be found', () => { + it( 'should return an empty string if layout definitions is null', () => { const expected = ''; - const result = getBlockGapCSS( - '.my-container', - undefined, - 'flex', - '3em' - ); + const result = getBlockGapCSS( '.my-container', null, 'flex', '3em' ); expect( result ).toBe( expected ); } ); diff --git a/packages/block-editor/src/layouts/utils.js b/packages/block-editor/src/layouts/utils.js index e058c778960544..51c92b5eb457e7 100644 --- a/packages/block-editor/src/layouts/utils.js +++ b/packages/block-editor/src/layouts/utils.js @@ -3,6 +3,11 @@ */ import { __, sprintf } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { LAYOUT_DEFINITIONS } from './definitions'; + /** * Utility to generate the proper CSS selector for layout styles. * @@ -35,14 +40,14 @@ export function appendSelectors( selectors, append = '' ) { * with the provided `blockGapValue`. * * @param {string} selector The CSS selector to target for the generated rules. - * @param {Object} layoutDefinitions Layout definitions object from theme.json. + * @param {Object} layoutDefinitions Layout definitions object. * @param {string} layoutType The layout type (e.g. `default` or `flex`). * @param {string} blockGapValue The current blockGap value to be applied. * @return {string} The generated CSS rules. */ export function getBlockGapCSS( selector, - layoutDefinitions, + layoutDefinitions = LAYOUT_DEFINITIONS, layoutType, blockGapValue ) { diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 0572a67dbbd240..f0a5bfb3902aaa 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -4,13 +4,25 @@ import * as globalStyles from './components/global-styles'; import { ExperimentalBlockEditorProvider } from './components/provider'; import { lock } from './lock-unlock'; +import { getRichTextValues } from './components/rich-text/get-rich-text-values'; +import { kebabCase } from './utils/object'; import ResizableBoxPopover from './components/resizable-box-popover'; import { ComposedPrivateInserter as PrivateInserter } from './components/inserter'; import { PrivateListView } from './components/list-view'; import BlockInfo from './components/block-info-slot-fill'; import { useShouldContextualToolbarShow } from './utils/use-should-contextual-toolbar-show'; import { cleanEmptyObject } from './hooks/utils'; -import { useBlockEditingMode } from './components/block-editing-mode'; +import BlockQuickNavigation from './components/block-quick-navigation'; +import { LayoutStyle } from './components/block-list/layout'; +import { BlockRemovalWarningModal } from './components/block-removal-warning-modal'; +import { useLayoutClasses, useLayoutStyles } from './hooks'; +import DimensionsTool from './components/dimensions-tool'; +import ResolutionTool from './components/resolution-tool'; +import { + default as ReusableBlocksRenameHint, + useReusableBlocksRenameHint, +} from './components/inserter/reusable-block-rename-hint'; +import { usesContextKey } from './components/rich-text/format-edit'; /** * Private @wordpress/block-editor APIs. @@ -19,11 +31,22 @@ export const privateApis = {}; lock( privateApis, { ...globalStyles, ExperimentalBlockEditorProvider, + getRichTextValues, + kebabCase, PrivateInserter, PrivateListView, ResizableBoxPopover, BlockInfo, useShouldContextualToolbarShow, cleanEmptyObject, - useBlockEditingMode, + BlockQuickNavigation, + LayoutStyle, + BlockRemovalWarningModal, + useLayoutClasses, + useLayoutStyles, + DimensionsTool, + ResolutionTool, + ReusableBlocksRenameHint, + useReusableBlocksRenameHint, + usesContextKey, } ); diff --git a/packages/block-editor/src/private-apis.native.js b/packages/block-editor/src/private-apis.native.js index 5555e00477e7b5..17676f634b1cae 100644 --- a/packages/block-editor/src/private-apis.native.js +++ b/packages/block-editor/src/private-apis.native.js @@ -3,6 +3,7 @@ */ import * as globalStyles from './components/global-styles'; import { ExperimentalBlockEditorProvider } from './components/provider'; +import { kebabCase } from './utils/object'; import { lock } from './lock-unlock'; /** @@ -11,5 +12,6 @@ import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { ...globalStyles, + kebabCase, ExperimentalBlockEditorProvider, } ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 27ff57e74919cb..32108de713f754 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1,3 +1,4 @@ +/* eslint no-console: [ 'error', { allow: [ 'error', 'warn' ] } ] */ /** * WordPress dependencies */ @@ -11,6 +12,7 @@ import { hasBlockSupport, switchToBlockType, synchronizeBlocksWithTemplate, + getBlockSupport, } from '@wordpress/blocks'; import { speak } from '@wordpress/a11y'; import { __, _n, sprintf } from '@wordpress/i18n'; @@ -25,40 +27,16 @@ import { retrieveSelectedAttribute, START_OF_SELECTED_AREA, } from '../utils/selection'; -import { __experimentalUpdateSettings } from './private-actions'; +import { + __experimentalUpdateSettings, + privateRemoveBlocks, +} from './private-actions'; /** @typedef {import('../components/use-on-block-drop/types').WPDropOperation} WPDropOperation */ const castArray = ( maybeArray ) => Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ]; -/** - * Action which will insert a default block insert action if there - * are no other blocks at the root of the editor. This action should be used - * in actions which may result in no blocks remaining in the editor (removal, - * replacement, etc). - */ -const ensureDefaultBlock = - () => - ( { select, dispatch } ) => { - // To avoid a focus loss when removing the last block, assure there is - // always a default block if the last of the blocks have been removed. - const count = select.getBlockCount(); - if ( count > 0 ) { - return; - } - - // If there's an custom appender, don't insert default block. - // We have to remember to manually move the focus elsewhere to - // prevent it from being lost though. - const { __unstableHasCustomAppender } = select.getSettings(); - if ( __unstableHasCustomAppender ) { - return; - } - - dispatch.insertDefaultBlock(); - }; - /** * Action that resets blocks state to the specified array of blocks, taking precedence * over any other content reflected as an edit in state. @@ -425,7 +403,7 @@ export const replaceBlocks = initialPosition, meta, } ); - dispatch( ensureDefaultBlock() ); + dispatch.ensureDefaultBlock(); }; /** @@ -971,36 +949,30 @@ export const __unstableSplitSelection = valueA = remove( valueA, selectionA.offset, valueA.text.length ); valueB = remove( valueB, 0, selectionB.offset ); - dispatch.replaceBlocks( - select.getSelectedBlockClientIds(), - [ - { - // Preserve the original client ID. - ...blockA, - attributes: { - ...blockA.attributes, - [ selectionA.attributeKey ]: toHTMLString( { - value: valueA, - ...mapRichTextSettings( attributeDefinitionA ), - } ), - }, + dispatch.replaceBlocks( select.getSelectedBlockClientIds(), [ + { + // Preserve the original client ID. + ...blockA, + attributes: { + ...blockA.attributes, + [ selectionA.attributeKey ]: toHTMLString( { + value: valueA, + ...mapRichTextSettings( attributeDefinitionA ), + } ), }, - createBlock( getDefaultBlockName() ), - { - // Preserve the original client ID. - ...blockB, - attributes: { - ...blockB.attributes, - [ selectionB.attributeKey ]: toHTMLString( { - value: valueB, - ...mapRichTextSettings( attributeDefinitionB ), - } ), - }, + }, + { + // Preserve the original client ID. + ...blockB, + attributes: { + ...blockB.attributes, + [ selectionB.attributeKey ]: toHTMLString( { + value: valueB, + ...mapRichTextSettings( attributeDefinitionB ), + } ), }, - ], - 1, // If we don't pass the `indexToSelect` it will default to the last block. - select.getSelectedBlocksInitialCaretPosition() - ); + }, + ] ); }; /** @@ -1035,9 +1007,17 @@ export const mergeBlocks = if ( ! blockAType ) return; + if ( + ! blockAType.merge && + ! getBlockSupport( blockA.name, '__experimentalOnMerge' ) + ) { + dispatch.selectBlock( blockA.clientId ); + return; + } + const blockB = select.getBlock( clientIdB ); - if ( blockAType && ! blockAType.merge ) { + if ( ! blockAType.merge ) { // If there's no merge function defined, attempt merging inner // blocks. const blocksWithTheSameType = switchToBlockType( @@ -1195,34 +1175,8 @@ export const mergeBlocks = * should be selected * when a block is removed. */ -export const removeBlocks = - ( clientIds, selectPrevious = true ) => - ( { select, dispatch } ) => { - if ( ! clientIds || ! clientIds.length ) { - return; - } - - clientIds = castArray( clientIds ); - const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] ); - const canRemoveBlocks = select.canRemoveBlocks( - clientIds, - rootClientId - ); - - if ( ! canRemoveBlocks ) { - return; - } - - if ( selectPrevious ) { - dispatch.selectPreviousBlock( clientIds[ 0 ], selectPrevious ); - } - - dispatch( { type: 'REMOVE_BLOCKS', clientIds } ); - - // To avoid a focus loss when removing the last block, assure there is - // always a default block if the last of the blocks have been removed. - dispatch( ensureDefaultBlock() ); - }; +export const removeBlocks = ( clientIds, selectPrevious = true ) => + privateRemoveBlocks( clientIds, selectPrevious ); /** * Returns an action object used in signalling that the block with the @@ -1438,7 +1392,9 @@ export function updateBlockListSettings( clientId, settings ) { * @return {Object} Action object */ export function updateSettings( settings ) { - return __experimentalUpdateSettings( settings, true ); + return __experimentalUpdateSettings( settings, { + stripExperimentalSettings: true, + } ); } /** @@ -1741,3 +1697,231 @@ export function __unstableSetTemporarilyEditingAsBlocks( temporarilyEditingAsBlocks, }; } + +/** + * Interface for inserter media requests. + * + * @typedef {Object} InserterMediaRequest + * @property {number} per_page How many items to fetch per page. + * @property {string} search The search term to use for filtering the results. + */ + +/** + * Interface for inserter media responses. Any media resource should + * map their response to this interface, in order to create the core + * WordPress media blocks (image, video, audio). + * + * @typedef {Object} InserterMediaItem + * @property {string} title The title of the media item. + * @property {string} url The source url of the media item. + * @property {string} [previewUrl] The preview source url of the media item to display in the media list. + * @property {number} [id] The WordPress id of the media item. + * @property {number|string} [sourceId] The id of the media item from external source. + * @property {string} [alt] The alt text of the media item. + * @property {string} [caption] The caption of the media item. + */ + +/** + * Registers a new inserter media category. Once registered, the media category is + * available in the inserter's media tab. + * + * The following interfaces are used: + * + * _Type Definition_ + * + * - _InserterMediaRequest_ `Object`: Interface for inserter media requests. + * + * _Properties_ + * + * - _per_page_ `number`: How many items to fetch per page. + * - _search_ `string`: The search term to use for filtering the results. + * + * _Type Definition_ + * + * - _InserterMediaItem_ `Object`: Interface for inserter media responses. Any media resource should + * map their response to this interface, in order to create the core + * WordPress media blocks (image, video, audio). + * + * _Properties_ + * + * - _title_ `string`: The title of the media item. + * - _url_ `string: The source url of the media item. + * - _previewUrl_ `[string]`: The preview source url of the media item to display in the media list. + * - _id_ `[number]`: The WordPress id of the media item. + * - _sourceId_ `[number|string]`: The id of the media item from external source. + * - _alt_ `[string]`: The alt text of the media item. + * - _caption_ `[string]`: The caption of the media item. + * + * @param {InserterMediaCategory} category The inserter media category to register. + * + * @example + * ```js + * + * wp.data.dispatch('core/block-editor').registerInserterMediaCategory( { + * name: 'openverse', + * labels: { + * name: 'Openverse', + * search_items: 'Search Openverse', + * }, + * mediaType: 'image', + * async fetch( query = {} ) { + * const defaultArgs = { + * mature: false, + * excluded_source: 'flickr,inaturalist,wikimedia', + * license: 'pdm,cc0', + * }; + * const finalQuery = { ...query, ...defaultArgs }; + * // Sometimes you might need to map the supported request params according to `InserterMediaRequest`. + * // interface. In this example the `search` query param is named `q`. + * const mapFromInserterMediaRequest = { + * per_page: 'page_size', + * search: 'q', + * }; + * const url = new URL( 'https://api.openverse.engineering/v1/images/' ); + * Object.entries( finalQuery ).forEach( ( [ key, value ] ) => { + * const queryKey = mapFromInserterMediaRequest[ key ] || key; + * url.searchParams.set( queryKey, value ); + * } ); + * const response = await window.fetch( url, { + * headers: { + * 'User-Agent': 'WordPress/inserter-media-fetch', + * }, + * } ); + * const jsonResponse = await response.json(); + * const results = jsonResponse.results; + * return results.map( ( result ) => ( { + * ...result, + * // If your response result includes an `id` prop that you want to access later, it should + * // be mapped to `InserterMediaItem`'s `sourceId` prop. This can be useful if you provide + * // a report URL getter. + * // Additionally you should always clear the `id` value of your response results because + * // it is used to identify WordPress media items. + * sourceId: result.id, + * id: undefined, + * caption: result.caption, + * previewUrl: result.thumbnail, + * } ) ); + * }, + * getReportUrl: ( { sourceId } ) => + * `https://wordpress.org/openverse/image/${ sourceId }/report/`, + * isExternalResource: true, + * } ); + * ``` + * + * @typedef {Object} InserterMediaCategory Interface for inserter media category. + * @property {string} name The name of the media category, that should be unique among all media categories. + * @property {Object} labels Labels for the media category. + * @property {string} labels.name General name of the media category. It's used in the inserter media items list. + * @property {string} [labels.search_items='Search'] Label for searching items. Default is ‘Search Posts’ / ‘Search Pages’. + * @property {('image'|'audio'|'video')} mediaType The media type of the media category. + * @property {(InserterMediaRequest) => Promise} fetch The function to fetch media items for the category. + * @property {(InserterMediaItem) => string} [getReportUrl] If the media category supports reporting media items, this function should return + * the report url for the media item. It accepts the `InserterMediaItem` as an argument. + * @property {boolean} [isExternalResource] If the media category is an external resource, this should be set to true. + * This is used to avoid making a request to the external resource when the user + */ +export const registerInserterMediaCategory = + ( category ) => + ( { select, dispatch } ) => { + if ( ! category || typeof category !== 'object' ) { + console.error( + 'Category should be an `InserterMediaCategory` object.' + ); + return; + } + if ( ! category.name ) { + console.error( + 'Category should have a `name` that should be unique among all media categories.' + ); + return; + } + if ( ! category.labels?.name ) { + console.error( 'Category should have a `labels.name`.' ); + return; + } + if ( ! [ 'image', 'audio', 'video' ].includes( category.mediaType ) ) { + console.error( + 'Category should have `mediaType` property that is one of `image|audio|video`.' + ); + return; + } + if ( ! category.fetch || typeof category.fetch !== 'function' ) { + console.error( + 'Category should have a `fetch` function defined with the following signature `(InserterMediaRequest) => Promise`.' + ); + return; + } + const { inserterMediaCategories = [] } = select.getSettings(); + if ( + inserterMediaCategories.some( + ( { name } ) => name === category.name + ) + ) { + console.error( + `A category is already registered with the same name: "${ category.name }".` + ); + return; + } + if ( + inserterMediaCategories.some( + ( { labels: { name } } ) => name === category.labels?.name + ) + ) { + console.error( + `A category is already registered with the same labels.name: "${ category.labels.name }".` + ); + return; + } + // `inserterMediaCategories` is a private block editor setting, which means it cannot + // be updated through the public `updateSettings` action. We preserve this setting as + // private, so extenders can only add new inserter media categories and don't have any + // control over the core media categories. + dispatch( { + type: 'UPDATE_SETTINGS', + settings: { + inserterMediaCategories: [ + ...inserterMediaCategories, + { ...category, isExternalResource: true }, + ], + }, + } ); + }; + +/** + * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode + */ + +/** + * Sets the block editing mode for a given block. + * + * @see useBlockEditingMode + * + * @param {string} clientId The block client ID, or `''` for the root container. + * @param {BlockEditingMode} mode The block editing mode. One of `'disabled'`, + * `'contentOnly'`, or `'default'`. + * + * @return {Object} Action object. + */ +export function setBlockEditingMode( clientId = '', mode ) { + return { + type: 'SET_BLOCK_EDITING_MODE', + clientId, + mode, + }; +} + +/** + * Clears the block editing mode for a given block. + * + * @see useBlockEditingMode + * + * @param {string} clientId The block client ID, or `''` for the root container. + * + * @return {Object} Action object. + */ +export function unsetBlockEditingMode( clientId = '' ) { + return { + type: 'UNSET_BLOCK_EDITING_MODE', + clientId, + }; +} diff --git a/packages/block-editor/src/store/defaults.js b/packages/block-editor/src/store/defaults.js index 7c33350d12dd01..acd40244cb2604 100644 --- a/packages/block-editor/src/store/defaults.js +++ b/packages/block-editor/src/store/defaults.js @@ -18,6 +18,7 @@ export const PREFERENCES_DEFAULTS = { * @property {number} maxWidth Max width to constraint resizing * @property {boolean|Array} allowedBlockTypes Allowed block types * @property {boolean} hasFixedToolbar Whether or not the editor toolbar is fixed + * @property {boolean} distractionFree Whether or not the editor UI is distraction free * @property {boolean} focusMode Whether the focus mode is enabled or not * @property {Array} styles Editor Styles * @property {boolean} keepCaretInsideBlock Whether caret should move between blocks in edit mode diff --git a/packages/block-editor/src/store/index.js b/packages/block-editor/src/store/index.js index ed17b387ba5884..0bcc00cb5f6ae8 100644 --- a/packages/block-editor/src/store/index.js +++ b/packages/block-editor/src/store/index.js @@ -43,3 +43,13 @@ const registeredStore = registerStore( STORE_NAME, { } ); unlock( registeredStore ).registerPrivateActions( privateActions ); unlock( registeredStore ).registerPrivateSelectors( privateSelectors ); + +// TODO: Remove once we switch to the `register` function (see above). +// +// Until then, private functions also need to be attached to the original +// `store` descriptor in order to avoid unit tests failing, which could happen +// when tests create new registries in which they register stores. +// +// @see https://github.com/WordPress/gutenberg/pull/51145#discussion_r1239999590 +unlock( store ).registerPrivateActions( privateActions ); +unlock( store ).registerPrivateSelectors( privateSelectors ); diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index 0a3484154e5a78..b5677975364d13 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -3,6 +3,9 @@ */ import { Platform } from '@wordpress/element'; +const castArray = ( maybeArray ) => + Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ]; + /** * A list of private/experimental block editor settings that * should not become a part of the WordPress public API. @@ -20,13 +23,15 @@ const privateSettings = [ * Action that updates the block editor settings and * conditionally preserves the experimental ones. * - * @param {Object} settings Updated settings - * @param {boolean} stripExperimentalSettings Whether to strip experimental settings. + * @param {Object} settings Updated settings + * @param {Object} options Options object. + * @param {boolean} options.stripExperimentalSettings Whether to strip experimental settings. + * @param {boolean} options.reset Whether to reset the settings. * @return {Object} Action object */ export function __experimentalUpdateSettings( settings, - stripExperimentalSettings = false + { stripExperimentalSettings = false, reset = false } = {} ) { let cleanSettings = settings; // There are no plugins in the mobile apps, so there is no @@ -42,6 +47,7 @@ export function __experimentalUpdateSettings( return { type: 'UPDATE_SETTINGS', settings: cleanSettings, + reset, }; } @@ -68,40 +74,188 @@ export function showBlockInterface() { } /** - * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode + * Yields action objects used in signalling that the blocks corresponding to + * the set of specified client IDs are to be removed. + * + * Compared to `removeBlocks`, this private interface exposes an additional + * parameter; see `forceRemove`. + * + * @param {string|string[]} clientIds Client IDs of blocks to remove. + * @param {boolean} selectPrevious True if the previous block + * or the immediate parent + * (if no previous block exists) + * should be selected + * when a block is removed. + * @param {boolean} forceRemove Whether to force the operation, + * bypassing any checks for certain + * block types. + */ +export const privateRemoveBlocks = + ( clientIds, selectPrevious = true, forceRemove = false ) => + ( { select, dispatch } ) => { + if ( ! clientIds || ! clientIds.length ) { + return; + } + + clientIds = castArray( clientIds ); + const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] ); + const canRemoveBlocks = select.canRemoveBlocks( + clientIds, + rootClientId + ); + + if ( ! canRemoveBlocks ) { + return; + } + + // In certain editing contexts, we'd like to prevent accidental removal + // of important blocks. For example, in the site editor, the Query Loop + // block is deemed important. In such cases, we'll ask the user for + // confirmation that they intended to remove such block(s). However, + // the editor instance is responsible for presenting those confirmation + // prompts to the user. Any instance opting into removal prompts must + // register using `setBlockRemovalRules()`. + // + // @see https://github.com/WordPress/gutenberg/pull/51145 + const rules = ! forceRemove && select.getBlockRemovalRules(); + if ( rules ) { + const blockNamesForPrompt = new Set(); + + // Given a list of client IDs of blocks that the user intended to + // remove, perform a tree search (BFS) to find all block names + // corresponding to "important" blocks, i.e. blocks that require a + // removal prompt. + const queue = [ ...clientIds ]; + while ( queue.length ) { + const clientId = queue.shift(); + const blockName = select.getBlockName( clientId ); + if ( rules[ blockName ] ) { + blockNamesForPrompt.add( blockName ); + } + const innerBlocks = select.getBlockOrder( clientId ); + queue.push( ...innerBlocks ); + } + + // If any such blocks were found, trigger the removal prompt and + // skip any other steps (thus postponing actual removal). + if ( blockNamesForPrompt.size ) { + dispatch( + displayBlockRemovalPrompt( + clientIds, + selectPrevious, + Array.from( blockNamesForPrompt ) + ) + ); + return; + } + } + + if ( selectPrevious ) { + dispatch.selectPreviousBlock( clientIds[ 0 ], selectPrevious ); + } + + dispatch( { type: 'REMOVE_BLOCKS', clientIds } ); + + // To avoid a focus loss when removing the last block, assure there is + // always a default block if the last of the blocks have been removed. + dispatch( ensureDefaultBlock() ); + }; + +/** + * Action which will insert a default block insert action if there + * are no other blocks at the root of the editor. This action should be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). */ +export const ensureDefaultBlock = + () => + ( { select, dispatch } ) => { + // To avoid a focus loss when removing the last block, assure there is + // always a default block if the last of the blocks have been removed. + const count = select.getBlockCount(); + if ( count > 0 ) { + return; + } + + // If there's an custom appender, don't insert default block. + // We have to remember to manually move the focus elsewhere to + // prevent it from being lost though. + const { __unstableHasCustomAppender } = select.getSettings(); + if ( __unstableHasCustomAppender ) { + return; + } + + dispatch.insertDefaultBlock(); + }; /** - * Sets the block editing mode for a given block. + * Returns an action object used in signalling that a block removal prompt must + * be displayed. + * + * Contrast with `setBlockRemovalRules`. * - * @see useBlockEditingMode + * @param {string|string[]} clientIds Client IDs of blocks to remove. + * @param {boolean} selectPrevious True if the previous block + * or the immediate parent + * (if no previous block exists) + * should be selected + * when a block is removed. + * @param {string[]} blockNamesForPrompt Names of the blocks that + * triggered the need for + * confirmation before removal. * - * @param {string} clientId The block client ID, or `''` for the root container. - * @param {BlockEditingMode} mode The block editing mode. One of `'disabled'`, - * `'contentOnly'`, or `'default'`. + * @return {Object} Action object. + */ +function displayBlockRemovalPrompt( + clientIds, + selectPrevious, + blockNamesForPrompt +) { + return { + type: 'DISPLAY_BLOCK_REMOVAL_PROMPT', + clientIds, + selectPrevious, + blockNamesForPrompt, + }; +} + +/** + * Returns an action object used in signalling that a block removal prompt must + * be cleared, either be cause the user has confirmed or canceled the request + * for removal. * * @return {Object} Action object. */ -export function setBlockEditingMode( clientId = '', mode ) { +export function clearBlockRemovalPrompt() { return { - type: 'SET_BLOCK_EDITING_MODE', - clientId, - mode, + type: 'CLEAR_BLOCK_REMOVAL_PROMPT', }; } /** - * Clears the block editing mode for a given block. + * Returns an action object used to set up any rules that a block editor may + * provide in order to prevent a user from accidentally removing certain + * blocks. These rules are then used to display a confirmation prompt to the + * user. For instance, in the Site Editor, the Query Loop block is important + * enough to warrant such confirmation. + * + * IMPORTANT: Registering rules implicitly signals to the `privateRemoveBlocks` + * action that the editor will be responsible for displaying block removal + * prompts and confirming deletions. This action is meant to be used by + * component `BlockRemovalWarningModal` only. * - * @see useBlockEditingMode + * The data is a record whose keys are block types (e.g. 'core/query') and + * whose values are the explanation to be shown to users (e.g. 'Query Loop + * displays a list of posts or pages.'). * - * @param {string} clientId The block client ID, or `''` for the root container. + * Contrast with `displayBlockRemovalPrompt`. * + * @param {Record|false} rules Block removal rules. * @return {Object} Action object. */ -export function unsetBlockEditingMode( clientId = '' ) { +export function setBlockRemovalRules( rules = false ) { return { - type: 'UNSET_BLOCK_EDITING_MODE', - clientId, + type: 'SET_BLOCK_REMOVAL_RULES', + rules, }; } diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index ce7802036184e5..13128150a11f26 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -3,19 +3,13 @@ */ import createSelector from 'rememo'; -/** - * WordPress dependencies - */ -import { createRegistrySelector } from '@wordpress/data'; -import { store as blocksStore } from '@wordpress/blocks'; - /** * Internal dependencies */ import { - getBlockRootClientId, - getTemplateLock, - getBlockName, + getBlockOrder, + getBlockParents, + getBlockEditingMode, } from './selectors'; /** @@ -40,76 +34,108 @@ export function getLastInsertedBlocksClientIds( state ) { } /** - * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode + * Returns true if the block with the given client ID and all of its descendants + * have an editing mode of 'disabled', or false otherwise. + * + * @param {Object} state Global application state. + * @param {string} clientId The block client ID. + * + * @return {boolean} Whether the block and its descendants are disabled. */ +export const isBlockSubtreeDisabled = createSelector( + ( state, clientId ) => { + const isChildSubtreeDisabled = ( childClientId ) => { + const mode = state.blockEditingModes.get( childClientId ); + return ( + ( mode === undefined || mode === 'disabled' ) && + getBlockOrder( state, childClientId ).every( + isChildSubtreeDisabled + ) + ); + }; + return ( + getBlockEditingMode( state, clientId ) === 'disabled' && + getBlockOrder( state, clientId ).every( isChildSubtreeDisabled ) + ); + }, + ( state ) => [ state.blockEditingModes, state.blocks.parents ] +); /** - * Returns the block editing mode for a given block. + * Returns a tree of block objects with only clientID and innerBlocks set. + * Blocks with a 'disabled' editing mode are not included. * - * The mode can be one of three options: + * @param {Object} state Global application state. + * @param {?string} rootClientId Optional root client ID of block list. * - * - `'disabled'`: Prevents editing the block entirely, i.e. it cannot be - * selected. - * - `'contentOnly'`: Hides all non-content UI, e.g. auxiliary controls in the - * toolbar, the block movers, block settings. - * - `'default'`: Allows editing the block as normal. + * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. + */ +export const getEnabledClientIdsTree = createSelector( + ( state, rootClientId = '' ) => { + return getBlockOrder( state, rootClientId ).flatMap( ( clientId ) => { + if ( getBlockEditingMode( state, clientId ) !== 'disabled' ) { + return [ + { + clientId, + innerBlocks: getEnabledClientIdsTree( state, clientId ), + }, + ]; + } + return getEnabledClientIdsTree( state, clientId ); + } ); + }, + ( state ) => [ + state.blocks.order, + state.blockEditingModes, + state.settings.templateLock, + state.blockListSettings, + ] +); + +/** + * Returns a list of a given block's ancestors, from top to bottom. Blocks with + * a 'disabled' editing mode are excluded. * - * Blocks can set a mode using the `useBlockEditingMode` hook. + * @see getBlockParents * - * The mode is inherited by all of the block's inner blocks, unless they have - * their own mode. + * @param {Object} state Global application state. + * @param {string} clientId The block client ID. + * @param {boolean} ascending Order results from bottom to top (true) or top + * to bottom (false). + */ +export const getEnabledBlockParents = createSelector( + ( state, clientId, ascending = false ) => { + return getBlockParents( state, clientId, ascending ).filter( + ( parent ) => getBlockEditingMode( state, parent ) !== 'disabled' + ); + }, + ( state ) => [ + state.blocks.parents, + state.blockEditingModes, + state.settings.templateLock, + state.blockListSettings, + ] +); + +/** + * Selector that returns the data needed to display a prompt when certain + * blocks are removed, or `false` if no such prompt is requested. * - * A template lock can also set a mode. If the template lock is `'contentOnly'`, - * the block's mode is overridden to `'contentOnly'` if the block has a content - * role attribute, or `'disabled'` otherwise. + * @param {Object} state Global application state. * - * @see useBlockEditingMode + * @return {Object|false} Data for removal prompt display, if any. + */ +export function getRemovalPromptData( state ) { + return state.removalPromptData; +} + +/** + * Returns true if removal prompt exists, or false otherwise. * - * @param {Object} state Global application state. - * @param {string} clientId The block client ID, or `''` for the root container. + * @param {Object} state Global application state. * - * @return {BlockEditingMode} The block editing mode. One of `'disabled'`, - * `'contentOnly'`, or `'default'`. + * @return {boolean} Whether removal prompt exists. */ -export const getBlockEditingMode = createRegistrySelector( - ( select ) => - ( state, clientId = '' ) => { - const explicitEditingMode = getExplcitBlockEditingMode( - state, - clientId - ); - const rootClientId = getBlockRootClientId( state, clientId ); - const templateLock = getTemplateLock( state, rootClientId ); - const name = getBlockName( state, clientId ); - const isContent = - select( blocksStore ).__experimentalHasContentRoleAttribute( - name - ); - if ( - explicitEditingMode === 'disabled' || - ( templateLock === 'contentOnly' && ! isContent ) - ) { - return 'disabled'; - } - if ( - explicitEditingMode === 'contentOnly' || - ( templateLock === 'contentOnly' && isContent ) - ) { - return 'contentOnly'; - } - return 'default'; - } -); - -const getExplcitBlockEditingMode = createSelector( - ( state, clientId = '' ) => { - while ( - ! state.blockEditingModes.has( clientId ) && - state.blocks.parents.has( clientId ) - ) { - clientId = state.blocks.parents.get( clientId ); - } - return state.blockEditingModes.get( clientId ) ?? 'default'; - }, - ( state ) => [ state.blockEditingModes, state.blocks.parents ] -); +export function getBlockRemovalRules( state ) { + return state.blockRemovalRules; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index b316bbeb5079e1..245aaf7adb0fdf 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1469,6 +1469,56 @@ export function isSelectionEnabled( state = true, action ) { return state; } +/** + * Reducer returning the data needed to display a prompt when certain blocks + * are removed, or `false` if no such prompt is requested. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object|false} Data for removal prompt display, if any. + */ +function removalPromptData( state = false, action ) { + switch ( action.type ) { + case 'DISPLAY_BLOCK_REMOVAL_PROMPT': + const { clientIds, selectPrevious, blockNamesForPrompt } = action; + return { + clientIds, + selectPrevious, + blockNamesForPrompt, + }; + case 'CLEAR_BLOCK_REMOVAL_PROMPT': + return false; + } + + return state; +} + +/** + * Reducer returning any rules that a block editor may provide in order to + * prevent a user from accidentally removing certain blocks. These rules are + * then used to display a confirmation prompt to the user. For instance, in the + * Site Editor, the Query Loop block is important enough to warrant such + * confirmation. + * + * The data is a record whose keys are block types (e.g. 'core/query') and + * whose values are the explanation to be shown to users (e.g. 'Query Loop + * displays a list of posts or pages.'). + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +function blockRemovalRules( state = false, action ) { + switch ( action.type ) { + case 'SET_BLOCK_REMOVAL_RULES': + return action.rules; + } + + return state; +} + /** * Reducer returning the initial block selection. * @@ -1581,6 +1631,12 @@ export function template( state = { isValid: true }, action ) { export function settings( state = SETTINGS_DEFAULTS, action ) { switch ( action.type ) { case 'UPDATE_SETTINGS': + if ( action.reset ) { + return { + ...SETTINGS_DEFAULTS, + ...action.settings, + }; + } return { ...state, ...action.settings, @@ -1604,18 +1660,17 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { case 'REPLACE_BLOCKS': return action.blocks.reduce( ( prevState, block ) => { const { attributes, name: blockName } = block; + let id = blockName; + // If a block variation match is found change the name to be the same with the + // one that is used for block variations in the Inserter (`getItemFromVariation`). const match = select( blocksStore ).getActiveBlockVariation( blockName, attributes ); - // If a block variation match is found change the name to be the same with the - // one that is used for block variations in the Inserter (`getItemFromVariation`). - let id = match?.name - ? `${ blockName }/${ match.name }` - : blockName; - const insert = { name: id }; + if ( match?.name ) { + id += '/' + match.name; + } if ( blockName === 'core/block' ) { - insert.ref = attributes.ref; id += '/' + attributes.ref; } @@ -1628,7 +1683,6 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { count: prevState.insertUsage[ id ] ? prevState.insertUsage[ id ].count + 1 : 1, - insert, }, }, }; @@ -1801,7 +1855,6 @@ export function lastBlockInserted( state = {}, action ) { switch ( action.type ) { case 'INSERT_BLOCKS': case 'REPLACE_BLOCKS': - case 'REPLACE_INNER_BLOCKS': if ( ! action.blocks.length ) { return state; } @@ -1883,6 +1936,8 @@ const combinedReducers = combineReducers( { temporarilyEditingAsBlocks, blockVisibility, blockEditingModes, + removalPromptData, + blockRemovalRules, } ); function withAutomaticChangeReset( reducer ) { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 487d13db811d84..3c961c130b78a2 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -14,12 +14,14 @@ import { getPossibleBlockTransformations, parse, switchToBlockType, + store as blocksStore, } from '@wordpress/blocks'; import { Platform } from '@wordpress/element'; import { applyFilters } from '@wordpress/hooks'; import { symbol } from '@wordpress/icons'; import { create, remove, toHTMLString } from '@wordpress/rich-text'; import deprecated from '@wordpress/deprecated'; +import { createRegistrySelector } from '@wordpress/data'; /** * Internal dependencies @@ -186,16 +188,27 @@ export function getBlocks( state, rootClientId ) { * Returns a stripped down block object containing only its client ID, * and its inner blocks' client IDs. * + * @deprecated + * * @param {Object} state Editor state. * @param {string} clientId Client ID of the block to get. * * @return {Object} Client IDs of the post blocks. */ export const __unstableGetClientIdWithClientIdsTree = createSelector( - ( state, clientId ) => ( { - clientId, - innerBlocks: __unstableGetClientIdsTree( state, clientId ), - } ), + ( state, clientId ) => { + deprecated( + "wp.data.select( 'core/block-editor' ).__unstableGetClientIdWithClientIdsTree", + { + since: '6.3', + version: '6.5', + } + ); + return { + clientId, + innerBlocks: __unstableGetClientIdsTree( state, clientId ), + }; + }, ( state ) => [ state.blocks.order ] ); @@ -204,16 +217,26 @@ export const __unstableGetClientIdWithClientIdsTree = createSelector( * given root, consisting of stripped down block objects containing only * their client IDs, and their inner blocks' client IDs. * + * @deprecated + * * @param {Object} state Editor state. * @param {?string} rootClientId Optional root client ID of block list. * * @return {Object[]} Client IDs of the post blocks. */ export const __unstableGetClientIdsTree = createSelector( - ( state, rootClientId = '' ) => - getBlockOrder( state, rootClientId ).map( ( clientId ) => + ( state, rootClientId = '' ) => { + deprecated( + "wp.data.select( 'core/block-editor' ).__unstableGetClientIdsTree", + { + since: '6.3', + version: '6.5', + } + ); + return getBlockOrder( state, rootClientId ).map( ( clientId ) => __unstableGetClientIdWithClientIdsTree( state, clientId ) - ), + ); + }, ( state ) => [ state.blocks.order ] ); @@ -302,10 +325,13 @@ export const __experimentalGetGlobalBlocksByName = createSelector( if ( ! blockName ) { return EMPTY_ARRAY; } + const blockNames = Array.isArray( blockName ) + ? blockName + : [ blockName ]; const clientIds = getClientIdsWithDescendants( state ); const foundBlocks = clientIds.filter( ( clientId ) => { const block = state.blocks.byClientId.get( clientId ); - return block.name === blockName; + return blockNames.includes( block.name ); } ); return foundBlocks.length > 0 ? foundBlocks : EMPTY_ARRAY; }, @@ -535,18 +561,10 @@ export const getBlockParents = createSelector( export const getBlockParentsByBlockName = createSelector( ( state, clientId, blockName, ascending = false ) => { const parents = getBlockParents( state, clientId, ascending ); - return parents - .map( ( id ) => ( { - id, - name: getBlockName( state, id ), - } ) ) - .filter( ( { name } ) => { - if ( Array.isArray( blockName ) ) { - return blockName.includes( name ); - } - return name === blockName; - } ) - .map( ( { id } ) => id ); + const hasName = Array.isArray( blockName ) + ? ( name ) => blockName.includes( name ) + : ( name ) => blockName === name; + return parents.filter( ( id ) => hasName( getBlockName( state, id ) ) ); }, ( state ) => [ state.blocks.parents ] ); @@ -1539,6 +1557,10 @@ const canInsertBlockTypeUnmemoized = ( return false; } + if ( getBlockEditingMode( state, rootClientId ?? '' ) === 'disabled' ) { + return false; + } + const parentBlockListSettings = getBlockListSettings( state, rootClientId ); // The parent block doesn't have settings indicating it doesn't support @@ -1633,6 +1655,7 @@ export const canInsertBlockType = createSelector( state.blocks.byClientId.get( rootClientId ), state.settings.allowedBlockTypes, state.settings.templateLock, + state.blockEditingModes, ] ); @@ -1663,21 +1686,17 @@ export function canInsertBlocks( state, clientIds, rootClientId = null ) { */ export function canRemoveBlock( state, clientId, rootClientId = null ) { const attributes = getBlockAttributes( state, clientId ); - - // attributes can be null if the block is already deleted. if ( attributes === null ) { return true; } - - const { lock } = attributes; - const parentIsLocked = !! getTemplateLock( state, rootClientId ); - // If we don't have a lock on the blockType level, we defer to the parent templateLock. - if ( lock === undefined || lock?.remove === undefined ) { - return ! parentIsLocked; + if ( attributes.lock?.remove !== undefined ) { + return ! attributes.lock.remove; + } + if ( getTemplateLock( state, rootClientId ) ) { + return false; } - // When remove is true, it means we cannot remove it. - return ! lock?.remove; + return getBlockEditingMode( state, rootClientId ) !== 'disabled'; } /** @@ -1707,18 +1726,16 @@ export function canRemoveBlocks( state, clientIds, rootClientId = null ) { export function canMoveBlock( state, clientId, rootClientId = null ) { const attributes = getBlockAttributes( state, clientId ); if ( attributes === null ) { - return; + return true; } - - const { lock } = attributes; - const parentIsLocked = getTemplateLock( state, rootClientId ) === 'all'; - // If we don't have a lock on the blockType level, we defer to the parent templateLock. - if ( lock === undefined || lock?.move === undefined ) { - return ! parentIsLocked; + if ( attributes.lock?.move !== undefined ) { + return ! attributes.lock.move; + } + if ( getTemplateLock( state, rootClientId ) === 'all' ) { + return false; } - // When move is true, it means we cannot move it. - return ! lock?.move; + return getBlockEditingMode( state, rootClientId ) !== 'disabled'; } /** @@ -1946,55 +1963,13 @@ const buildBlockTypeItem = */ export const getInserterItems = createSelector( ( state, rootClientId = null ) => { - const buildBlockTypeInserterItem = buildBlockTypeItem( state, { - buildScope: 'inserter', - } ); - - /* - * Matches block comment delimiters amid serialized content. - * - * @see `tokenizer` in `@wordpress/block-serialization-default-parser` - * package - * - * blockParserTokenizer differs from the original tokenizer in the - * following ways: - * - * - removed global flag (/g) - * - prepended ^\s* - * - */ - const blockParserTokenizer = - /^\s*)[^])*)\5|[^]*?)}\s+)?(\/)?-->/; - const buildReusableBlockInserterItem = ( reusableBlock ) => { - let icon = symbol; - - /* - * Instead of always displaying a generic "symbol" icon for every - * reusable block, try to use an icon that represents the first - * outermost block contained in the reusable block. This requires - * scanning the serialized form of the reusable block to find its - * first block delimiter, then looking up the corresponding block - * type, if available. - */ - if ( Platform.OS === 'web' ) { - const content = - typeof reusableBlock.content.raw === 'string' - ? reusableBlock.content.raw - : reusableBlock.content; - const rawBlockMatch = content.match( blockParserTokenizer ); - if ( rawBlockMatch ) { - const [ , , namespace = 'core/', blockName ] = - rawBlockMatch; - const referencedBlockType = getBlockType( - namespace + blockName - ); - if ( referencedBlockType ) { - icon = referencedBlockType.icon; - } - } - } - + const icon = ! reusableBlock.wp_pattern_sync_status + ? { + src: symbol, + foreground: 'var(--wp-block-synced-color)', + } + : symbol; const id = `core/block/${ reusableBlock.id }`; const { time, count = 0 } = getInsertUsage( state, id ) || {}; const frecency = calculateFrecency( time, count ); @@ -2003,23 +1978,19 @@ export const getInserterItems = createSelector( id, name: 'core/block', initialAttributes: { ref: reusableBlock.id }, - title: reusableBlock.title.raw, + title: reusableBlock.title?.raw, icon, category: 'reusable', - keywords: [], + keywords: [ 'reusable' ], isDisabled: false, utility: 1, // Deprecated. frecency, + content: reusableBlock.content.raw, + syncStatus: reusableBlock.wp_pattern_sync_status, }; }; - const blockTypeInserterItems = getBlockTypes() - .filter( ( blockType ) => - canIncludeBlockTypeInInserter( state, blockType, rootClientId ) - ) - .map( buildBlockTypeInserterItem ); - - const reusableBlockInserterItems = canInsertBlockTypeUnmemoized( + const syncedPatternInserterItems = canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId @@ -2027,6 +1998,16 @@ export const getInserterItems = createSelector( ? getReusableBlocks( state ).map( buildReusableBlockInserterItem ) : []; + const buildBlockTypeInserterItem = buildBlockTypeItem( state, { + buildScope: 'inserter', + } ); + + const blockTypeInserterItems = getBlockTypes() + .filter( ( blockType ) => + canIncludeBlockTypeInInserter( state, blockType, rootClientId ) + ) + .map( buildBlockTypeInserterItem ); + const items = blockTypeInserterItems.reduce( ( accumulator, item ) => { const { variations = [] } = item; // Exclude any block type item that is to be replaced by a default variation. @@ -2057,7 +2038,7 @@ export const getInserterItems = createSelector( { core: [], noncore: [] } ); const sortedBlockTypes = [ ...coreItems, ...nonCoreItems ]; - return [ ...sortedBlockTypes, ...reusableBlockInserterItems ]; + return [ ...sortedBlockTypes, ...syncedPatternInserterItems ]; }, ( state, rootClientId ) => [ state.blockListSettings[ rootClientId ], @@ -2186,15 +2167,24 @@ export const getAllowedBlocks = createSelector( return; } - return getBlockTypes().filter( ( blockType ) => + const blockTypes = getBlockTypes().filter( ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) ); + const hasReusableBlock = + canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId ) && + getReusableBlocks( state ).length > 0; + + return [ + ...blockTypes, + ...( hasReusableBlock ? [ 'core/block' ] : [] ), + ]; }, ( state, rootClientId ) => [ state.blockListSettings[ rootClientId ], state.blocks.byClientId, state.settings.allowedBlockTypes, state.settings.templateLock, + getReusableBlocks( state ), getBlockTypes(), ] ); @@ -2230,15 +2220,15 @@ export const __experimentalGetAllowedBlocks = createSelector( * @property {?Object} attributes Attributes to pass to the newly created block. * @property {?Array} attributesToCopy Attributes to be copied from adjecent blocks when inserted. */ -export const __experimentalGetDirectInsertBlock = createSelector( +export const getDirectInsertBlock = createSelector( ( state, rootClientId = null ) => { if ( ! rootClientId ) { return; } const defaultBlock = - state.blockListSettings[ rootClientId ]?.__experimentalDefaultBlock; + state.blockListSettings[ rootClientId ]?.defaultBlock; const directInsert = - state.blockListSettings[ rootClientId ]?.__experimentalDirectInsert; + state.blockListSettings[ rootClientId ]?.directInsert; if ( ! defaultBlock || ! directInsert ) { return; } @@ -2255,6 +2245,25 @@ export const __experimentalGetDirectInsertBlock = createSelector( ] ); +export const __experimentalGetDirectInsertBlock = createSelector( + ( state, rootClientId = null ) => { + deprecated( + 'wp.data.select( "core/block-editor" ).__experimentalGetDirectInsertBlock', + { + alternative: + 'wp.data.select( "core/block-editor" ).getDirectInsertBlock', + since: '6.3', + version: '6.4', + } + ); + return getDirectInsertBlock( state, rootClientId ); + }, + ( state, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.tree.get( rootClientId ), + ] +); + const checkAllowListRecursive = ( blocks, allowedBlockTypes ) => { if ( typeof allowedBlockTypes === 'boolean' ) { return allowedBlockTypes; @@ -2281,10 +2290,33 @@ const checkAllowListRecursive = ( blocks, allowedBlockTypes ) => { return true; }; +function getUnsyncedPatterns( state ) { + const reusableBlocks = + state?.settings?.__experimentalReusableBlocks ?? EMPTY_ARRAY; + + return reusableBlocks + .filter( + ( reusableBlock ) => + reusableBlock.wp_pattern_sync_status === 'unsynced' + ) + .map( ( reusableBlock ) => { + return { + name: `core/block/${ reusableBlock.id }`, + title: reusableBlock.title.raw, + categories: [ 'custom' ], + content: reusableBlock.content.raw, + }; + } ); +} + export const __experimentalGetParsedPattern = createSelector( ( state, patternName ) => { const patterns = state.settings.__experimentalBlockPatterns; - const pattern = patterns.find( ( { name } ) => name === patternName ); + const unsyncedPatterns = getUnsyncedPatterns( state ); + + const pattern = [ ...patterns, ...unsyncedPatterns ].find( + ( { name } ) => name === patternName + ); if ( ! pattern ) { return null; } @@ -2295,14 +2327,20 @@ export const __experimentalGetParsedPattern = createSelector( } ), }; }, - ( state ) => [ state.settings.__experimentalBlockPatterns ] + ( state ) => [ + state.settings.__experimentalBlockPatterns, + state.settings.__experimentalReusableBlocks, + ] ); const getAllAllowedPatterns = createSelector( ( state ) => { const patterns = state.settings.__experimentalBlockPatterns; + const unsyncedPatterns = getUnsyncedPatterns( state ); + const { allowedBlockTypes } = getSettings( state ); - const parsedPatterns = patterns + + const parsedPatterns = [ ...patterns, ...unsyncedPatterns ] .filter( ( { inserter = true } ) => !! inserter ) .map( ( { name } ) => __experimentalGetParsedPattern( state, name ) @@ -2314,6 +2352,7 @@ const getAllAllowedPatterns = createSelector( }, ( state ) => [ state.settings.__experimentalBlockPatterns, + state.settings.__experimentalReusableBlocks, state.settings.allowedBlockTypes, ] ); @@ -2340,6 +2379,7 @@ export const __experimentalGetAllowedPatterns = createSelector( }, ( state, rootClientId ) => [ state.settings.__experimentalBlockPatterns, + state.settings.__experimentalReusableBlocks, state.settings.allowedBlockTypes, state.settings.templateLock, state.blockListSettings[ rootClientId ], @@ -2490,30 +2530,6 @@ export function getSettings( state ) { return state.settings; } -/** - * Returns the behaviors registered with the editor. - * - * Behaviors are named, reusable pieces of functionality that can be - * attached to blocks. They are registered with the editor using the - * `theme.json` file. - * - * @example - * - * ```js - * const behaviors = select( blockEditorStore ).getBehaviors(); - * if ( behaviors?.lightbox ) { - * // Do something with the lightbox. - * } - *``` - * - * @param {Object} state Editor state. - * - * @return {Object} The editor behaviors object. - */ -export function getBehaviors( state ) { - return state.settings.behaviors; -} - /** * Returns true if the most recent block change is be considered persistent, or * false otherwise. A persistent change is one committed by BlockEditorProvider @@ -2812,6 +2828,14 @@ export function __unstableGetTemporarilyEditingAsBlocks( state ) { } export function __unstableHasActiveBlockOverlayActive( state, clientId ) { + // Prevent overlay on blocks with a non-default editing mode. If the mdoe is + // 'disabled' then the overlay is redundant since the block can't be + // selected. If the mode is 'contentOnly' then the overlay is redundant + // since there will be no controls to interact with once selected. + if ( getBlockEditingMode( state, clientId ) !== 'default' ) { + return false; + } + // If the block editing is locked, the block overlay is always active. if ( ! canEditBlock( state, clientId ) ) { return true; @@ -2861,3 +2885,59 @@ export function __unstableIsWithinBlockOverlay( state, clientId ) { } return false; } + +/** + * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode + */ + +/** + * Returns the block editing mode for a given block. + * + * The mode can be one of three options: + * + * - `'disabled'`: Prevents editing the block entirely, i.e. it cannot be + * selected. + * - `'contentOnly'`: Hides all non-content UI, e.g. auxiliary controls in the + * toolbar, the block movers, block settings. + * - `'default'`: Allows editing the block as normal. + * + * Blocks can set a mode using the `useBlockEditingMode` hook. + * + * The mode is inherited by all of the block's inner blocks, unless they have + * their own mode. + * + * A template lock can also set a mode. If the template lock is `'contentOnly'`, + * the block's mode is overridden to `'contentOnly'` if the block has a content + * role attribute, or `'disabled'` otherwise. + * + * @see useBlockEditingMode + * + * @param {Object} state Global application state. + * @param {string} clientId The block client ID, or `''` for the root container. + * + * @return {BlockEditingMode} The block editing mode. One of `'disabled'`, + * `'contentOnly'`, or `'default'`. + */ +export const getBlockEditingMode = createRegistrySelector( + ( select ) => + ( state, clientId = '' ) => { + if ( state.blockEditingModes.has( clientId ) ) { + return state.blockEditingModes.get( clientId ); + } + if ( ! clientId ) { + return 'default'; + } + const rootClientId = getBlockRootClientId( state, clientId ); + const templateLock = getTemplateLock( state, rootClientId ); + if ( templateLock === 'contentOnly' ) { + const name = getBlockName( state, clientId ); + const isContent = + select( blocksStore ).__experimentalHasContentRoleAttribute( + name + ); + return isContent ? 'contentOnly' : 'disabled'; + } + const parentMode = getBlockEditingMode( state, rootClientId ); + return parentMode === 'contentOnly' ? 'default' : parentMode; + } +); diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 65a1eca96d5fe3..4dc9adefb82b57 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -53,6 +53,9 @@ const { updateBlockListSettings, updateSettings, validateBlocksToTemplate, + registerInserterMediaCategory, + setBlockEditingMode, + unsetBlockEditingMode, } = actions; describe( 'actions', () => { @@ -215,6 +218,7 @@ describe( 'actions', () => { getBlockCount: () => 1, }; const dispatch = jest.fn(); + dispatch.ensureDefaultBlock = jest.fn(); replaceBlock( 'chicken', block )( { select, dispatch } ); @@ -280,6 +284,7 @@ describe( 'actions', () => { getBlockCount: () => 1, }; const dispatch = jest.fn(); + dispatch.ensureDefaultBlock = jest.fn(); replaceBlocks( [ 'chicken' ], blocks )( { select, dispatch } ); @@ -313,6 +318,7 @@ describe( 'actions', () => { getBlockCount: () => 1, }; const dispatch = jest.fn(); + dispatch.ensureDefaultBlock = jest.fn(); replaceBlocks( [ 'chicken' ], @@ -617,6 +623,7 @@ describe( 'actions', () => { const select = { getBlockRootClientId: () => undefined, canRemoveBlocks: () => true, + getBlockRemovalRules: () => false, }; const dispatch = Object.assign( jest.fn(), { selectPreviousBlock: jest.fn(), @@ -727,6 +734,7 @@ describe( 'actions', () => { const select = { getBlockRootClientId: () => null, canRemoveBlocks: () => true, + getBlockRemovalRules: () => false, }; const dispatch = Object.assign( jest.fn(), { selectPreviousBlock: jest.fn(), @@ -751,6 +759,7 @@ describe( 'actions', () => { const select = { getBlockRootClientId: () => null, canRemoveBlocks: () => true, + getBlockRemovalRules: () => false, }; const dispatch = Object.assign( jest.fn(), { selectPreviousBlock: jest.fn(), @@ -1209,4 +1218,137 @@ describe( 'actions', () => { expect( result ).toEqual( false ); } ); } ); + + describe( 'registerInserterMediaCategory', () => { + describe( 'should log errors when invalid', () => { + it( 'valid object', () => { + registerInserterMediaCategory()( {} ); + expect( console ).toHaveErroredWith( + 'Category should be an `InserterMediaCategory` object.' + ); + } ); + it( 'has name', () => { + registerInserterMediaCategory( {} )( {} ); + expect( console ).toHaveErroredWith( + 'Category should have a `name` that should be unique among all media categories.' + ); + } ); + it( 'has labels.name', () => { + registerInserterMediaCategory( { name: 'a' } )( {} ); + expect( console ).toHaveErroredWith( + 'Category should have a `labels.name`.' + ); + } ); + it( 'has proper media type', () => { + registerInserterMediaCategory( { + name: 'a', + labels: { name: 'a' }, + mediaType: 'b', + } )( {} ); + expect( console ).toHaveErroredWith( + 'Category should have `mediaType` property that is one of `image|audio|video`.' + ); + } ); + it( 'has fetch function', () => { + registerInserterMediaCategory( { + name: 'a', + labels: { name: 'a' }, + mediaType: 'image', + fetch: 'c', + } )( {} ); + expect( console ).toHaveErroredWith( + 'Category should have a `fetch` function defined with the following signature `(InserterMediaRequest) => Promise`.' + ); + } ); + it( 'has unique name', () => { + registerInserterMediaCategory( { + name: 'a', + labels: { name: 'a' }, + mediaType: 'image', + fetch: () => {}, + } )( { + select: { + getSettings: () => ( { + inserterMediaCategories: [ { name: 'a' } ], + } ), + }, + } ); + expect( console ).toHaveErroredWith( + 'A category is already registered with the same name: "a".' + ); + } ); + it( 'has unique labels.name', () => { + registerInserterMediaCategory( { + name: 'a', + labels: { name: 'a' }, + mediaType: 'image', + fetch: () => {}, + } )( { + select: { + getSettings: () => ( { + inserterMediaCategories: [ + { labels: { name: 'a' } }, + ], + } ), + }, + } ); + expect( console ).toHaveErroredWith( + 'A category is already registered with the same labels.name: "a".' + ); + } ); + } ); + it( 'should register a media category', () => { + const category = { + name: 'new', + labels: { name: 'new' }, + mediaType: 'image', + fetch: () => {}, + }; + const inserterMediaCategories = [ + { name: 'a', labels: { name: 'a' } }, + ]; + const dispatch = jest.fn(); + registerInserterMediaCategory( category )( { + select: { + getSettings: () => ( { inserterMediaCategories } ), + }, + dispatch, + } ); + expect( dispatch ).toHaveBeenLastCalledWith( { + type: 'UPDATE_SETTINGS', + settings: { + inserterMediaCategories: [ + ...inserterMediaCategories, + { ...category, isExternalResource: true }, + ], + }, + } ); + } ); + } ); + + describe( 'setBlockEditingMode', () => { + it( 'should return the SET_BLOCK_EDITING_MODE action', () => { + expect( + setBlockEditingMode( + '14501cc2-90a6-4f52-aa36-ab6e896135d1', + 'default' + ) + ).toEqual( { + type: 'SET_BLOCK_EDITING_MODE', + clientId: '14501cc2-90a6-4f52-aa36-ab6e896135d1', + mode: 'default', + } ); + } ); + } ); + + describe( 'unsetBlockEditingMode', () => { + it( 'should return the UNSET_BLOCK_EDITING_MODE action', () => { + expect( + unsetBlockEditingMode( '14501cc2-90a6-4f52-aa36-ab6e896135d1' ) + ).toEqual( { + type: 'UNSET_BLOCK_EDITING_MODE', + clientId: '14501cc2-90a6-4f52-aa36-ab6e896135d1', + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js index fdfe993091fef7..6a9593493fbc4d 100644 --- a/packages/block-editor/src/store/test/private-actions.js +++ b/packages/block-editor/src/store/test/private-actions.js @@ -4,8 +4,7 @@ import { hideBlockInterface, showBlockInterface, - setBlockEditingMode, - unsetBlockEditingMode, + __experimentalUpdateSettings, } from '../private-actions'; describe( 'private actions', () => { @@ -25,28 +24,57 @@ describe( 'private actions', () => { } ); } ); - describe( 'setBlockEditingMode', () => { - it( 'should return the SET_BLOCK_EDITING_MODE action', () => { + describe( '__experimentalUpdateSettings', () => { + const experimentalSettings = { + inserterMediaCategories: 'foo', + blockInspectorAnimation: 'bar', + }; + + const stableSettings = { + foo: 'foo', + bar: 'bar', + baz: 'baz', + }; + + const settings = { + ...experimentalSettings, + ...stableSettings, + }; + + it( 'should dispatch provided settings by default', () => { + expect( __experimentalUpdateSettings( settings ) ).toEqual( { + type: 'UPDATE_SETTINGS', + settings, + reset: false, + } ); + } ); + + it( 'should dispatch provided settings with reset flag when `reset` argument is truthy', () => { expect( - setBlockEditingMode( - '14501cc2-90a6-4f52-aa36-ab6e896135d1', - 'default' - ) + __experimentalUpdateSettings( settings, { + stripExperimentalSettings: false, + reset: true, + } ) ).toEqual( { - type: 'SET_BLOCK_EDITING_MODE', - clientId: '14501cc2-90a6-4f52-aa36-ab6e896135d1', - mode: 'default', + type: 'UPDATE_SETTINGS', + settings, + reset: true, } ); } ); - } ); - describe( 'unsetBlockEditingMode', () => { - it( 'should return the UNSET_BLOCK_EDITING_MODE action', () => { + it( 'should strip experimental settings from a given settings object when `stripExperimentalSettings` argument is truthy', () => { expect( - unsetBlockEditingMode( '14501cc2-90a6-4f52-aa36-ab6e896135d1' ) + __experimentalUpdateSettings( settings, { + stripExperimentalSettings: true, + } ) ).toEqual( { - type: 'UNSET_BLOCK_EDITING_MODE', - clientId: '14501cc2-90a6-4f52-aa36-ab6e896135d1', + type: 'UPDATE_SETTINGS', + settings: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + }, + reset: false, } ); } ); } ); diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 954c8c94c13799..746a51b6031101 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -4,8 +4,11 @@ import { isBlockInterfaceHidden, getLastInsertedBlocksClientIds, - getBlockEditingMode, + isBlockSubtreeDisabled, + getEnabledClientIdsTree, + getEnabledBlockParents, } from '../private-selectors'; +import { getBlockEditingMode } from '../selectors'; describe( 'private selectors', () => { describe( 'isBlockInterfaceHidden', () => { @@ -51,7 +54,7 @@ describe( 'private selectors', () => { } ); } ); - describe( 'getBlockEditingMode', () => { + describe( 'isBlockSubtreeDisabled', () => { const baseState = { settings: {}, blocks: { @@ -63,6 +66,33 @@ describe( 'private selectors', () => { [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', {} ], // | | Paragraph [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', {} ], // | | Paragraph ] ), + order: new Map( [ + [ + '', + [ + '6cf70164-9097-4460-bcbf-200560546988', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + ], + [ '6cf70164-9097-4460-bcbf-200560546988', [] ], + [ + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + ], + [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', [] ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + [ + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ], + ], + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', [] ], + [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', [] ], + ] ), parents: new Map( [ [ '6cf70164-9097-4460-bcbf-200560546988', '' ], [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', '' ], @@ -98,113 +128,353 @@ describe( 'private selectors', () => { } ) ), }; - it( 'should return default by default', () => { + it( 'should return false when top level block is not disabled', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [] ), + }; expect( - getBlockEditingMode( - baseState, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + isBlockSubtreeDisabled( + state, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ) - ).toBe( 'default' ); + ).toBe( false ); } ); - [ 'disabled', 'contentOnly' ].forEach( ( mode ) => { - it( `should return ${ mode } if explicitly set`, () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', mode ], - ] ), - }; - expect( - getBlockEditingMode( - state, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' - ) - ).toBe( mode ); - } ); - - it( `should return ${ mode } if explicitly set on a parent`, () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', mode ], - ] ), - }; - expect( - getBlockEditingMode( - state, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' - ) - ).toBe( mode ); - } ); - - it( `should return ${ mode } if overridden by a parent`, () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ - [ '', mode ], - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'default' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', mode ], - ] ), - }; - expect( - getBlockEditingMode( - state, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' - ) - ).toBe( mode ); - } ); - - it( `should return ${ mode } if explicitly set on root`, () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ [ '', mode ] ] ), - }; - expect( - getBlockEditingMode( - state, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' - ) - ).toBe( mode ); - } ); - } ); - - it( 'should return disabled if parent is locked and the block has no content role', () => { + it( 'should return true when top level block is disabled and there are no editing modes within it', () => { const state = { ...baseState, - blockListSettings: { - ...baseState.blockListSettings, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': { - templateLock: 'contentOnly', - }, + blockEditingModes: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], + ] ), + }; + expect( + isBlockSubtreeDisabled( + state, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' + ) + ).toBe( true ); + } ); + + it( 'should return true when top level block is disabled via inheritence and there are no editing modes within it', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ [ '', 'disabled' ] ] ), + }; + expect( + isBlockSubtreeDisabled( + state, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' + ) + ).toBe( true ); + } ); + + it( 'should return true when top level block is disabled and there are disabled editing modes within it', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'disabled' ], + ] ), + }; + expect( + isBlockSubtreeDisabled( + state, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' + ) + ).toBe( true ); + } ); + + it( 'should return false when top level block is disabled and there are non-disabled editing modes within it', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'default' ], + ] ), + }; + expect( + isBlockSubtreeDisabled( + state, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' + ) + ).toBe( false ); + } ); + + it( 'should return false when top level block is disabled via inheritence and there are non-disabled editing modes within it', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'default' ], + ] ), + }; + expect( + isBlockSubtreeDisabled( + state, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' + ) + ).toBe( false ); + } ); + } ); + + describe( 'getEnabledClientIdsTree', () => { + const baseState = { + 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 + ] ), + order: new Map( [ + [ + '', + [ + '6cf70164-9097-4460-bcbf-200560546988', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + ], + [ '6cf70164-9097-4460-bcbf-200560546988', [] ], + [ + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + ], + [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', [] ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + [ + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ], + ], + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', [] ], + [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', [] ], + ] ), + parents: new Map( [ + [ '6cf70164-9097-4460-bcbf-200560546988', '' ], + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', '' ], + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + [ + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + ] ), + }, + blockListSettings: { + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337': {}, + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': {}, + }, + }; + + it( 'should return tree containing only clientId and innerBlocks', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [] ), + }; + expect( getEnabledClientIdsTree( state ) ).toEqual( [ + { + clientId: '6cf70164-9097-4460-bcbf-200560546988', + innerBlocks: [], + }, + { + clientId: 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + innerBlocks: [ + { + clientId: 'b26fc763-417d-4f01-b81c-2ec61e14a972', + innerBlocks: [], + }, + { + clientId: '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + innerBlocks: [ + { + clientId: + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + innerBlocks: [], + }, + { + clientId: + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + innerBlocks: [], + }, + ], + }, + ], }, + ] ); + } ); + + it( 'should return a subtree when rootBlockClientId is given', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [] ), }; - __experimentalHasContentRoleAttribute.mockReturnValueOnce( false ); expect( - getBlockEditingMode( + getEnabledClientIdsTree( state, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ) - ).toBe( 'disabled' ); + ).toEqual( [ + { + clientId: 'b26fc763-417d-4f01-b81c-2ec61e14a972', + innerBlocks: [], + }, + { + clientId: '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + innerBlocks: [ + { + clientId: 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + innerBlocks: [], + }, + { + clientId: 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + innerBlocks: [], + }, + ], + }, + ] ); } ); - it( 'should return contentOnly if parent is locked and the block has a content role', () => { + it( 'should filter out disabled blocks', () => { const state = { ...baseState, - blockListSettings: { - ...baseState.blockListSettings, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': { - templateLock: 'contentOnly', - }, + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', 'contentOnly' ], + [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'contentOnly' ], + ] ), + }; + expect( getEnabledClientIdsTree( state ) ).toEqual( [ + { + clientId: 'b26fc763-417d-4f01-b81c-2ec61e14a972', + innerBlocks: [], + }, + { + clientId: '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + innerBlocks: [ + { + clientId: 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + innerBlocks: [], + }, + { + clientId: 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + innerBlocks: [], + }, + ], + }, + ] ); + } ); + } ); + + describe( 'getEnabledBlockParents', () => { + it( 'should return an empty array if block is at the root', () => { + const state = { + settings: {}, + blocks: { + parents: new Map( [ + [ '6cf70164-9097-4460-bcbf-200560546988', '' ], + ] ), + }, + blockEditingModes: new Map(), + }; + expect( + getEnabledBlockParents( + state, + '6cf70164-9097-4460-bcbf-200560546988' + ) + ).toEqual( [] ); + } ); + + it( 'should return non-disabled parents', () => { + const state = { + settings: {}, + blocks: { + parents: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', '' ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + [ + '4c2b7140-fffd-44b4-b2a7-820c670a6514', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ], + ] ), }, + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'default' ], + ] ), + blockListSettings: {}, }; - __experimentalHasContentRoleAttribute.mockReturnValueOnce( true ); expect( - getBlockEditingMode( + getEnabledBlockParents( state, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + '4c2b7140-fffd-44b4-b2a7-820c670a6514' ) - ).toBe( 'contentOnly' ); + ).toEqual( [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ] ); + } ); + + it( 'should order from bottom to top if ascending is true', () => { + const state = { + settings: {}, + blocks: { + parents: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', '' ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + [ + '4c2b7140-fffd-44b4-b2a7-820c670a6514', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ], + ] ), + }, + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'default' ], + ] ), + blockListSettings: {}, + }; + expect( + getEnabledBlockParents( + state, + '4c2b7140-fffd-44b4-b2a7-820c670a6514', + true + ) + ).toEqual( [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ] ); } ); } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 67ed0ae69106d6..9d886f9aa37bbc 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2889,7 +2889,6 @@ describe( 'state', () => { 'core/embed': { time: 123456, count: 1, - insert: { name: 'core/embed' }, }, }, } ); @@ -2900,7 +2899,6 @@ describe( 'state', () => { 'core/embed': { time: 123456, count: 1, - insert: { name: 'core/embed' }, }, }, } ), @@ -2926,12 +2924,10 @@ describe( 'state', () => { 'core/embed': { time: 123457, count: 2, - insert: { name: 'core/embed' }, }, 'core/block/123': { time: 123457, count: 1, - insert: { name: 'core/block', ref: 123 }, }, }, } ); @@ -2994,17 +2990,14 @@ describe( 'state', () => { [ orangeVariationName ]: { time: 123456, count: 1, - insert: { name: orangeVariationName }, }, [ appleVariationName ]: { time: 123456, count: 1, - insert: { name: appleVariationName }, }, [ blockWithVariations ]: { time: 123456, count: 2, - insert: { name: blockWithVariations }, }, } ), } ); @@ -3335,25 +3328,32 @@ describe( 'state', () => { expect( state.clientIds ).toEqual( [ clientIdOne, clientIdTwo ] ); } ); - it( 'should return client ids of all blocks when inner blocks are replaced with REPLACE_INNER_BLOCKS', () => { - const clientIdOne = '62bfef6e-d5e9-43ba-b7f9-c77cf354141f'; - const clientIdTwo = '9db792c6-a25a-495d-adbd-97d56a4c4189'; + it( 'should return client ids of the original blocks when inner blocks are replaced with REPLACE_INNER_BLOCKS', () => { + const initialBlocks = deepFreeze( [ + '62bfef6e-d5e9-43ba-b7f9-c77cf354141f', + '9db792c6-a25a-495d-adbd-97d56a4c4189', + ] ); const action = { blocks: [ { - clientId: clientIdOne, + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', }, { - clientId: clientIdTwo, + clientId: '14501cc2-90a6-4f52-aa36-ab6e896135d1', }, ], type: 'REPLACE_INNER_BLOCKS', }; - const state = lastBlockInserted( {}, action ); + const state = lastBlockInserted( + { + clientIds: initialBlocks, + }, + action + ); - expect( state.clientIds ).toEqual( [ clientIdOne, clientIdTwo ] ); + expect( state.clientIds ).toEqual( initialBlocks ); } ); it( 'should return empty state if last block inserted is called with action RESET_BLOCKS', () => { diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 60d90d80b9d41e..dfc27f6d21d090 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -7,6 +7,7 @@ import { setFreeformContentHandlerName, } from '@wordpress/blocks'; import { RawHTML } from '@wordpress/element'; +import { symbol } from '@wordpress/icons'; /** * Internal dependencies @@ -72,6 +73,7 @@ const { __experimentalGetPatternTransformItems, wasBlockJustInserted, __experimentalGetGlobalBlocksByName, + getBlockEditingMode, } = selectors; describe( 'selectors', () => { @@ -121,7 +123,7 @@ describe( 'selectors', () => { parent: [ 'core/test-block-b' ], } ); - registerBlockType( 'core/test-freeform', { + registerBlockType( 'core/freeform', { save: ( props ) => { props.attributes.content }, category: 'text', title: 'Test Freeform Content Handler', @@ -177,7 +179,7 @@ describe( 'selectors', () => { ancestor: [ 'core/test-block-ancestor' ], } ); - setFreeformContentHandlerName( 'core/test-freeform' ); + setFreeformContentHandlerName( 'core/freeform' ); cachedSelectors.forEach( ( { clear } ) => clear() ); } ); @@ -187,7 +189,7 @@ describe( 'selectors', () => { unregisterBlockType( 'core/test-block-a' ); unregisterBlockType( 'core/test-block-b' ); unregisterBlockType( 'core/test-block-c' ); - unregisterBlockType( 'core/test-freeform' ); + unregisterBlockType( 'core/freeform' ); unregisterBlockType( 'core/post-content-child' ); unregisterBlockType( 'core/test-block-ancestor' ); unregisterBlockType( 'core/test-block-parent' ); @@ -2693,11 +2695,13 @@ describe( 'selectors', () => { blocks: { byClientId: new Map(), attributes: new Map(), + parents: new Map(), }, blockListSettings: {}, settings: { allowedBlockTypes: [ 'core/test-block-a' ], }, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( true @@ -2720,14 +2724,36 @@ describe( 'selectors', () => { ); } ); + it( 'should deny blocks when the editor has a disabled editing mode', () => { + const state = { + blocks: { + byClientId: new Map(), + attributes: new Map(), + parents: new Map(), + }, + blockListSettings: {}, + settings: {}, + blockEditingModes: new Map( + Object.entries( { + '': 'disabled', + } ) + ), + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( + false + ); + } ); + it( 'should deny blocks that restrict parent from being inserted into the root', () => { const state = { blocks: { byClientId: new Map(), attributes: new Map(), + parents: new Map(), }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-c' ) ).toBe( false @@ -2747,9 +2773,11 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) @@ -2769,11 +2797,13 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: { block1: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) @@ -2793,11 +2823,13 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: { block1: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) @@ -2817,6 +2849,7 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: { block1: { @@ -2824,6 +2857,7 @@ describe( 'selectors', () => { }, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-b', 'block1' ) @@ -2843,6 +2877,7 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: { block1: { @@ -2850,12 +2885,41 @@ describe( 'selectors', () => { }, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-b', 'block1' ) ).toBe( true ); } ); + it( 'should deny blocks from being inserted into a block that has a disabled editing mode', () => { + const state = { + blocks: { + byClientId: new Map( + Object.entries( { + block1: { name: 'core/test-block-a' }, + } ) + ), + attributes: new Map( + Object.entries( { + block1: {}, + } ) + ), + parents: new Map(), + }, + blockListSettings: {}, + settings: {}, + blockEditingModes: new Map( + Object.entries( { + block1: 'disabled', + } ) + ), + }; + expect( + canInsertBlockType( state, 'core/test-block-b', 'block1' ) + ).toBe( false ); + } ); + it( 'should prioritise parent over allowedBlocks', () => { const state = { blocks: { @@ -2869,6 +2933,7 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: { block1: { @@ -2876,6 +2941,7 @@ describe( 'selectors', () => { }, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) @@ -2895,9 +2961,11 @@ describe( 'selectors', () => { block1: {}, } ) ), + parents: new Map(), }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/post-content-child', 'block1' ) @@ -2909,9 +2977,11 @@ describe( 'selectors', () => { blocks: { byClientId: new Map(), attributes: new Map(), + parents: new Map(), }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( state, 'core/post-content-child' ) @@ -2944,6 +3014,7 @@ describe( 'selectors', () => { block2: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( @@ -2984,6 +3055,7 @@ describe( 'selectors', () => { block3: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( @@ -3023,6 +3095,7 @@ describe( 'selectors', () => { block3: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( @@ -3062,6 +3135,7 @@ describe( 'selectors', () => { block3: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( @@ -3100,6 +3174,7 @@ describe( 'selectors', () => { }, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( @@ -3136,6 +3211,7 @@ describe( 'selectors', () => { block2: {}, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlockType( @@ -3165,6 +3241,7 @@ describe( 'selectors', () => { 3: {}, } ) ), + parents: new Map(), }, blockListSettings: { 1: { @@ -3175,6 +3252,7 @@ describe( 'selectors', () => { }, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlocks( state, [ '2', '3' ], '1' ) ).toBe( true ); } ); @@ -3196,6 +3274,7 @@ describe( 'selectors', () => { 3: {}, } ) ), + parents: new Map(), }, blockListSettings: { 1: { @@ -3203,6 +3282,7 @@ describe( 'selectors', () => { }, }, settings: {}, + blockEditingModes: new Map(), }; expect( canInsertBlocks( state, [ '2', '3' ], '1' ) ).toBe( false ); } ); @@ -3241,42 +3321,46 @@ describe( 'selectors', () => { // See: https://github.com/WordPress/gutenberg/issues/14580 preferences: {}, blockListSettings: {}, + blockEditingModes: new Map(), }; const items = getInserterItems( state ); const testBlockAItem = items.find( ( item ) => item.id === 'core/test-block-a' ); expect( testBlockAItem ).toEqual( { + category: 'design', + description: undefined, + example: undefined, + frecency: 0, + icon: { src: 'test' }, id: 'core/test-block-a', - name: 'core/test-block-a', initialAttributes: {}, - title: 'Test Block A', - icon: { - src: 'test', - }, - category: 'design', - keywords: [ 'testing' ], - variations: [], isDisabled: false, + keywords: [ 'testing' ], + name: 'core/test-block-a', + title: 'Test Block A', utility: 1, - frecency: 0, + variations: [], } ); const reusableBlockItem = items.find( ( item ) => item.id === 'core/block/1' ); expect( reusableBlockItem ).toEqual( { - id: 'core/block/1', - name: 'core/block', - initialAttributes: { ref: 1 }, - title: 'Reusable Block 1', + category: 'reusable', + content: '', + frecency: 0, icon: { - src: 'test', + src: symbol, + foreground: 'var(--wp-block-synced-color)', }, - category: 'reusable', - keywords: [], + id: 'core/block/1', + initialAttributes: { ref: 1 }, isDisabled: false, + keywords: [ 'reusable' ], + name: 'core/block', + syncStatus: undefined, + title: 'Reusable Block 1', utility: 1, - frecency: 0, } ); } ); @@ -3349,6 +3433,7 @@ describe( 'selectors', () => { block3: {}, block4: {}, }, + blockEditingModes: new Map(), }; const stateSecondBlockRestricted = { @@ -3370,7 +3455,7 @@ describe( 'selectors', () => { expect( firstBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ 'core/test-block-a', 'core/test-block-b', - 'core/test-freeform', + 'core/freeform', 'core/test-block-ancestor', 'core/test-block-parent', 'core/block/1', @@ -3386,7 +3471,7 @@ describe( 'selectors', () => { expect( secondBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ 'core/test-block-a', 'core/test-block-b', - 'core/test-freeform', + 'core/freeform', 'core/test-block-ancestor', 'core/test-block-parent', 'core/block/1', @@ -3436,6 +3521,7 @@ describe( 'selectors', () => { }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; const items = getInserterItems( state ); const testBlockBItem = items.find( @@ -3460,6 +3546,7 @@ describe( 'selectors', () => { }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; const items = getInserterItems( state ); const reusableBlock2Item = items.find( @@ -3551,6 +3638,7 @@ describe( 'selectors', () => { settings: {}, preferences: {}, blockListSettings: {}, + blockEditingModes: new Map(), }; const blocks = [ { name: 'core/with-tranforms-a' } ]; const items = getBlockTransformItems( state, blocks ); @@ -3591,6 +3679,7 @@ describe( 'selectors', () => { settings: {}, preferences: {}, blockListSettings: {}, + blockEditingModes: new Map(), }; const block = { name: 'core/with-tranforms-a' }; const items = getBlockTransformItems( state, block ); @@ -3629,6 +3718,7 @@ describe( 'selectors', () => { }, block2: {}, }, + blockEditingModes: new Map(), }; const blocks = [ { clientId: 'block2', name: 'core/with-tranforms-a' }, @@ -3676,6 +3766,7 @@ describe( 'selectors', () => { }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; const blocks = [ { name: 'core/with-tranforms-a' } ]; const items = getBlockTransformItems( state, blocks ); @@ -3709,6 +3800,7 @@ describe( 'selectors', () => { }, blockListSettings: {}, settings: {}, + blockEditingModes: new Map(), }; const blocks = [ { name: 'core/with-tranforms-c' } ]; const items = getBlockTransformItems( state, blocks ); @@ -4121,6 +4213,12 @@ describe( 'selectors', () => { block2: {}, } ) ), + parents: new Map( + Object.entries( { + block1: '', + block2: '', + } ) + ), }, blockListSettings: { block1: { @@ -4152,6 +4250,7 @@ describe( 'selectors', () => { }, ], }, + blockEditingModes: new Map(), }; it( 'should return all patterns for root level', () => { @@ -4249,6 +4348,7 @@ describe( 'selectors', () => { block1: { name: 'core/test-block-a' }, } ) ), + parents: new Map(), }, blockListSettings: { block1: { @@ -4279,6 +4379,7 @@ describe( 'selectors', () => { }, ], }, + blockEditingModes: new Map(), }; it( 'should return empty array if no block name is provided', () => { expect( getPatternsByBlockTypes( state ) ).toEqual( [] ); @@ -4329,6 +4430,7 @@ describe( 'selectors', () => { block2: { name: 'core/test-block-b' }, } ) ), + parents: new Map(), controlledInnerBlocks: { 'block2-clientId': true }, }, blockListSettings: { @@ -4371,6 +4473,7 @@ describe( 'selectors', () => { }, ], }, + blockEditingModes: new Map(), }; describe( 'should return empty array', () => { it( 'when no blocks are selected', () => { @@ -4591,6 +4694,7 @@ describe( 'getInserterItems with core blocks prioritization', () => { settings: {}, preferences: {}, blockListSettings: {}, + blockEditingModes: new Map(), }; const items = getInserterItems( state ); const expectedResult = [ @@ -4635,6 +4739,7 @@ describe( '__unstableGetClientIdWithClientIdsTree', () => { { clientId: 'baz', innerBlocks: [] }, ], } ); + expect( console ).toHaveWarned(); } ); } ); describe( '__unstableGetClientIdsTree', () => { @@ -4687,3 +4792,201 @@ describe( '__unstableGetClientIdsTree', () => { ] ); } ); } ); + +describe( 'getBlockEditingMode', () => { + const baseState = { + 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 + ] ), + order: new Map( [ + [ + '', + [ + '6cf70164-9097-4460-bcbf-200560546988', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + ], + [ '6cf70164-9097-4460-bcbf-200560546988', [] ], + [ + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + ], + [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', [] ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + [ + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ], + ], + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', [] ], + [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', [] ], + ] ), + parents: new Map( [ + [ '6cf70164-9097-4460-bcbf-200560546988', '' ], + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', '' ], + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + ], + [ + 'b3247f75-fd94-4fef-97f9-5bfd162cc416', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], + ] ), + }, + blockListSettings: { + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337': {}, + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': {}, + }, + blockEditingModes: new Map( [] ), + }; + + const __experimentalHasContentRoleAttribute = jest.fn( () => false ); + getBlockEditingMode.registry = { + select: jest.fn( () => ( { + __experimentalHasContentRoleAttribute, + } ) ), + }; + + it( 'should return default by default', () => { + expect( + getBlockEditingMode( + baseState, + 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + ) + ).toBe( 'default' ); + } ); + + it( 'should return disabled if explicitly set', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'disabled' ], + ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'disabled' ); + } ); + + it( 'should return contentOnly if explicitly set', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'contentOnly' ], + ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'contentOnly' ); + } ); + + it( 'should return disabled if explicitly set on a parent', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], + ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'disabled' ); + } ); + + it( 'should return default if parent is set to contentOnly', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'contentOnly' ], + ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'default' ); + } ); + + it( 'should return disabled if overridden by a parent', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ '', 'disabled' ], + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'default' ], + [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'disabled' ], + ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'disabled' ); + } ); + + it( 'should return disabled if explicitly set on root', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ [ '', 'disabled' ] ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'disabled' ); + } ); + + it( 'should return default if root is contentOnly', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ [ '', 'contentOnly' ] ] ), + }; + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'default' ); + } ); + + it( 'should return disabled if parent is locked and the block has no content role', () => { + const state = { + ...baseState, + blockListSettings: { + ...baseState.blockListSettings, + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': { + templateLock: 'contentOnly', + }, + }, + }; + __experimentalHasContentRoleAttribute.mockReturnValueOnce( false ); + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'disabled' ); + } ); + + it( 'should return contentOnly if parent is locked and the block has a content role', () => { + const state = { + ...baseState, + blockListSettings: { + ...baseState.blockListSettings, + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': { + templateLock: 'contentOnly', + }, + }, + }; + __experimentalHasContentRoleAttribute.mockReturnValueOnce( true ); + expect( + getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) + ).toBe( 'contentOnly' ); + } ); +} ); diff --git a/packages/block-editor/src/utils/object.js b/packages/block-editor/src/utils/object.js index a7496bd593923c..ed81450e49ab93 100644 --- a/packages/block-editor/src/utils/object.js +++ b/packages/block-editor/src/utils/object.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { paramCase } from 'change-case'; + /** * Converts a path to an array of its fragments. * Supports strings, numbers and arrays: @@ -19,14 +24,49 @@ function normalizePath( path ) { return [ path ]; } +/** + * Converts any string to kebab case. + * Backwards compatible with Lodash's `_.kebabCase()`. + * Backwards compatible with `_wp_to_kebab_case()`. + * + * @see https://lodash.com/docs/4.17.15#kebabCase + * @see https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/ + * + * @param {string} str String to convert. + * @return {string} Kebab-cased string + */ +export function kebabCase( str ) { + let input = str; + if ( typeof str !== 'string' ) { + input = str?.toString?.() ?? ''; + } + + // See https://github.com/lodash/lodash/blob/b185fcee26b2133bd071f4aaca14b455c2ed1008/lodash.js#L4970 + input = input.replace( /['\u2019]/, '' ); + + return paramCase( input, { + splitRegexp: [ + /(?!(?:1ST|2ND|3RD|[4-9]TH)(?![a-z]))([a-z0-9])([A-Z])/g, // fooBar => foo-bar, 3Bar => 3-bar + /(?!(?:1st|2nd|3rd|[4-9]th)(?![a-z]))([0-9])([a-z])/g, // 3bar => 3-bar + /([A-Za-z])([0-9])/g, // Foo3 => foo-3, foo3 => foo-3 + /([A-Z])([A-Z][a-z])/g, // FOOBar => foo-bar + ], + } ); +} + /** * Clones an object. + * Arrays are also cloned as arrays. * Non-object values are returned unchanged. * * @param {*} object Object to clone. * @return {*} Cloned object, or original literal non-object value. */ function cloneObject( object ) { + if ( Array.isArray( object ) ) { + return object.map( cloneObject ); + } + if ( object && typeof object === 'object' ) { return { ...Object.fromEntries( @@ -44,7 +84,7 @@ function cloneObject( object ) { /** * Immutably sets a value inside an object. Like `lodash#set`, but returning a * new object. Treats nullish initial values as empty objects. Clones any - * nested objects. + * nested objects. Supports arrays, too. * * @param {Object} object Object to set a value in. * @param {number|string|Array} path Path in the object to modify. @@ -57,7 +97,11 @@ export function setImmutably( object, path, value ) { normalizedPath.reduce( ( acc, key, i ) => { if ( acc[ key ] === undefined ) { - acc[ key ] = {}; + if ( Number.isInteger( path[ i + 1 ] ) ) { + acc[ key ] = []; + } else { + acc[ key ] = {}; + } } if ( i === normalizedPath.length - 1 ) { acc[ key ] = value; @@ -67,3 +111,24 @@ export function setImmutably( object, path, value ) { return newObject; } + +/** + * Helper util to return a value from a certain path of the object. + * Path is specified as either: + * - a string of properties, separated by dots, for example: "x.y". + * - an array of properties, for example `[ 'x', 'y' ]`. + * You can also specify a default value in case the result is nullish. + * + * @param {Object} object Input object. + * @param {string|Array} path Path to the object property. + * @param {*} defaultValue Default value if the value at the specified path is nullish. + * @return {*} Value of the object property at the specified path. + */ +export const getValueFromObjectPath = ( object, path, defaultValue ) => { + const normalizedPath = Array.isArray( path ) ? path : path.split( '.' ); + let value = object; + normalizedPath.forEach( ( fieldName ) => { + value = value?.[ fieldName ]; + } ); + return value ?? defaultValue; +}; diff --git a/packages/block-editor/src/utils/pre-parse-patterns.js b/packages/block-editor/src/utils/pre-parse-patterns.js deleted file mode 100644 index c18215ee8e63f9..00000000000000 --- a/packages/block-editor/src/utils/pre-parse-patterns.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect, select } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../store'; - -const requestIdleCallback = ( () => { - if ( typeof window === 'undefined' ) { - return ( callback ) => { - setTimeout( () => callback( Date.now() ), 0 ); - }; - } - - return window.requestIdleCallback || window.requestAnimationFrame; -} )(); - -const cancelIdleCallback = ( () => { - if ( typeof window === 'undefined' ) { - return clearTimeout; - } - - return window.cancelIdleCallback || window.cancelAnimationFrame; -} )(); - -export function usePreParsePatterns() { - const { patterns, isPreviewMode } = useSelect( ( _select ) => { - const { __experimentalBlockPatterns, __unstableIsPreviewMode } = - _select( blockEditorStore ).getSettings(); - return { - patterns: __experimentalBlockPatterns, - isPreviewMode: __unstableIsPreviewMode, - }; - }, [] ); - - useEffect( () => { - if ( isPreviewMode ) { - return; - } - if ( ! patterns?.length ) { - return; - } - - let handle; - let index = -1; - - const callback = () => { - index++; - if ( index >= patterns.length ) { - return; - } - - select( blockEditorStore ).__experimentalGetParsedPattern( - patterns[ index ].name - ); - - handle = requestIdleCallback( callback ); - }; - - handle = requestIdleCallback( callback ); - return () => cancelIdleCallback( handle ); - }, [ patterns, isPreviewMode ] ); - - return null; -} diff --git a/packages/block-editor/src/utils/test/object.js b/packages/block-editor/src/utils/test/object.js index 8e363e1511db2f..87f01375df311d 100644 --- a/packages/block-editor/src/utils/test/object.js +++ b/packages/block-editor/src/utils/test/object.js @@ -1,7 +1,102 @@ /** * Internal dependencies */ -import { setImmutably } from '../object'; +import { kebabCase, setImmutably } from '../object'; + +describe( 'kebabCase', () => { + it( 'separates lowercase letters, followed by uppercase letters', () => { + expect( kebabCase( 'fooBar' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'separates numbers, followed by uppercase letters', () => { + expect( kebabCase( '123FOO' ) ).toEqual( '123-foo' ); + } ); + + it( 'separates numbers, followed by lowercase characters', () => { + expect( kebabCase( '123bar' ) ).toEqual( '123-bar' ); + } ); + + it( 'separates uppercase letters, followed by numbers', () => { + expect( kebabCase( 'FOO123' ) ).toEqual( 'foo-123' ); + } ); + + it( 'separates lowercase letters, followed by numbers', () => { + expect( kebabCase( 'foo123' ) ).toEqual( 'foo-123' ); + } ); + + it( 'separates uppercase groups from capitalized groups', () => { + expect( kebabCase( 'FOOBar' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'removes any non-dash special characters', () => { + expect( + kebabCase( 'foo±§!@#$%^&*()-_=+/?.>,<\\|{}[]`~\'";:bar' ) + ).toEqual( 'foo-bar' ); + } ); + + it( 'removes any spacing characters', () => { + expect( kebabCase( ' foo \t \n \r \f \v bar ' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'groups multiple dashes into a single one', () => { + expect( kebabCase( 'foo---bar' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'returns an empty string unchanged', () => { + expect( kebabCase( '' ) ).toEqual( '' ); + } ); + + it( 'returns an existing kebab case string unchanged', () => { + expect( kebabCase( 'foo-123-bar' ) ).toEqual( 'foo-123-bar' ); + } ); + + it( 'returns an empty string if any nullish type is passed', () => { + expect( kebabCase( undefined ) ).toEqual( '' ); + expect( kebabCase( null ) ).toEqual( '' ); + } ); + + it( 'converts any unexpected non-nullish type to a string', () => { + expect( kebabCase( 12345 ) ).toEqual( '12345' ); + expect( kebabCase( [] ) ).toEqual( '' ); + expect( kebabCase( {} ) ).toEqual( 'object-object' ); + } ); + + /** + * Should cover all test cases of `_wp_to_kebab_case()`. + * + * @see https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/ + * @see https://github.com/WordPress/wordpress-develop/blob/76376fdbc3dc0b3261de377dffc350677345e7ba/tests/phpunit/tests/functions/wpToKebabCase.php#L35-L62 + */ + it.each( [ + [ 'white', 'white' ], + [ 'white+black', 'white-black' ], + [ 'white:black', 'white-black' ], + [ 'white*black', 'white-black' ], + [ 'white.black', 'white-black' ], + [ 'white black', 'white-black' ], + [ 'white black', 'white-black' ], + [ 'white-to-black', 'white-to-black' ], + [ 'white2white', 'white-2-white' ], + [ 'white2nd', 'white-2nd' ], + [ 'white2ndcolor', 'white-2-ndcolor' ], + [ 'white2ndColor', 'white-2nd-color' ], + [ 'white2nd_color', 'white-2nd-color' ], + [ 'white23color', 'white-23-color' ], + [ 'white23', 'white-23' ], + [ '23color', '23-color' ], + [ 'white4th', 'white-4th' ], + [ 'font2xl', 'font-2-xl' ], + [ 'whiteToWhite', 'white-to-white' ], + [ 'whiteTOwhite', 'white-t-owhite' ], + [ 'WHITEtoWHITE', 'whit-eto-white' ], + [ 42, '42' ], + [ "i've done", 'ive-done' ], + [ '#ffffff', 'ffffff' ], + [ '$ffffff', 'ffffff' ], + ] )( 'converts %s properly to %s', ( input, expected ) => { + expect( kebabCase( input ) ).toEqual( expected ); + } ); +} ); describe( 'setImmutably', () => { describe( 'handling falsy values properly', () => { @@ -55,6 +150,22 @@ describe( 'setImmutably', () => { expect( result ).toEqual( { test: 2 } ); } ); + it( 'handles first level arrays properly', () => { + const result = setImmutably( [ 5 ], 0, 6 ); + + expect( result ).toEqual( [ 6 ] ); + } ); + + it( 'handles nested arrays properly', () => { + const result = setImmutably( + [ [ 'foo', [ 'bar' ] ] ], + [ 0, 1, 0 ], + 'baz' + ); + + expect( result ).toEqual( [ [ 'foo', [ 'baz' ] ] ] ); + } ); + describe( 'with array notation access', () => { it( 'assigns values at deeper levels', () => { const result = setImmutably( {}, [ 'foo', 'bar', 'baz' ], 5 ); @@ -141,5 +252,25 @@ describe( 'setImmutably', () => { expect( result.foo.bar ).not.toBe( input.foo.bar ); expect( result.foo.bar.baz ).not.toBe( input.foo.bar.baz ); } ); + + it( 'clones arrays at the first level', () => { + const input = []; + const result = setImmutably( input, 0, 1 ); + + expect( result ).not.toBe( input ); + } ); + + it( 'clones arrays at deeper levels', () => { + const input = [ [ [ [ 'foo', [ 'bar' ] ] ] ] ]; + const result = setImmutably( input, [ 0, 0, 0, 1, 0 ], 'baz' ); + + expect( result ).not.toBe( input ); + expect( result[ 0 ] ).not.toBe( input[ 0 ] ); + expect( result[ 0 ][ 0 ] ).not.toBe( input[ 0 ][ 0 ] ); + expect( result[ 0 ][ 0 ][ 0 ] ).not.toBe( input[ 0 ][ 0 ][ 0 ] ); + expect( result[ 0 ][ 0 ][ 0 ][ 1 ] ).not.toBe( + input[ 0 ][ 0 ][ 0 ][ 1 ] + ); + } ); } ); } ); diff --git a/packages/block-editor/src/utils/transform-styles/ast/parse.js b/packages/block-editor/src/utils/transform-styles/ast/parse.js index 3e7ba714bd4f1a..8f7d227d61442d 100644 --- a/packages/block-editor/src/utils/transform-styles/ast/parse.js +++ b/packages/block-editor/src/utils/transform-styles/ast/parse.js @@ -455,6 +455,36 @@ export default function ( css, options ) { } ); } + /** + * Parse container. + */ + + function atcontainer() { + const pos = position(); + const m = match( /^@container *([^{]+)/ ); + + if ( ! m ) { + return; + } + const container = trim( m[ 1 ] ); + + if ( ! open() ) { + return error( "@container missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@container missing '}'" ); + } + + return pos( { + type: 'container', + container, + rules: style, + } ); + } + /** * Parse custom-media. */ @@ -624,6 +654,7 @@ export default function ( css, options ) { return ( atkeyframes() || atmedia() || + atcontainer() || atcustommedia() || atsupports() || atimport() || diff --git a/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js b/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js index e7305659e1b259..6a2a3af3769be0 100644 --- a/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js +++ b/packages/block-editor/src/utils/transform-styles/ast/stringify/compress.js @@ -68,6 +68,19 @@ Compiler.prototype.media = function ( node ) { ); }; +/** + * Visit container node. + */ + +Compiler.prototype.container = function ( node ) { + return ( + this.emit( '@container ' + node.container, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ) + ); +}; + /** * Visit document node. */ diff --git a/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js b/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js index 41ba6e35b2eb5e..760ca4044631ee 100644 --- a/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js +++ b/packages/block-editor/src/utils/transform-styles/ast/stringify/identity.js @@ -83,6 +83,19 @@ Compiler.prototype.media = function ( node ) { ); }; +/** + * Visit container node. + */ + +Compiler.prototype.container = function ( node ) { + return ( + this.emit( '@container ' + node.container, node.position ) + + this.emit( ' {\n' + this.indent( 1 ) ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( this.indent( -1 ) + '\n}' ) + ); +}; + /** * Visit document node. */ diff --git a/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap b/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap index 9060dda7470295..b55f74cfd7bb0b 100644 --- a/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap +++ b/packages/block-editor/src/utils/transform-styles/transforms/test/__snapshots__/wrap.js.snap @@ -42,6 +42,14 @@ color: red; }" `; +exports[`CSS selector wrap should wrap selectors inside container queries 1`] = ` +"@container (width > 400px) { +.my-namespace h1 { +color: red; +} +}" +`; + exports[`CSS selector wrap should replace :root selectors 1`] = ` ".my-namespace { --my-color: #ff0000; diff --git a/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js b/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js index 47035da70de2d8..c26bd3761212b1 100644 --- a/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js +++ b/packages/block-editor/src/utils/transform-styles/transforms/test/wrap.js @@ -50,6 +50,17 @@ describe( 'CSS selector wrap', () => { expect( output ).toMatchSnapshot(); } ); + it( 'should wrap selectors inside container queries', () => { + const callback = wrap( '.my-namespace' ); + const input = ` + @container (width > 400px) { + h1 { color: red; } + }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + it( 'should ignore font-face selectors', () => { const callback = wrap( '.my-namespace' ); const input = ` diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index be80c974b8ee33..a7fe0bd99bd4a0 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 8.17.0 (2023-08-16) + +## 8.16.0 (2023-08-10) + +## 8.15.0 (2023-07-20) + +## 8.14.0 (2023-07-05) + +## 8.13.0 (2023-06-23) + +## 8.12.0 (2023-06-07) + ## 8.11.0 (2023-05-24) ## 8.10.0 (2023-05-10) diff --git a/packages/block-library/README.md b/packages/block-library/README.md index 96ec2d8963b3c3..d75d9a45dcb4d4 100644 --- a/packages/block-library/README.md +++ b/packages/block-library/README.md @@ -119,4 +119,23 @@ To find out more about contributing to this package or Gutenberg as a whole, ple } ``` +### Naming convention for PHP functions + +All PHP function names declared within the subdirectories of the `packages/block-library/src/` directory should start with one of the following prefixes: + +- `block_core_` +- `render_block_core_` +- `register_block_core_` + +In this context, `` represents the name of the directory where the corresponding `.php` file is located. +The directory name is converted to lowercase, and any characters except for letters and digits are replaced with underscores. + +#### Example: + +For the PHP functions declared in the `packages/block-library/src/my-block/index.php` file, the correct prefixes would be: + +- `block_core_my_block` +- `render_block_core_my_block` +- `register_block_core_my_block` +

Code is Poetry.

diff --git a/packages/block-library/package.json b/packages/block-library/package.json index f739ba882baef5..5fe213651f54a8 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-library", - "version": "8.11.0", + "version": "8.17.0", "description": "Block library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -27,12 +27,10 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/*/init.js", - "{src,build,build-module}/utils/interactivity/index.js" + "{src,build,build-module}/*/init.js" ], "dependencies": { "@babel/runtime": "^7.16.0", - "@preact/signals": "^1.1.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", @@ -52,6 +50,7 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/interactivity": "file:../interactivity", "@wordpress/keycodes": "file:../keycodes", "@wordpress/notices": "file:../notices", "@wordpress/primitives": "file:../primitives", @@ -65,15 +64,13 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", - "deepsignal": "^1.3.0", "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21", "memize": "^2.1.0", "micromodal": "^0.4.10", - "preact": "^10.13.2", - "remove-accents": "^0.4.2" + "remove-accents": "^0.5.0", + "uuid": "^8.3.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/block-library/src/archives/block.json b/packages/block-library/src/archives/block.json index edc6895e14b06f..7e0f5181d2c3dd 100644 --- a/packages/block-library/src/archives/block.json +++ b/packages/block-library/src/archives/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/archives", "title": "Archives", "category": "widgets", @@ -26,11 +26,14 @@ }, "supports": { "align": true, - "anchor": true, "html": false, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "typography": { "fontSize": true, diff --git a/packages/block-library/src/audio/block.json b/packages/block-library/src/audio/block.json index 13aed788968fb4..a4740e304451ce 100644 --- a/packages/block-library/src/audio/block.json +++ b/packages/block-library/src/audio/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/audio", "title": "Audio", "category": "media", @@ -49,7 +49,11 @@ "align": true, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } } }, "editorStyle": "wp-block-audio-editor", diff --git a/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap index 4d43cef74385e4..e37c7fa1071021 100644 --- a/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap @@ -4,7 +4,11 @@ exports[`Audio block renders audio block error state without crashing 1`] = ` - + + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> +
@@ -188,7 +220,11 @@ exports[`Audio block renders audio file without crashing 1`] = ` - + + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> + @@ -393,22 +466,13 @@ exports[`Audio block renders placeholder without crashing 1`] = ` } > + + + + + Path + + + + + Audio + + - - - Path - - + + Add audio + - - Audio - - - ADD AUDIO - diff --git a/packages/block-library/src/avatar/block.json b/packages/block-library/src/avatar/block.json index 3fbb6dd9221aec..3b4ac7c84f6172 100644 --- a/packages/block-library/src/avatar/block.json +++ b/packages/block-library/src/avatar/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/avatar", "title": "Avatar", "category": "theme", @@ -25,7 +25,6 @@ }, "usesContext": [ "postType", "postId", "commentId" ], "supports": { - "anchor": true, "html": false, "align": true, "alignWide": false, @@ -49,6 +48,9 @@ "__experimentalDuotone": "img" } }, - "editorStyle": "wp-block-avatar", + "selectors": { + "border": ".wp-block-avatar img" + }, + "editorStyle": "wp-block-avatar-editor", "style": "wp-block-avatar" } diff --git a/packages/block-library/src/avatar/edit.js b/packages/block-library/src/avatar/edit.js index 5141152189ddf3..8b326f4e72d88a 100644 --- a/packages/block-library/src/avatar/edit.js +++ b/packages/block-library/src/avatar/edit.js @@ -36,6 +36,7 @@ const AvatarInspectorControls = ( { setAttributes( { diff --git a/packages/block-library/src/avatar/style.scss b/packages/block-library/src/avatar/style.scss index 1f1bd7c97e8426..ed6d846afad0ec 100644 --- a/packages/block-library/src/avatar/style.scss +++ b/packages/block-library/src/avatar/style.scss @@ -1,6 +1,7 @@ .wp-block-avatar { // This block has customizable padding, border-box makes that more predictable. box-sizing: border-box; + line-height: 0; img { box-sizing: border-box; } diff --git a/packages/block-library/src/block/block.json b/packages/block-library/src/block/block.json index f472fd0f4760a1..4cb53960725d21 100644 --- a/packages/block-library/src/block/block.json +++ b/packages/block-library/src/block/block.json @@ -1,10 +1,11 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/block", - "title": "Reusable block", + "title": "Pattern", "category": "reusable", - "description": "Create and save content to reuse across your site. Update the block, and the changes apply everywhere it’s used.", + "description": "Create and save content to reuse across your site. Update the pattern, and the changes apply everywhere it’s used.", + "keywords": [ "reusable" ], "textdomain": "default", "attributes": { "ref": { diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index fcba45450ea5e9..13745ae0fd6dec 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -1,7 +1,6 @@ /** * WordPress dependencies */ -import { useDispatch, useSelect } from '@wordpress/data'; import { useEntityBlockEditor, useEntityProp, @@ -10,8 +9,6 @@ import { import { Placeholder, Spinner, - ToolbarGroup, - ToolbarButton, TextControl, PanelBody, } from '@wordpress/components'; @@ -21,16 +18,12 @@ import { __experimentalRecursionProvider as RecursionProvider, __experimentalUseHasRecursion as useHasRecursion, InnerBlocks, - BlockControls, InspectorControls, useBlockProps, Warning, - store as blockEditorStore, } from '@wordpress/block-editor'; -import { store as reusableBlocksStore } from '@wordpress/reusable-blocks'; -import { ungroup } from '@wordpress/icons'; -export default function ReusableBlockEdit( { attributes: { ref }, clientId } ) { +export default function ReusableBlockEdit( { attributes: { ref } } ) { const hasAlreadyRendered = useHasRecursion( ref ); const { record, hasResolved } = useEntityRecord( 'postType', @@ -39,26 +32,12 @@ export default function ReusableBlockEdit( { attributes: { ref }, clientId } ) { ); const isMissing = hasResolved && ! record; - const { canRemove, innerBlockCount } = useSelect( - ( select ) => { - const { canRemoveBlock, getBlockCount } = - select( blockEditorStore ); - return { - canRemove: canRemoveBlock( clientId ), - innerBlockCount: getBlockCount( clientId ), - }; - }, - [ clientId ] - ); - - const { __experimentalConvertBlockToStatic: convertBlockToStatic } = - useDispatch( reusableBlocksStore ); - const [ blocks, onInput, onChange ] = useEntityBlockEditor( 'postType', 'wp_block', { id: ref } ); + const [ title, setTitle ] = useEntityProp( 'postType', 'wp_block', @@ -111,22 +90,6 @@ export default function ReusableBlockEdit( { attributes: { ref }, clientId } ) { return ( - { canRemove && ( - - - convertBlockToStatic( clientId ) } - label={ - innerBlockCount > 1 - ? __( 'Convert to regular blocks' ) - : __( 'Convert to regular block' ) - } - icon={ ungroup } - showTooltip - /> - - - ) } { - const successNotice = - innerBlockCount > 1 - ? /* translators: %s: name of the reusable block */ - __( '%s converted to regular blocks' ) - : /* translators: %s: name of the reusable block */ - __( '%s converted to regular block' ); + /* translators: %s: name of the synced block */ + const successNotice = __( '%s detached' ); createSuccessNotice( sprintf( successNotice, title ) ); clearSelectedBlock(); @@ -152,14 +148,14 @@ export default function ReusableBlockEdit( { ? sprintf( /* translators: %s: name of the host app (e.g. WordPress) */ __( - 'Editing reusable blocks is not yet supported on %s for Android' + 'Editing synced patterns is not yet supported on %s for Android' ), hostAppNamespace ) : sprintf( /* translators: %s: name of the host app (e.g. WordPress) */ __( - 'Editing reusable blocks is not yet supported on %s for iOS' + 'Editing synced patterns is not yet supported on %s for iOS' ), hostAppNamespace ); @@ -182,17 +178,17 @@ export default function ReusableBlockEdit( { { innerBlockCount > 1 ? __( - 'Alternatively, you can detach and edit these blocks separately by tapping “Convert to regular blocks”.' + 'Alternatively, you can detach and edit these blocks separately by tapping “Detach patterns”.' ) : __( - 'Alternatively, you can detach and edit this block separately by tapping “Convert to regular block”.' + 'Alternatively, you can detach and edit this block separately by tapping “Detach pattern”.' ) } 1 - ? __( 'Convert to regular blocks' ) - : __( 'Convert to regular block' ) + ? __( 'Detach patterns' ) + : __( 'Detach pattern' ) } separatorType="topFullWidth" onPress={ onConvertToRegularBlocks } diff --git a/packages/block-library/src/block/editor.native.scss b/packages/block-library/src/block/editor.native.scss index 3aa23e0ddd20ea..b56a50f676600d 100644 --- a/packages/block-library/src/block/editor.native.scss +++ b/packages/block-library/src/block/editor.native.scss @@ -19,8 +19,8 @@ background-color: $light-gray-400; height: 1px; position: absolute; - left: -$block-selected-to-content + $block-selected-border-width; - right: -$block-selected-to-content + $block-selected-border-width; + left: -$grid-unit-05; + right: -$grid-unit-05; bottom: 16; } diff --git a/packages/block-library/src/block/test/__snapshots__/transforms.native.js.snap b/packages/block-library/src/block/test/__snapshots__/transforms.native.js.snap index 7489c0b04954a7..3c4d791eb9f755 100644 --- a/packages/block-library/src/block/test/__snapshots__/transforms.native.js.snap +++ b/packages/block-library/src/block/test/__snapshots__/transforms.native.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Reusable block block transforms to Columns block 1`] = ` +exports[`Pattern block transforms to Columns block 1`] = ` "
@@ -8,7 +8,7 @@ exports[`Reusable block block transforms to Columns block 1`] = ` " `; -exports[`Reusable block block transforms to Group block 1`] = ` +exports[`Pattern block transforms to Group block 1`] = ` "
" diff --git a/packages/block-library/src/block/test/edit.native.js b/packages/block-library/src/block/test/edit.native.js index 4652f8ba20f385..1e6c43b5bc4456 100644 --- a/packages/block-library/src/block/test/edit.native.js +++ b/packages/block-library/src/block/test/edit.native.js @@ -56,8 +56,8 @@ afterAll( () => { } ); } ); -describe( 'Reusable block', () => { - it( 'inserts a reusable block', async () => { +describe( 'Synced patterns', () => { + it( 'inserts a synced pattern', async () => { // We have to use different ids because entities are cached in memory. const reusableBlockMock1 = getMockedReusableBlock( 1 ); const reusableBlockMock2 = getMockedReusableBlock( 2 ); @@ -86,7 +86,7 @@ describe( 'Reusable block', () => { fireEvent.press( await screen.findByLabelText( 'Add block' ) ); // Navigate to reusable tab. - const reusableSegment = await screen.findByText( 'Reusable' ); + const reusableSegment = await screen.findByText( 'Synced patterns' ); // onLayout event is required by Segment component. fireEvent( reusableSegment, 'layout', { nativeEvent: { @@ -98,7 +98,7 @@ describe( 'Reusable block', () => { fireEvent.press( reusableSegment ); const reusableBlockList = screen.getByTestId( - 'InserterUI-ReusableBlocks' + 'InserterUI-SyncedPatterns' ); // onScroll event used to force the FlatList to render all items. fireEvent.scroll( reusableBlockList, { @@ -114,7 +114,7 @@ describe( 'Reusable block', () => { // Get the reusable block. const [ reusableBlock ] = await screen.findAllByLabelText( - /Reusable block Block\. Row 1/ + /Pattern Block\. Row 1/ ); expect( reusableBlock ).toBeDefined(); @@ -131,7 +131,7 @@ describe( 'Reusable block', () => { } ); const [ reusableBlock ] = await screen.findAllByLabelText( - /Reusable block Block\. Row 1/ + /Pattern Block\. Row 1/ ); const blockDeleted = within( reusableBlock ).getByText( @@ -142,9 +142,7 @@ describe( 'Reusable block', () => { expect( blockDeleted ).toBeDefined(); } ); - // Skipped until `pointerEvents: 'none'` no longer erroneously prevents - // triggering `onLayout*` on the element: https://github.com/callstack/react-native-testing-library/issues/897. - it.skip( 'renders block content', async () => { + it( 'renders block content', async () => { // We have to use different ids because entities are cached in memory. const id = 4; const initialHtml = ``; @@ -163,8 +161,8 @@ describe( 'Reusable block', () => { initialHtml, } ); - const [ reusableBlock ] = await screen.findByLabelText( - /Reusable block Block\. Row 1/ + const reusableBlock = await screen.findByLabelText( + /Pattern Block\. Row 1/ ); const innerBlockListWrapper = await within( diff --git a/packages/block-library/src/block/test/transforms.native.js b/packages/block-library/src/block/test/transforms.native.js index 9771b40743a422..95104ac6133993 100644 --- a/packages/block-library/src/block/test/transforms.native.js +++ b/packages/block-library/src/block/test/transforms.native.js @@ -9,7 +9,7 @@ import { getBlockTransformOptions, } from 'test/helpers'; -const block = 'Reusable block'; +const block = 'Pattern'; const initialHtml = ` `; diff --git a/packages/block-library/src/button/block.json b/packages/block-library/src/button/block.json index a8d7caaba6e0cf..e337aa857fc175 100644 --- a/packages/block-library/src/button/block.json +++ b/packages/block-library/src/button/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/button", "title": "Button", "category": "design", diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index 5311086a0803b1..4684c05a37f7d6 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useEffect, useState, useRef } from '@wordpress/element'; +import { useEffect, useState, useRef, useMemo } from '@wordpress/element'; import { Button, ButtonGroup, @@ -52,7 +52,7 @@ function WidthPanel( { selectedWidth, setAttributes } ) { return ( @@ -281,9 +273,7 @@ export default function CoverInspectorControls( { : dimRatio !== ( url ? 50 : 100 ); } } label={ __( 'Overlay opacity' ) } - onDeselect={ () => - setAttributes( { dimRatio: url ? 50 : 100 } ) - } + onDeselect={ () => updateDimRatio( url ? 50 : 100 ) } resetAllFilter={ () => ( { dimRatio: url ? 50 : 100, } ) } @@ -294,15 +284,14 @@ export default function CoverInspectorControls( { __nextHasNoMarginBottom label={ __( 'Overlay opacity' ) } value={ dimRatio } - onChange={ ( newDimRation ) => - setAttributes( { - dimRatio: newDimRation, - } ) + onChange={ ( newDimRatio ) => + updateDimRatio( newDimRatio ) } min={ 0 } max={ 100 } step={ 10 } required + __next40pxDefaultSize /> diff --git a/packages/block-library/src/cover/edit/resizable-cover-popover.js b/packages/block-library/src/cover/edit/resizable-cover-popover.js index cf37294f95f018..7db8b0cc50b883 100644 --- a/packages/block-library/src/cover/edit/resizable-cover-popover.js +++ b/packages/block-library/src/cover/edit/resizable-cover-popover.js @@ -12,7 +12,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const RESIZABLE_BOX_ENABLE_OPTION = { top: false, diff --git a/packages/block-library/src/cover/edit/use-cover-is-dark.js b/packages/block-library/src/cover/edit/use-cover-is-dark.js deleted file mode 100644 index a0966f7c289183..00000000000000 --- a/packages/block-library/src/cover/edit/use-cover-is-dark.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * External dependencies - */ -import { FastAverageColor } from 'fast-average-color'; -import { colord } from 'colord'; - -/** - * WordPress dependencies - */ -import { useEffect, useState } from '@wordpress/element'; -import { applyFilters } from '@wordpress/hooks'; - -function retrieveFastAverageColor() { - if ( ! retrieveFastAverageColor.fastAverageColor ) { - retrieveFastAverageColor.fastAverageColor = new FastAverageColor(); - } - return retrieveFastAverageColor.fastAverageColor; -} - -/** - * useCoverIsDark is a hook that returns a boolean variable specifying if the cover - * background is dark or not. - * - * @param {?string} url Url of the media background. - * @param {?number} dimRatio Transparency of the overlay color. If an image and - * color are set, dimRatio is used to decide what is used - * for background darkness checking purposes. - * @param {?string} overlayColor String containing the overlay color value if one exists. - * - * @return {boolean} True if the cover background is considered "dark" and false otherwise. - */ -export default function useCoverIsDark( url, dimRatio = 50, overlayColor ) { - const [ isDark, setIsDark ] = useState( false ); - useEffect( () => { - // If opacity is lower than 50 the dominant color is the image or video color, - // so use that color for the dark mode computation. - if ( url && dimRatio <= 50 ) { - const imgCrossOrigin = applyFilters( - 'media.crossOrigin', - undefined, - url - ); - retrieveFastAverageColor() - .getColorAsync( url, { - // Previously the default color was white, but that changed - // in v6.0.0 so it has to be manually set now. - defaultColor: [ 255, 255, 255, 255 ], - // Errors that come up don't reject the promise, so error - // logging has to be silenced with this option. - silent: process.env.NODE_ENV === 'production', - crossOrigin: imgCrossOrigin, - } ) - .then( ( color ) => setIsDark( color.isDark ) ); - } - }, [ url, url && dimRatio <= 50, setIsDark ] ); - useEffect( () => { - // If opacity is greater than 50 the dominant color is the overlay color, - // so use that color for the dark mode computation. - if ( dimRatio > 50 || ! url ) { - if ( ! overlayColor ) { - // If no overlay color exists the overlay color is black (isDark ) - setIsDark( true ); - return; - } - setIsDark( colord( overlayColor ).isDark() ); - } - }, [ overlayColor, dimRatio > 50 || ! url, setIsDark ] ); - useEffect( () => { - if ( ! url && ! overlayColor ) { - // Reset isDark. - setIsDark( false ); - } - }, [ ! url && ! overlayColor, setIsDark ] ); - return isDark; -} diff --git a/packages/block-library/src/cover/shared.js b/packages/block-library/src/cover/shared.js index 0e18d28372764e..72a42c0d5c70b8 100644 --- a/packages/block-library/src/cover/shared.js +++ b/packages/block-library/src/cover/shared.js @@ -1,7 +1,14 @@ +/** + * External dependencies + */ +import { FastAverageColor } from 'fast-average-color'; +import { colord } from 'colord'; + /** * WordPress dependencies */ import { getBlobTypeByURL, isBlobURL } from '@wordpress/blob'; +import { applyFilters } from '@wordpress/hooks'; const POSITION_CLASSNAMES = { 'top left': 'is-position-top-left', @@ -35,9 +42,9 @@ export function dimRatioToClass( ratio ) { } export function attributesFromMedia( setAttributes, dimRatio ) { - return ( media ) => { + return ( media, isDark ) => { if ( ! media || ! media.url ) { - setAttributes( { url: undefined, id: undefined } ); + setAttributes( { url: undefined, id: undefined, isDark } ); return; } @@ -67,6 +74,7 @@ export function attributesFromMedia( setAttributes, dimRatio ) { } setAttributes( { + isDark, dimRatio: dimRatio === 100 ? 50 : dimRatio, url: media.url, id: media.id, @@ -109,3 +117,86 @@ export function getPositionClassName( contentPosition ) { return POSITION_CLASSNAMES[ contentPosition ]; } + +/** + * Performs a Porter Duff composite source over operation on two rgba colors. + * + * @see https://www.w3.org/TR/compositing-1/#porterduffcompositingoperators_srcover + * + * @param {import('colord').RgbaColor} source Source color. + * @param {import('colord').RgbaColor} dest Destination color. + * @return {import('colord').RgbaColor} Composite color. + */ +function compositeSourceOver( source, dest ) { + return { + r: source.r * source.a + dest.r * dest.a * ( 1 - source.a ), + g: source.g * source.a + dest.g * dest.a * ( 1 - source.a ), + b: source.b * source.a + dest.b * dest.a * ( 1 - source.a ), + a: source.a + dest.a * ( 1 - source.a ), + }; +} + +function retrieveFastAverageColor() { + if ( ! retrieveFastAverageColor.fastAverageColor ) { + retrieveFastAverageColor.fastAverageColor = new FastAverageColor(); + } + return retrieveFastAverageColor.fastAverageColor; +} + +/** + * This method evaluates if the cover block's background is dark or not and this boolean + * can then be applied to the relevant attribute to help ensure that text is visible by default. + * This needs to be recalculated in all of the following Cover block scenarios: + * - When an overlay image is added, changed or removed + * - When the featured image is selected as the overlay image, or removed from the overlay + * - When the overlay color is changed + * - When the overlay color is removed + * - When the dimRatio is changed + * + * See the comments below for more details about which aspects take priority when + * calculating the relative darkness of the Cover. + * + * @param {string} url + * @param {number} dimRatio + * @param {string} overlayColor + * @return {Promise} True if cover should be considered to be dark. + */ +export async function getCoverIsDark( url, dimRatio = 50, overlayColor ) { + const overlay = colord( overlayColor ) + .alpha( dimRatio / 100 ) + .toRgb(); + + if ( url ) { + try { + const imgCrossOrigin = applyFilters( + 'media.crossOrigin', + undefined, + url + ); + const { + value: [ r, g, b, a ], + } = await retrieveFastAverageColor().getColorAsync( url, { + // Previously the default color was white, but that changed + // in v6.0.0 so it has to be manually set now. + defaultColor: [ 255, 255, 255, 255 ], + // Errors that come up don't reject the promise, so error + // logging has to be silenced with this option. + silent: process.env.NODE_ENV === 'production', + crossOrigin: imgCrossOrigin, + } ); + // FAC uses 0-255 for alpha, but colord expects 0-1. + const media = { r, g, b, a: a / 255 }; + const composite = compositeSourceOver( overlay, media ); + return colord( composite ).isDark(); + } catch ( error ) { + // If there's an error, just assume the image is dark. + return true; + } + } + + // Assume a white background because it isn't easy to get the actual + // parent background color. + const background = { r: 255, g: 255, b: 255, a: 1 }; + const composite = compositeSourceOver( overlay, background ); + return colord( composite ).isDark(); +} diff --git a/packages/block-library/src/cover/style.native.scss b/packages/block-library/src/cover/style.native.scss index c121f9cb35bdeb..15ca9bcc5105ee 100644 --- a/packages/block-library/src/cover/style.native.scss +++ b/packages/block-library/src/cover/style.native.scss @@ -207,3 +207,14 @@ .mediaPlaceholderEmptyStateContainer { height: 300; } + +.selectedColorText { + font-family: $default-monospace-font; + color: $light-primary; + font-size: 16px; + font-weight: 400; +} + +.selectedColorTextDark { + color: $dark-primary; +} diff --git a/packages/block-library/src/cover/test/edit.js b/packages/block-library/src/cover/test/edit.js index e399f379e21553..23005d1d0a8dbd 100644 --- a/packages/block-library/src/cover/test/edit.js +++ b/packages/block-library/src/cover/test/edit.js @@ -18,7 +18,10 @@ const defaultSettings = { defaultPalette: true, defaultGradients: true, palette: { - default: [ { name: 'Black', slug: 'black', color: '#000000' } ], + default: [ + { name: 'Black', slug: 'black', color: '#000000' }, + { name: 'White', slug: 'white', color: '#ffffff' }, + ], }, }, }, @@ -281,15 +284,18 @@ describe( 'Cover block', () => { name: 'Styles', } ) ); - - fireEvent.change( - screen.getByRole( 'spinbutton', { - name: 'Overlay opacity', - } ), - { - target: { value: '40' }, - } - ); + // Need act here as the isDark method is async. + // eslint-disable-next-line testing-library/no-unnecessary-act + await act( async () => { + fireEvent.change( + screen.getByRole( 'spinbutton', { + name: 'Overlay opacity', + } ), + { + target: { value: '40' }, + } + ); + } ); expect( overlay[ 0 ] ).toHaveClass( 'has-background-dim-40' ); } ); @@ -312,12 +318,16 @@ describe( 'Cover block', () => { } ) ); - fireEvent.change( - screen.getByRole( 'slider', { - name: 'Overlay opacity', - } ), - { target: { value: 30 } } - ); + // Need act here as the isDark method is async. + // eslint-disable-next-line testing-library/no-unnecessary-act + await act( async () => { + fireEvent.change( + screen.getByRole( 'slider', { + name: 'Overlay opacity', + } ), + { target: { value: 30 } } + ); + } ); expect( overlay[ 0 ] ).toHaveClass( 'has-background-dim-30' ); } ); @@ -375,4 +385,54 @@ describe( 'Cover block', () => { } ); } ); } ); + + describe( 'isDark settings', () => { + test( 'should toggle is-light class if background changed from light to dark', async () => { + await setup(); + const colorPicker = screen.getByRole( 'button', { + name: 'Color: White', + } ); + await userEvent.click( colorPicker ); + + const coverBlock = screen.getByLabelText( 'Block: Cover' ); + + expect( coverBlock ).toHaveClass( 'is-light' ); + + await selectBlock( 'Block: Cover' ); + await userEvent.click( + screen.getByRole( 'tab', { + name: 'Styles', + } ) + ); + await userEvent.click( screen.getByText( 'Overlay' ) ); + const popupColorPicker = screen.getByRole( 'button', { + name: 'Color: Black', + } ); + await userEvent.click( popupColorPicker ); + expect( coverBlock ).not.toHaveClass( 'is-light' ); + } ); + test( 'should remove is-light class if overlay color is removed', async () => { + await setup(); + const colorPicker = screen.getByRole( 'button', { + name: 'Color: White', + } ); + await userEvent.click( colorPicker ); + const coverBlock = screen.getByLabelText( 'Block: Cover' ); + expect( coverBlock ).toHaveClass( 'is-light' ); + await selectBlock( 'Block: Cover' ); + await userEvent.click( + screen.getByRole( 'tab', { + name: 'Styles', + } ) + ); + await userEvent.click( screen.getByText( 'Overlay' ) ); + // The default color is black, so clicking the black color option will remove the background color, + // which should remove the isDark setting and assign the is-light class. + const popupColorPicker = screen.getByRole( 'button', { + name: 'Color: White', + } ); + await userEvent.click( popupColorPicker ); + expect( coverBlock ).not.toHaveClass( 'is-light' ); + } ); + } ); } ); diff --git a/packages/block-library/src/cover/test/edit.native.js b/packages/block-library/src/cover/test/edit.native.js index 6a5bef376c18f8..8ca3d40967a27b 100644 --- a/packages/block-library/src/cover/test/edit.native.js +++ b/packages/block-library/src/cover/test/edit.native.js @@ -7,7 +7,7 @@ import { initializeEditor, render, fireEvent, - waitFor, + waitForModalVisible, within, getBlock, openBlockSettings, @@ -185,14 +185,14 @@ describe( 'when an image is attached', () => { } ); it( 'toggles a fixed background', async () => { - const { getByText } = render( + const screen = render( ); - const fixedBackgroundButton = await waitFor( () => - getByText( 'Fixed background' ) + const fixedBackgroundButton = await screen.findByText( + 'Fixed background' ); fireEvent.press( fixedBackgroundButton ); @@ -215,11 +215,15 @@ describe( 'when an image is attached', () => { ); fireEvent.press( editFocalPointButton ); fireEvent( - screen.getByTestId( 'Slider Y-Axis Position' ), + screen.getByTestId( 'Slider Y-Axis Position', { hidden: true } ), 'valueChange', '52' ); fireEvent.press( screen.getByLabelText( 'Apply' ) ); + // TODO(jest-console): Fix the warning and remove the expect below. + expect( console ).toHaveWarnedWith( + `Non-serializable values were found in the navigation state. Check:\n\nFocalPoint > params.onFocalPointChange (Function)\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.` + ); expect( setAttributes ).toHaveBeenCalledWith( expect.objectContaining( { @@ -240,10 +244,12 @@ describe( 'when an image is attached', () => { ); fireEvent.press( editFocalPointButton ); fireEvent.press( - screen.getByText( ( attributes.focalPoint.x * 100 ).toString() ) + screen.getByText( ( attributes.focalPoint.x * 100 ).toString(), { + hidden: true, + } ) ); fireEvent.changeText( - screen.getByLabelText( 'X-Axis Position' ), + screen.getByLabelText( 'X-Axis Position', { hidden: true } ), '99' ); fireEvent.press( screen.getByLabelText( 'Apply' ) ); @@ -256,21 +262,26 @@ describe( 'when an image is attached', () => { } ); it( 'discards canceled focal point changes', async () => { - const { getByText, getByLabelText } = render( + const screen = render( ); - const editFocalPointButton = await waitFor( () => - getByText( 'Edit focal point' ) + const editFocalPointButton = await screen.findByText( + 'Edit focal point' ); fireEvent.press( editFocalPointButton ); fireEvent.press( - getByText( ( attributes.focalPoint.x * 100 ).toString() ) + screen.getByText( ( attributes.focalPoint.x * 100 ).toString(), { + hidden: true, + } ) ); - fireEvent.changeText( getByLabelText( 'X-Axis Position' ), '80' ); - fireEvent.press( getByLabelText( 'Go back' ) ); + fireEvent.changeText( + screen.getByLabelText( 'X-Axis Position', { hidden: true } ), + '80' + ); + fireEvent.press( screen.getByLabelText( 'Go back' ) ); expect( setAttributes ).not.toHaveBeenCalledWith( expect.objectContaining( { @@ -314,9 +325,13 @@ describe( 'when an image is attached', () => { // Update Opacity attribute const opacityControl = getByLabelText( /Opacity/ ); - fireEvent.press( within( opacityControl ).getByText( '50' ) ); - const heightTextInput = - within( opacityControl ).getByDisplayValue( '50' ); + fireEvent.press( + within( opacityControl ).getByText( '50', { hidden: true } ) + ); + const heightTextInput = within( opacityControl ).getByDisplayValue( + '50', + { hidden: true } + ); fireEvent.changeText( heightTextInput, '20' ); // The decreasing button should be disabled @@ -356,7 +371,7 @@ describe( 'color settings', () => { // Wait for Block Settings to be visible. const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Open the overlay color settings. const colorOverlay = await screen.findByLabelText( 'Color. Empty' ); @@ -366,6 +381,10 @@ describe( 'color settings', () => { // Find the selected color. const colorPaletteButton = await screen.findByTestId( COLOR_PINK ); expect( colorPaletteButton ).toBeDefined(); + // TODO(jest-console): Fix the warning and remove the expect below. + expect( console ).toHaveWarnedWith( + `Non-serializable values were found in the navigation state. Check:\n\nColor > params.onColorChange (Function)\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.` + ); // Select another color. const newColorButton = await screen.findByTestId( COLOR_RED ); @@ -391,7 +410,7 @@ describe( 'color settings', () => { // Wait for Block Settings to be visible. const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Open the overlay color settings. const colorOverlay = await screen.findByLabelText( 'Color. Empty' ); @@ -447,7 +466,7 @@ describe( 'color settings', () => { // Wait for Block Settings to be visible. const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Open the overlay color settings. const colorOverlay = await screen.findByLabelText( 'Color. Empty' ); @@ -503,7 +522,7 @@ describe( 'color settings', () => { // Wait for Block Settings to be visible. const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Open the overlay color settings. const colorOverlay = await screen.findByLabelText( 'Color. Empty' ); @@ -519,6 +538,36 @@ describe( 'color settings', () => { expect( getEditorHtml() ).toMatchSnapshot(); } ); + + it( 'displays the hex color value in the custom color picker', async () => { + const screen = await initializeEditor( { + initialHtml: COVER_BLOCK_PLACEHOLDER_HTML, + } ); + + // Select a color from the placeholder palette. + const colorButton = screen.getByA11yHint( + 'Navigates to custom color picker' + ); + fireEvent.press( colorButton ); + + // Wait for Block Settings to be visible. + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); + + // Assert label text before tapping color picker + expect( screen.getByText( 'Select a color' ) ).toBeVisible(); + + // Tap color picker + const colorPicker = screen.getByTestId( 'hsv-color-picker' ); + fireEvent( colorPicker, 'onHuePickerPress', { + hue: 120, + saturation: 12, + value: 50, + } ); + + // Assert label hex value after tapping color picker + expect( screen.getByText( '#00FF00' ) ).toBeVisible(); + } ); } ); describe( 'minimum height settings', () => { @@ -536,12 +585,12 @@ describe( 'minimum height settings', () => { await openBlockSettings( screen ); // Set vw unit - fireEvent.press( getByText( 'px' ) ); - fireEvent.press( getByText( 'Viewport width (vw)' ) ); + fireEvent.press( getByText( 'px', { hidden: true } ) ); + fireEvent.press( getByText( 'Viewport width (vw)', { hidden: true } ) ); // Update height attribute - fireEvent.press( getByText( '300' ) ); - const heightTextInput = getByDisplayValue( '300' ); + fireEvent.press( getByText( '300', { hidden: true } ) ); + const heightTextInput = getByDisplayValue( '300', { hidden: true } ); fireEvent.changeText( heightTextInput, '20' ); expect( getEditorHtml() ).toMatchSnapshot(); @@ -561,8 +610,8 @@ describe( 'minimum height settings', () => { await openBlockSettings( screen ); // Set the pixel unit - fireEvent.press( getByText( 'vw' ) ); - fireEvent.press( getByText( 'Pixels (px)' ) ); + fireEvent.press( getByText( 'vw', { hidden: true } ) ); + fireEvent.press( getByText( 'Pixels (px)', { hidden: true } ) ); expect( getEditorHtml() ).toMatchSnapshot(); } ); @@ -592,14 +641,17 @@ describe( 'minimum height settings', () => { await openBlockSettings( screen ); // Set the unit name - fireEvent.press( getByText( 'vw' ) ); - fireEvent.press( getByText( unitName ) ); + fireEvent.press( getByText( 'vw', { hidden: true } ) ); + fireEvent.press( getByText( unitName, { hidden: true } ) ); // Update height attribute const heightControl = getByLabelText( /Minimum height/ ); - fireEvent.press( within( heightControl ).getByText( value ) ); - const heightTextInput = - within( heightControl ).getByDisplayValue( value ); + fireEvent.press( + within( heightControl ).getByText( value, { hidden: true } ) + ); + const heightTextInput = within( + heightControl + ).getByDisplayValue( value, { hidden: true } ); fireEvent.changeText( heightTextInput, minValue ); // The decreasing button should be disabled diff --git a/packages/block-library/src/cover/transforms.js b/packages/block-library/src/cover/transforms.js index adf7bfe0997e3d..c9526c025ba9ba 100644 --- a/packages/block-library/src/cover/transforms.js +++ b/packages/block-library/src/cover/transforms.js @@ -8,7 +8,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; * Internal dependencies */ import { IMAGE_BACKGROUND_TYPE, VIDEO_BACKGROUND_TYPE } from './shared'; -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; const { cleanEmptyObject } = unlock( blockEditorPrivateApis ); diff --git a/packages/block-library/src/details/block.json b/packages/block-library/src/details/block.json index 40321ee6b0c9c1..d449d42e1e10c4 100644 --- a/packages/block-library/src/details/block.json +++ b/packages/block-library/src/details/block.json @@ -1,12 +1,11 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, - "__experimental": true, + "apiVersion": 3, "name": "core/details", "title": "Details", "category": "text", "description": "Hide and show additional content.", - "keywords": [ "disclosure", "summary", "hide", "accordion" ], + "keywords": [ "accordion", "summary", "toggle", "disclosure" ], "textdomain": "default", "attributes": { "showContent": { @@ -14,7 +13,9 @@ "default": false }, "summary": { - "type": "string" + "type": "string", + "source": "html", + "selector": "summary" } }, "supports": { @@ -35,7 +36,12 @@ "html": false, "spacing": { "margin": true, - "padding": true + "padding": true, + "blockGap": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "typography": { "fontSize": true, @@ -49,6 +55,9 @@ "__experimentalDefaultControls": { "fontSize": true } + }, + "layout": { + "allowEditing": false } }, "editorStyle": "wp-block-details-editor", diff --git a/packages/block-library/src/details/edit.js b/packages/block-library/src/details/edit.js index bc0c206aa2d26d..81e4d7a52056a7 100644 --- a/packages/block-library/src/details/edit.js +++ b/packages/block-library/src/details/edit.js @@ -30,15 +30,18 @@ function DetailsEdit( { attributes, setAttributes, clientId } ) { } ); // Check if either the block or the inner blocks are selected. - const hasSelection = useSelect( ( select ) => { - const { isBlockSelected, hasSelectedInnerBlock } = - select( blockEditorStore ); - /* Sets deep to true to also find blocks inside the details content block. */ - return ( - hasSelectedInnerBlock( clientId, true ) || - isBlockSelected( clientId ) - ); - }, [] ); + const hasSelection = useSelect( + ( select ) => { + const { isBlockSelected, hasSelectedInnerBlock } = + select( blockEditorStore ); + /* Sets deep to true to also find blocks inside the details content block. */ + return ( + hasSelectedInnerBlock( clientId, true ) || + isBlockSelected( clientId ) + ); + }, + [ clientId ] + ); return ( <> diff --git a/packages/block-library/src/details/init.js b/packages/block-library/src/details/init.js new file mode 100644 index 00000000000000..79f0492c2cb2f8 --- /dev/null +++ b/packages/block-library/src/details/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/packages/block-library/src/details/style.scss b/packages/block-library/src/details/style.scss index 415a3210a50aeb..904e056d049de7 100644 --- a/packages/block-library/src/details/style.scss +++ b/packages/block-library/src/details/style.scss @@ -6,14 +6,3 @@ .wp-block-details summary { cursor: pointer; } - -// Use block gap for block; falls back to browser default if not supported. -.wp-block-details > *:not(summary) { - margin-block-start: var(--wp--style--block-gap); - margin-block-end: 0; -} - -// Remove excess margin from the last block. -.wp-block-details > *:last-child { - margin-bottom: 0; -} diff --git a/packages/block-library/src/embed/block.json b/packages/block-library/src/embed/block.json index e56c5b894dabe7..9ca54db871db19 100644 --- a/packages/block-library/src/embed/block.json +++ b/packages/block-library/src/embed/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/embed", "title": "Embed", "category": "embed", diff --git a/packages/block-library/src/embed/embed-link-settings.native.js b/packages/block-library/src/embed/embed-link-settings.native.js index ad4152c2fe19a8..9c3fbffc8a196b 100644 --- a/packages/block-library/src/embed/embed-link-settings.native.js +++ b/packages/block-library/src/embed/embed-link-settings.native.js @@ -90,7 +90,6 @@ const EmbedLinkSettings = ( { onDismiss={ onDismiss } setAttributes={ onSetAttributes } options={ linkSettingsOptions } - testID="embed-edit-url-modal" withBottomSheet={ withBottomSheet } showIcon /> diff --git a/packages/block-library/src/embed/embed-placeholder.native.js b/packages/block-library/src/embed/embed-placeholder.native.js index 966b96c939217c..35b71b3ab988e1 100644 --- a/packages/block-library/src/embed/embed-placeholder.native.js +++ b/packages/block-library/src/embed/embed-placeholder.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { View, Text, TouchableWithoutFeedback } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; /** * WordPress dependencies @@ -18,6 +18,8 @@ import { useRef } from '@wordpress/element'; import styles from './styles.scss'; import { noticeOutline } from '../../../components/src/mobile/gridicons'; +const hitSlop = { top: 22, bottom: 22, left: 22, right: 22 }; + const EmbedPlaceholder = ( { icon, isSelected, @@ -28,10 +30,17 @@ const EmbedPlaceholder = ( { tryAgain, openEmbedLinkSettings, } ) => { - const containerStyle = usePreferredColorSchemeStyle( - styles.embed__container, - styles[ 'embed__container--dark' ] + const containerSelectedStyle = usePreferredColorSchemeStyle( + styles[ 'embed__container-selected' ], + styles[ 'embed__container-selected--dark' ] ); + const containerStyle = [ + usePreferredColorSchemeStyle( + styles.embed__container, + styles[ 'embed__container--dark' ] + ), + isSelected && containerSelectedStyle, + ]; const labelStyle = usePreferredColorSchemeStyle( styles.embed__label, styles[ 'embed__label--dark' ] @@ -44,6 +53,15 @@ const EmbedPlaceholder = ( { ); const embedIconErrorStyle = styles[ 'embed__icon--error' ]; + const buttonStyles = usePreferredColorSchemeStyle( + styles.embed__button, + styles[ 'embed__button--dark' ] + ); + const iconStyles = usePreferredColorSchemeStyle( + styles.embed__icon, + styles[ 'embed__icon--dark' ] + ); + const cannotEmbedMenuPickerRef = useRef(); const errorPickerOptions = { @@ -89,55 +107,70 @@ const EmbedPlaceholder = ( { return ( <> - - - { cannotEmbed ? ( - <> - - - { __( 'Unable to embed media' ) } - + + { cannotEmbed ? ( + <> + + + { __( 'Unable to embed media' ) } + + { __( 'More options' ) } - - - ) : ( - <> - + + + + ) : ( + <> + + { label } + + - { __( 'ADD LINK' ) } + { __( 'Add link' ) } - - ) } - - + + + ) } + ); }; diff --git a/packages/block-library/src/embed/styles.native.scss b/packages/block-library/src/embed/styles.native.scss index fade4204a975bf..d7f07bc259361f 100644 --- a/packages/block-library/src/embed/styles.native.scss +++ b/packages/block-library/src/embed/styles.native.scss @@ -4,19 +4,28 @@ flex-direction: column; align-items: center; justify-content: center; - background-color: $gray-lighten-30; + background-color: #e0e0e0; // $light-dim padding-left: 12; padding-right: 12; padding-top: 12; padding-bottom: 12; - border-top-left-radius: 4; - border-top-right-radius: 4; - border-bottom-left-radius: 4; - border-bottom-right-radius: 4; + border-top-left-radius: 2; + border-top-right-radius: 2; + border-bottom-left-radius: 2; + border-bottom-right-radius: 2; } .embed__container--dark { - background-color: $background-dark-secondary; + background-color: #1f1f1f; // $dark-dim +} + +.embed__container-selected { + border-width: 2px; + border-color: $blue-40; +} + +.embed__container-selected--dark { + border-color: $blue-50; } .embed__icon--error { @@ -24,23 +33,38 @@ fill: $alert-red; } +.embed__placeholder-header { + flex-direction: row; + align-items: center; + margin-top: 4; + margin-bottom: 16; +} + +.embed__icon { + fill: $light-secondary; +} + +.embed__icon--dark { + fill: $dark-tertiary; +} + .embed__label { text-align: center; - margin-top: 4; - margin-bottom: 4; - font-size: 14; - font-weight: 500; - color: $gray-90; + margin-left: $grid-unit; + font-size: 16; + font-weight: 400; + color: $light-secondary; } .embed__label--dark { - color: $gray-10; + color: $dark-tertiary; } .embed__description { font-size: $default-font-size; text-align: center; - margin-bottom: 4; + margin-top: 4; + margin-bottom: 16; color: $light-secondary; } @@ -53,12 +77,14 @@ } .embed__action { - width: 100%; text-align: center; - color: $blue-wordpress; - font-size: 14; - font-weight: 500; - margin-top: 4; + color: $white; + font-size: 16; + font-weight: 400; +} + +.embed__action--dark { + color: $black; } .embed-preview__loading { @@ -158,3 +184,13 @@ .embed-no-preview__sheet-button--dark { color: $blue-30; } + +.embed__button { + background-color: $light-primary; + border-radius: 3px; + padding: $grid-unit $grid-unit-20; +} + +.embed__button--dark { + background-color: $dark-primary; +} diff --git a/packages/block-library/src/embed/test/index.native.js b/packages/block-library/src/embed/test/index.native.js index d4b7fa99ded83e..b13b86b40d3fd3 100644 --- a/packages/block-library/src/embed/test/index.native.js +++ b/packages/block-library/src/embed/test/index.native.js @@ -6,6 +6,7 @@ import { initializeEditor, fireEvent, waitFor, + waitForModalVisible, within, } from 'test/helpers'; import { Platform } from 'react-native'; @@ -199,6 +200,10 @@ beforeEach( () => { MOCK_EMBED_PHOTO_SUCCESS_RESPONSE, MOCK_BAD_EMBED_PROVIDER_RESPONSE, ] ); + + // Intentionally suppress the expected console logs to reduce noise in the + // test output. + jest.spyOn( console, 'log' ).mockImplementation( () => {} ); } ); afterAll( () => { @@ -236,9 +241,9 @@ describe( 'Embed block', () => { // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Dismiss the edit URL modal. fireEvent( embedEditURLModal, 'backdropPress' ); @@ -254,9 +259,9 @@ describe( 'Embed block', () => { // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Set an URL. const linkTextInput = editor.getByPlaceholderText( 'Add link' ); @@ -294,9 +299,9 @@ describe( 'Embed block', () => { // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Get embed link with auto-pasted URL. const autopastedLinkField = await editor.findByText( clipboardURL ); @@ -329,13 +334,13 @@ describe( 'Embed block', () => { const editor = await initializeWithEmbedBlock( EMPTY_EMBED_HTML ); // Edit URL. - fireEvent.press( await editor.findByText( 'ADD LINK' ) ); + fireEvent.press( await editor.findByText( 'Add link' ) ); // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Dismiss the edit URL modal. fireEvent( embedEditURLModal, 'backdropPress' ); @@ -350,13 +355,13 @@ describe( 'Embed block', () => { const editor = await initializeWithEmbedBlock( EMPTY_EMBED_HTML ); // Edit URL. - fireEvent.press( editor.getByText( 'ADD LINK' ) ); + fireEvent.press( editor.getByText( 'Add link' ) ); // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Set an URL. const linkTextInput = editor.getByPlaceholderText( 'Add link' ); @@ -391,13 +396,13 @@ describe( 'Embed block', () => { const editor = await initializeWithEmbedBlock( EMPTY_EMBED_HTML ); // Edit URL. - fireEvent.press( editor.getByText( 'ADD LINK' ) ); + fireEvent.press( editor.getByText( 'Add link' ) ); // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Get embed link. const embedLink = await editor.findByText( clipboardURL ); @@ -438,7 +443,7 @@ describe( 'Embed block', () => { const blockSettingsModal = editor.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Dismiss the Block Settings modal. fireEvent( blockSettingsModal, 'backdropPress' ); @@ -462,7 +467,7 @@ describe( 'Embed block', () => { const blockSettingsModal = editor.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Start editing link. fireEvent.press( @@ -507,7 +512,7 @@ describe( 'Embed block', () => { const blockSettingsModal = editor.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Start editing link. fireEvent.press( @@ -579,9 +584,9 @@ describe( 'Embed block', () => { // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Dismiss the edit URL modal. fireEvent( embedEditURLModal, 'backdropPress' ); @@ -591,24 +596,22 @@ describe( 'Embed block', () => { fireEvent.press( editor.block ); // Edit URL. - fireEvent.press( editor.getByText( 'ADD LINK' ) ); + fireEvent.press( editor.getByText( 'Add link' ) ); // Wait for edit URL modal to be visible. - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Dismiss the edit URL modal. fireEvent( embedEditURLModal, 'backdropPress' ); fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); // Edit URL. - fireEvent.press( editor.getByText( 'ADD LINK' ) ); + fireEvent.press( editor.getByText( 'Add link' ) ); // Wait for edit URL modal to be visible. - const isVisibleThirdTime = await waitFor( - () => embedEditURLModal.props.isVisible - ); + await waitForModalVisible( embedEditURLModal ); - expect( isVisibleThirdTime ).toBeTruthy(); + expect( embedEditURLModal.props.isVisible ).toBe( true ); } ); // This test case covers the bug fixed in PR #35013. @@ -620,9 +623,9 @@ describe( 'Embed block', () => { // Wait for edit URL modal to be visible. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); - await waitFor( () => embedEditURLModal.props.isVisible ); + await waitForModalVisible( embedEditURLModal ); // Set an bad URL. let linkTextInput = editor.getByPlaceholderText( 'Add link' ); @@ -640,7 +643,7 @@ describe( 'Embed block', () => { const blockSettingsModal = editor.getByTestId( 'block-settings-modal' ); - await waitFor( () => blockSettingsModal.props.isVisible ); + await waitForModalVisible( blockSettingsModal ); // Start editing link. fireEvent.press( @@ -801,7 +804,7 @@ describe( 'Embed block', () => { // Dismiss the edit URL modal. const embedEditURLModal = editor.getByTestId( - 'embed-edit-url-modal' + 'link-settings-navigation' ); fireEvent( embedEditURLModal, 'backdropPress' ); fireEvent( embedEditURLModal, MODAL_DISMISS_EVENT ); @@ -832,7 +835,7 @@ describe( 'Embed block', () => { // Wait for no preview modal to be visible. const noPreviewModal = getByTestId( 'embed-no-preview-modal' ); - await waitFor( () => noPreviewModal.props.isVisible ); + await waitForModalVisible( noPreviewModal ); // Preview post. fireEvent.press( getByText( 'Preview post' ) ); @@ -854,13 +857,15 @@ describe( 'Embed block', () => { // Wait for no preview modal to be visible. const noPreviewModal = getByTestId( 'embed-no-preview-modal' ); - await waitFor( () => noPreviewModal.props.isVisible ); + await waitForModalVisible( noPreviewModal ); // Dismiss modal. fireEvent.press( getByText( 'Dismiss' ) ); // Wait for no preview modal to be not visible. - await waitFor( () => ! noPreviewModal.props.isVisible ); + await waitFor( () => + expect( noPreviewModal.props.isVisible ).toBe( false ) + ); expect( requestPreview ).not.toHaveBeenCalled(); } ); @@ -893,10 +898,14 @@ describe( 'Embed block', () => { const embedHandlerPicker = editor.getByTestId( 'embed-handler-picker' ); - await waitFor( () => embedHandlerPicker.props.isVisible ); + await waitForModalVisible( embedHandlerPicker ); // Select create embed option. fireEvent.press( editor.getByText( 'Create embed' ) ); + expect( console ).toHaveLoggedWith( + 'Processed HTML piece:\n\n', + `

${ expectedURL }

` + ); // Get the created embed block. const [ embedBlock ] = await editor.findAllByLabelText( @@ -937,16 +946,18 @@ describe( 'Embed block', () => { const embedHandlerPicker = editor.getByTestId( 'embed-handler-picker' ); - await waitFor( () => embedHandlerPicker.props.isVisible ); + await waitForModalVisible( embedHandlerPicker ); // Select create link option. fireEvent.press( editor.getByText( 'Create link' ) ); + expect( console ).toHaveLoggedWith( + 'Processed HTML piece:\n\n', + `

${ expectedURL }

` + ); // Get the link text. - const linkText = await waitFor( () => - editor.getByDisplayValue( - `

${ expectedURL }

` - ) + const linkText = await editor.findByDisplayValue( + `

${ expectedURL }

` ); expect( linkText ).toBeDefined(); @@ -1041,11 +1052,10 @@ describe( 'Embed block', () => { it( 'sets block caption', async () => { const expectedCaption = 'Caption'; - const { getByPlaceholderText, getByDisplayValue } = - await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + const screen = await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); // Set a caption. - const captionField = getByPlaceholderText( 'Add caption' ); + const captionField = screen.getByPlaceholderText( 'Add caption' ); fireEvent( captionField, 'focus' ); fireEvent( captionField, 'onChange', { nativeEvent: { @@ -1056,8 +1066,8 @@ describe( 'Embed block', () => { } ); // Get current caption. - const caption = await waitFor( () => - getByDisplayValue( `

${ expectedCaption }

` ) + const caption = await screen.findByDisplayValue( + `

${ expectedCaption }

` ); expect( caption ).toBeDefined(); @@ -1086,36 +1096,32 @@ describe( 'Embed block', () => { describe( 'block settings', () => { it( 'toggles resize for smaller devices media settings', async () => { - const { getByLabelText, getByText } = - await initializeWithEmbedBlock( RICH_TEXT_EMBED_HTML ); + const screen = await initializeWithEmbedBlock( + RICH_TEXT_EMBED_HTML + ); // Open Block Settings. - fireEvent.press( - await waitFor( () => getByLabelText( 'Open Settings' ) ) - ); + fireEvent.press( await screen.findByLabelText( 'Open Settings' ) ); // Untoggle resize for smaller devices. fireEvent.press( - await waitFor( () => getByText( /Resize for smaller devices/ ) ) + await screen.findByText( /Resize for smaller devices/ ) ); expect( getEditorHtml() ).toMatchSnapshot(); } ); it( 'does not show media settings panel if responsive is not supported', async () => { - const { getByLabelText, getByText } = - await initializeWithEmbedBlock( WP_EMBED_HTML ); + const screen = await initializeWithEmbedBlock( WP_EMBED_HTML ); // Open Block Settings. - fireEvent.press( - await waitFor( () => getByLabelText( 'Open Settings' ) ) - ); + fireEvent.press( await screen.findByLabelText( 'Open Settings' ) ); // Wait for media settings panel. let mediaSettingsPanel; try { - mediaSettingsPanel = await waitFor( () => - getByText( 'Media settings' ) + mediaSettingsPanel = await screen.findByText( + 'Media settings' ); } catch ( e ) { // NOOP. diff --git a/packages/block-library/src/embed/util.js b/packages/block-library/src/embed/util.js index 609a46293666e8..a7a6ea219f2772 100644 --- a/packages/block-library/src/embed/util.js +++ b/packages/block-library/src/embed/util.js @@ -1,18 +1,13 @@ -/** - * Internal dependencies - */ -import { ASPECT_RATIOS, WP_EMBED_TYPE } from './constants'; - /** * External dependencies */ -import { kebabCase } from 'lodash'; import classnames from 'classnames/dedupe'; import memoize from 'memize'; /** * WordPress dependencies */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; import { renderToString } from '@wordpress/element'; import { createBlock, @@ -24,8 +19,11 @@ import { * Internal dependencies */ import metadata from './block.json'; +import { ASPECT_RATIOS, WP_EMBED_TYPE } from './constants'; +import { unlock } from '../lock-unlock'; const { name: DEFAULT_EMBED_BLOCK } = metadata; +const { kebabCase } = unlock( blockEditorPrivateApis ); /** @typedef {import('@wordpress/blocks').WPBlockVariation} WPBlockVariation */ diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 08a78f3a94ce42..576fe34f5cf8f7 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/file", "title": "File", "category": "media", @@ -57,6 +57,10 @@ "supports": { "anchor": true, "align": true, + "spacing": { + "margin": true, + "padding": true + }, "color": { "gradients": true, "link": true, diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 9081b63a5e76b4..7dd77b20f466c0 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -5,18 +5,48 @@ * @package WordPress */ +if ( gutenberg_should_block_use_interactivity_api( 'core/file' ) ) { + /** + * Replaces view script for the File block with version using Interactivity API. + * + * @param array $metadata Block metadata as read in via block.json. + * + * @return array Filtered block type metadata. + */ + function gutenberg_block_core_file_update_interactive_view_script( $metadata ) { + if ( 'core/file' === $metadata['name'] ) { + $metadata['viewScript'] = array( 'file:./view-interactivity.min.js' ); + $metadata['supports']['interactivity'] = true; + } + return $metadata; + } + add_filter( 'block_type_metadata', 'gutenberg_block_core_file_update_interactive_view_script', 10, 1 ); +} + /** - * When the `core/file` block is rendering, check if we need to enqueue the `'wp-block-file-view` script. + * When the `core/file` block is rendering, check if we need to enqueue the `wp-block-file-view` script. * - * @param array $attributes The block attributes. - * @param string $content The block content. + * @param array $attributes The block attributes. + * @param string $content The block content. + * @param WP_Block $block The parsed block. * * @return string Returns the block content. */ -function render_block_core_file( $attributes, $content ) { - $should_load_view_script = ! empty( $attributes['displayPreview'] ) && ! wp_script_is( 'wp-block-file-view' ); - if ( $should_load_view_script ) { - wp_enqueue_script( 'wp-block-file-view' ); +function render_block_core_file( $attributes, $content, $block ) { + $should_load_view_script = ! empty( $attributes['displayPreview'] ); + $view_js_file = 'wp-block-file-view'; + // If the script already exists, there is no point in removing it from viewScript. + if ( ! wp_script_is( $view_js_file ) ) { + $script_handles = $block->block_type->view_script_handles; + + // If the script is not needed, and it is still in the `view_script_handles`, remove it. + if ( ! $should_load_view_script && in_array( $view_js_file, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); + } + // If the script is needed, but it was previously removed, add it again. + if ( $should_load_view_script && ! in_array( $view_js_file, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file ) ); + } } // Update object's aria-label attribute if present in block HTML. @@ -25,7 +55,7 @@ function render_block_core_file( $attributes, $content ) { $pattern = '@aria-label="(?[^"]+)?")@i'; $content = preg_replace_callback( $pattern, - function ( $matches ) { + static function ( $matches ) { $filename = ! empty( $matches['filename'] ) ? $matches['filename'] : ''; $has_filename = ! empty( $filename ) && 'PDF embed' !== $filename; $label = $has_filename ? @@ -41,6 +71,17 @@ function ( $matches ) { $content ); + // If it uses the Interactivity API, add the directives. + if ( gutenberg_should_block_use_interactivity_api( 'core/file' ) && $should_load_view_script ) { + $processor = new WP_HTML_Tag_Processor( $content ); + $processor->next_tag(); + $processor->set_attribute( 'data-wp-interactive', '' ); + $processor->next_tag( 'object' ); + $processor->set_attribute( 'data-wp-bind--hidden', '!selectors.core.file.hasPdfPreview' ); + $processor->set_attribute( 'hidden', true ); + return $processor->get_updated_html(); + } + return $content; } diff --git a/packages/block-library/src/file/inspector.js b/packages/block-library/src/file/inspector.js index adef947462057c..76ed28d124600e 100644 --- a/packages/block-library/src/file/inspector.js +++ b/packages/block-library/src/file/inspector.js @@ -58,6 +58,7 @@ export default function FileBlockInspector( { { displayPreview && ( - + File name

", + deleteEnter={true} + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="File name" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": undefined, + "minHeight": 0, + } } - } - textAlign="left" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "

File name

", + } + } + textAlign="left" + triggerKeyCodes={[]} + /> + - + Download

", + color="white" + deleteEnter={true} + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + minWidth={40} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="" + placeholderTextColor="white" + selectionColor="white" + style={ + { + "backgroundColor": undefined, + "color": "white", + "maxWidth": 80, + "minHeight": 0, + } } - } - textAlign="center" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "

Download

", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> +
@@ -250,7 +301,11 @@ exports[`File block renders file without crashing 1`] = ` - + File name

", + deleteEnter={true} + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="File name" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": undefined, + "minHeight": 0, + } } - } - textAlign="left" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "

File name

", + } + } + textAlign="left" + triggerKeyCodes={[]} + /> +
- + Download

", + color="white" + deleteEnter={true} + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + minWidth={40} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="" + placeholderTextColor="white" + selectionColor="white" + style={ + { + "backgroundColor": undefined, + "color": "white", + "maxWidth": 80, + "minHeight": 0, + } } - } - textAlign="center" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "

Download

", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> +
@@ -439,22 +541,13 @@ exports[`File block renders placeholder without crashing 1`] = ` } > + + + + + Path + + + + + File + + - - - Path - - + + Choose a file + - - File - - - CHOOSE A FILE - `; diff --git a/packages/block-library/src/file/view-interactivity.js b/packages/block-library/src/file/view-interactivity.js new file mode 100644 index 00000000000000..9d09ca2b7f4340 --- /dev/null +++ b/packages/block-library/src/file/view-interactivity.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; +/** + * Internal dependencies + */ +import { browserSupportsPdfs as hasPdfPreview } from './utils'; + +store( { + selectors: { + core: { + file: { + hasPdfPreview, + }, + }, + }, +} ); diff --git a/packages/block-library/src/footnotes/block.json b/packages/block-library/src/footnotes/block.json new file mode 100644 index 00000000000000..28b094f24f9164 --- /dev/null +++ b/packages/block-library/src/footnotes/block.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/footnotes", + "title": "Footnotes", + "category": "text", + "description": "", + "keywords": [ "references" ], + "textdomain": "default", + "usesContext": [ "postId", "postType" ], + "supports": { + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "radius": false, + "color": false, + "width": false, + "style": false + } + }, + "color": { + "background": true, + "link": true, + "text": true, + "__experimentalDefaultControls": { + "link": true, + "text": true + } + }, + "html": false, + "multiple": false, + "reusable": false, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalTextDecoration": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalLetterSpacing": true, + "__experimentalTextTransform": true, + "__experimentalWritingMode": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "style": "wp-block-footnotes" +} diff --git a/packages/block-library/src/footnotes/edit.js b/packages/block-library/src/footnotes/edit.js new file mode 100644 index 00000000000000..b8b92170fe217f --- /dev/null +++ b/packages/block-library/src/footnotes/edit.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { BlockIcon, RichText, useBlockProps } from '@wordpress/block-editor'; +import { useEntityProp } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { Placeholder } from '@wordpress/components'; +import { formatListNumbered as icon } from '@wordpress/icons'; + +export default function FootnotesEdit( { context: { postType, postId } } ) { + const [ meta, updateMeta ] = useEntityProp( + 'postType', + postType, + 'meta', + postId + ); + const footnotes = meta?.footnotes ? JSON.parse( meta.footnotes ) : []; + const blockProps = useBlockProps(); + + if ( postType !== 'post' && postType !== 'page' ) { + return ( +
+ } + label={ __( 'Footnotes' ) } + // To do: add instructions. We can't add new string in RC. + /> +
+ ); + } + + if ( ! footnotes.length ) { + return ( +
+ } + label={ __( 'Footnotes' ) } + instructions={ __( + 'Footnotes found in blocks within this document will be displayed here.' + ) } + /> +
+ ); + } + + return ( +
    + { footnotes.map( ( { id, content } ) => ( +
  1. + { + if ( ! event.target.textContent.trim() ) { + event.target.scrollIntoView(); + } + } } + onChange={ ( nextFootnote ) => { + updateMeta( { + ...meta, + footnotes: JSON.stringify( + footnotes.map( ( footnote ) => { + return footnote.id === id + ? { + content: nextFootnote, + id, + } + : footnote; + } ) + ), + } ); + } } + />{ ' ' } + ↩︎ +
  2. + ) ) } +
+ ); +} diff --git a/packages/block-library/src/footnotes/format.js b/packages/block-library/src/footnotes/format.js new file mode 100644 index 00000000000000..0197c427412c0c --- /dev/null +++ b/packages/block-library/src/footnotes/format.js @@ -0,0 +1,179 @@ +/** + * External dependencies + */ +import { v4 as createId } from 'uuid'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { formatListNumbered as icon } from '@wordpress/icons'; +import { insertObject } from '@wordpress/rich-text'; +import { + RichTextToolbarButton, + store as blockEditorStore, + privateApis, +} from '@wordpress/block-editor'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { createBlock, store as blocksStore } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { unlock } from '../lock-unlock'; + +const { usesContextKey } = unlock( privateApis ); + +export const formatName = 'core/footnote'; + +const POST_CONTENT_BLOCK_NAME = 'core/post-content'; +const SYNCED_PATTERN_BLOCK_NAME = 'core/block'; + +export const format = { + title: __( 'Footnote' ), + tagName: 'sup', + className: 'fn', + attributes: { + 'data-fn': 'data-fn', + }, + interactive: true, + contentEditable: false, + [ usesContextKey ]: [ 'postType' ], + edit: function Edit( { + value, + onChange, + isObjectActive, + context: { postType }, + } ) { + const registry = useRegistry(); + const { + getSelectedBlockClientId, + getBlocks, + getBlockRootClientId, + getBlockName, + getBlockParentsByBlockName, + } = registry.select( blockEditorStore ); + const hasFootnotesBlockType = useSelect( + ( select ) => + !! select( blocksStore ).getBlockType( 'core/footnotes' ), + [] + ); + /* + * This useSelect exists because we need to use its return value + * outside the event callback. + */ + const isBlockWithinPattern = useSelect( ( select ) => { + const { + getBlockParentsByBlockName: _getBlockParentsByBlockName, + getSelectedBlockClientId: _getSelectedBlockClientId, + } = select( blockEditorStore ); + const parentCoreBlocks = _getBlockParentsByBlockName( + _getSelectedBlockClientId(), + SYNCED_PATTERN_BLOCK_NAME + ); + return parentCoreBlocks && parentCoreBlocks.length > 0; + }, [] ); + + const { selectionChange, insertBlock } = + useDispatch( blockEditorStore ); + + if ( ! hasFootnotesBlockType ) { + return null; + } + + if ( postType !== 'post' && postType !== 'page' ) { + return null; + } + + // Checks if the selected block lives within a pattern. + if ( isBlockWithinPattern ) { + return null; + } + + function onClick() { + registry.batch( () => { + let id; + if ( isObjectActive ) { + const object = value.replacements[ value.start ]; + id = object?.attributes?.[ 'data-fn' ]; + } else { + id = createId(); + const newValue = insertObject( + value, + { + type: formatName, + attributes: { + 'data-fn': id, + }, + innerHTML: `*`, + }, + value.end, + value.end + ); + newValue.start = newValue.end - 1; + onChange( newValue ); + } + + const selectedClientId = getSelectedBlockClientId(); + + /* + * Attempts to find a common parent post content block. + * This allows for locating blocks within a page edited in the site editor. + */ + const parentPostContent = getBlockParentsByBlockName( + selectedClientId, + POST_CONTENT_BLOCK_NAME + ); + + // When called with a post content block, getBlocks will return + // the block with controlled inner blocks included. + const blocks = parentPostContent.length + ? getBlocks( parentPostContent[ 0 ] ) + : getBlocks(); + + // BFS search to find the first footnote block. + let fnBlock = null; + { + const queue = [ ...blocks ]; + while ( queue.length ) { + const block = queue.shift(); + if ( block.name === 'core/footnotes' ) { + fnBlock = block; + break; + } + queue.push( ...block.innerBlocks ); + } + } + + // Maybe this should all also be moved to the entity provider. + // When there is no footnotes block in the post, create one and + // insert it at the bottom. + if ( ! fnBlock ) { + let rootClientId = getBlockRootClientId( selectedClientId ); + + while ( + rootClientId && + getBlockName( rootClientId ) !== POST_CONTENT_BLOCK_NAME + ) { + rootClientId = getBlockRootClientId( rootClientId ); + } + + fnBlock = createBlock( 'core/footnotes' ); + + insertBlock( fnBlock, undefined, rootClientId ); + } + + selectionChange( fnBlock.clientId, id, 0, 0 ); + } ); + } + + return ( + + ); + }, +}; diff --git a/packages/block-library/src/footnotes/index.js b/packages/block-library/src/footnotes/index.js new file mode 100644 index 00000000000000..c5e851af7e033f --- /dev/null +++ b/packages/block-library/src/footnotes/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { formatListNumbered as icon } from '@wordpress/icons'; +import { registerFormatType } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import edit from './edit'; +import metadata from './block.json'; +import { formatName, format } from './format'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + icon, + edit, +}; + +registerFormatType( formatName, format ); + +export const init = () => { + initBlock( { name, metadata, settings } ); +}; diff --git a/packages/block-library/src/footnotes/index.php b/packages/block-library/src/footnotes/index.php new file mode 100644 index 00000000000000..5924db3a190c20 --- /dev/null +++ b/packages/block-library/src/footnotes/index.php @@ -0,0 +1,286 @@ +context['postId'] ) ) { + return ''; + } + + if ( post_password_required( $block->context['postId'] ) ) { + return; + } + + $footnotes = get_post_meta( $block->context['postId'], 'footnotes', true ); + + if ( ! $footnotes ) { + return; + } + + $footnotes = json_decode( $footnotes, true ); + + if ( ! is_array( $footnotes ) || count( $footnotes ) === 0 ) { + return ''; + } + + $wrapper_attributes = get_block_wrapper_attributes(); + + $block_content = ''; + + foreach ( $footnotes as $footnote ) { + $block_content .= sprintf( + '
  • %2$s ↩︎
  • ', + $footnote['id'], + $footnote['content'] + ); + } + + return sprintf( + '
      %2$s
    ', + $wrapper_attributes, + $block_content + ); +} + +/** + * Registers the `core/footnotes` block on the server. + * + * @since 6.3.0 + */ +function register_block_core_footnotes() { + foreach ( array( 'post', 'page' ) as $post_type ) { + register_post_meta( + $post_type, + 'footnotes', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + } + register_block_type_from_metadata( + __DIR__ . '/footnotes', + array( + 'render_callback' => 'render_block_core_footnotes', + ) + ); +} +add_action( 'init', 'register_block_core_footnotes' ); + +/** + * Saves the footnotes meta value to the revision. + * + * @since 6.3.0 + * + * @param int $revision_id The revision ID. + */ +function wp_save_footnotes_meta( $revision_id ) { + $post_id = wp_is_post_revision( $revision_id ); + + if ( $post_id ) { + $footnotes = get_post_meta( $post_id, 'footnotes', true ); + + if ( $footnotes ) { + // Can't use update_post_meta() because it doesn't allow revisions. + update_metadata( 'post', $revision_id, 'footnotes', wp_slash( $footnotes ) ); + } + } +} +add_action( 'wp_after_insert_post', 'wp_save_footnotes_meta' ); + +/** + * Keeps track of the revision ID for "rest_after_insert_{$post_type}". + * + * @since 6.3.0 + * + * @global int $wp_temporary_footnote_revision_id The footnote revision ID. + * + * @param int $revision_id The revision ID. + */ +function wp_keep_footnotes_revision_id( $revision_id ) { + global $wp_temporary_footnote_revision_id; + $wp_temporary_footnote_revision_id = $revision_id; +} +add_action( '_wp_put_post_revision', 'wp_keep_footnotes_revision_id' ); + +/** + * This is a specific fix for the REST API. The REST API doesn't update + * the post and post meta in one go (through `meta_input`). While it + * does fix the `wp_after_insert_post` hook to be called correctly after + * updating meta, it does NOT fix hooks such as post_updated and + * save_post, which are normally also fired after post meta is updated + * in `wp_insert_post()`. Unfortunately, `wp_save_post_revision` is + * added to the `post_updated` action, which means the meta is not + * available at the time, so we have to add it afterwards through the + * `"rest_after_insert_{$post_type}"` action. + * + * @since 6.3.0 + * + * @global int $wp_temporary_footnote_revision_id The footnote revision ID. + * + * @param WP_Post $post The post object. + */ +function wp_add_footnotes_revisions_to_post_meta( $post ) { + global $wp_temporary_footnote_revision_id; + + if ( $wp_temporary_footnote_revision_id ) { + $revision = get_post( $wp_temporary_footnote_revision_id ); + + if ( ! $revision ) { + return; + } + + $post_id = $revision->post_parent; + + // Just making sure we're updating the right revision. + if ( $post->ID === $post_id ) { + $footnotes = get_post_meta( $post_id, 'footnotes', true ); + + if ( $footnotes ) { + // Can't use update_post_meta() because it doesn't allow revisions. + update_metadata( 'post', $wp_temporary_footnote_revision_id, 'footnotes', wp_slash( $footnotes ) ); + } + } + } +} + +add_action( 'rest_after_insert_post', 'wp_add_footnotes_revisions_to_post_meta' ); +add_action( 'rest_after_insert_page', 'wp_add_footnotes_revisions_to_post_meta' ); + +/** + * Restores the footnotes meta value from the revision. + * + * @since 6.3.0 + * + * @param int $post_id The post ID. + * @param int $revision_id The revision ID. + */ +function wp_restore_footnotes_from_revision( $post_id, $revision_id ) { + $footnotes = get_post_meta( $revision_id, 'footnotes', true ); + + if ( $footnotes ) { + update_post_meta( $post_id, 'footnotes', wp_slash( $footnotes ) ); + } else { + delete_post_meta( $post_id, 'footnotes' ); + } +} +add_action( 'wp_restore_post_revision', 'wp_restore_footnotes_from_revision', 10, 2 ); + +/** + * Adds the footnotes field to the revision. + * + * @since 6.3.0 + * + * @param array $fields The revision fields. + * @return array The revision fields. + */ +function wp_add_footnotes_to_revision( $fields ) { + $fields['footnotes'] = __( 'Footnotes' ); + return $fields; +} +add_filter( '_wp_post_revision_fields', 'wp_add_footnotes_to_revision' ); + +/** + * Gets the footnotes field from the revision. + * + * @since 6.3.0 + * + * @param string $revision_field The field value, but $revision->$field + * (footnotes) does not exist. + * @param string $field The field name, in this case "footnotes". + * @param object $revision The revision object to compare against. + * @return string The field value. + */ +function wp_get_footnotes_from_revision( $revision_field, $field, $revision ) { + return get_metadata( 'post', $revision->ID, $field, true ); +} +add_filter( '_wp_post_revision_field_footnotes', 'wp_get_footnotes_from_revision', 10, 3 ); + +/** + * The REST API autosave endpoint doesn't save meta, so we can use the + * `wp_creating_autosave` when it updates an exiting autosave, and + * `_wp_put_post_revision` when it creates a new autosave. + * + * @since 6.3.0 + * + * @param int|array $autosave The autosave ID or array. + */ +function _wp_rest_api_autosave_meta( $autosave ) { + // Ensure it's a REST API request. + if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) { + return; + } + + $body = rest_get_server()->get_raw_data(); + $body = json_decode( $body, true ); + + if ( ! isset( $body['meta']['footnotes'] ) ) { + return; + } + + // `wp_creating_autosave` passes the array, + // `_wp_put_post_revision` passes the ID. + $id = is_int( $autosave ) ? $autosave : $autosave['ID']; + + if ( ! $id ) { + return; + } + + update_post_meta( $id, 'footnotes', wp_slash( $body['meta']['footnotes'] ) ); +} +// See https://github.com/WordPress/wordpress-develop/blob/2103cb9966e57d452c94218bbc3171579b536a40/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php#L391C1-L391C1. +add_action( 'wp_creating_autosave', '_wp_rest_api_autosave_meta' ); +// See https://github.com/WordPress/wordpress-develop/blob/2103cb9966e57d452c94218bbc3171579b536a40/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php#L398. +// Then https://github.com/WordPress/wordpress-develop/blob/2103cb9966e57d452c94218bbc3171579b536a40/src/wp-includes/revision.php#L367. +add_action( '_wp_put_post_revision', '_wp_rest_api_autosave_meta' ); + +/** + * This is a workaround for the autosave endpoint returning early if the + * revision field are equal. The problem is that "footnotes" is not real + * revision post field, so there's nothing to compare against. + * + * This trick sets the "footnotes" field (value doesn't matter), which will + * cause the autosave endpoint to always update the latest revision. That should + * be fine, it should be ok to update the revision even if nothing changed. Of + * course, this is temporary fix. + * + * @since 6.3.0 + * + * @param WP_Post $prepared_post The prepared post object. + * @param WP_REST_Request $request The request object. + * + * See https://github.com/WordPress/wordpress-develop/blob/2103cb9966e57d452c94218bbc3171579b536a40/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php#L365-L384. + * See https://github.com/WordPress/wordpress-develop/blob/2103cb9966e57d452c94218bbc3171579b536a40/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php#L219. + */ +function _wp_rest_api_force_autosave_difference( $prepared_post, $request ) { + // We only want to be altering POST requests. + if ( $request->get_method() !== 'POST' ) { + return $prepared_post; + } + + // Only alter requests for the '/autosaves' route. + if ( substr( $request->get_route(), -strlen( '/autosaves' ) ) !== '/autosaves' ) { + return $prepared_post; + } + + $prepared_post->footnotes = '[]'; + return $prepared_post; +} + +add_filter( 'rest_pre_insert_post', '_wp_rest_api_force_autosave_difference', 10, 2 ); diff --git a/packages/block-library/src/footnotes/init.js b/packages/block-library/src/footnotes/init.js new file mode 100644 index 00000000000000..79f0492c2cb2f8 --- /dev/null +++ b/packages/block-library/src/footnotes/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/packages/block-library/src/footnotes/style.scss b/packages/block-library/src/footnotes/style.scss new file mode 100644 index 00000000000000..aa7ab8b6951dd3 --- /dev/null +++ b/packages/block-library/src/footnotes/style.scss @@ -0,0 +1,21 @@ +// These styles are for backwards compatibility with the old footnotes anchors. +// Can be removed in the future. +.editor-styles-wrapper, +.entry-content { + counter-reset: footnotes; +} + +a[data-fn].fn { + vertical-align: super; + font-size: smaller; + counter-increment: footnotes; + display: inline-flex; + text-decoration: none; + text-indent: -9999999px; +} + +a[data-fn].fn::after { + content: "[" counter(footnotes) "]"; + text-indent: 0; + float: left; +} diff --git a/packages/block-library/src/freeform/block.json b/packages/block-library/src/freeform/block.json index 84b57b75326c0a..d40e8ea13dc117 100644 --- a/packages/block-library/src/freeform/block.json +++ b/packages/block-library/src/freeform/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/freeform", "title": "Classic", "category": "text", @@ -9,7 +9,7 @@ "attributes": { "content": { "type": "string", - "source": "html" + "source": "raw" } }, "supports": { diff --git a/packages/block-library/src/freeform/editor.scss b/packages/block-library/src/freeform/editor.scss index a3be7ffc5e2c8b..7329eb6e5fb064 100644 --- a/packages/block-library/src/freeform/editor.scss +++ b/packages/block-library/src/freeform/editor.scss @@ -376,14 +376,20 @@ div[data-type="core/freeform"] { } .block-editor-freeform-modal { - .components-modal__frame { + .block-editor-freeform-modal__content { + .mce-edit-area iframe { + height: 50vh !important; + } // On large screens, make the TinyMCE edit area grow to take all the // available height so that the Cancel/Save buttons are always into the // view. On smaller screens, the modal content is scrollable. @include break-large() { + // On medium and large screens, the modal component sets a max-height. // We want the modal to be as tall as possible also when the content is short. - height: 9999rem; + &:not(.is-full-screen) { + height: 9999rem; + } .components-modal__header + div { height: 100%; @@ -397,6 +403,7 @@ div[data-type="core/freeform"] { height: 100%; display: flex; flex-direction: column; + min-width: 50vw; } .mce-edit-area { diff --git a/packages/block-library/src/freeform/modal.js b/packages/block-library/src/freeform/modal.js index 9f7b20460c1e6b..c1b10a61de808d 100644 --- a/packages/block-library/src/freeform/modal.js +++ b/packages/block-library/src/freeform/modal.js @@ -13,6 +13,29 @@ import { import { useEffect, useState, RawHTML } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; +import { fullscreen } from '@wordpress/icons'; +import { useViewportMatch } from '@wordpress/compose'; + +function ModalAuxiliaryActions( { onClick, isModalFullScreen } ) { + // 'small' to match the rules in editor.scss. + const isMobileViewport = useViewportMatch( 'small', '<' ); + if ( isMobileViewport ) { + return null; + } + + return ( + ' - . $img[0] . - '
    '; - $body_content = preg_replace( '/]+>/', $button, $content ); + // If the lightbox is enabled, the image is not linked, and the Interactivity API is enabled, load the view script. + if ( isset( $lightbox_settings['enabled'] ) && + true === $lightbox_settings['enabled'] && + 'none' === $link_destination + ) { + $should_load_view_script = true; + } - // For the modal, set an ID on the image to be used for an aria-labelledby attribute. - $modal_content = new WP_HTML_Tag_Processor( $content ); - $modal_content->next_tag( 'img' ); - $image_lightbox_id = $modal_content->get_attribute( 'class' ) . '-lightbox'; - $modal_content->set_attribute( 'id', $image_lightbox_id ); - $modal_content = $modal_content->get_updated_html(); + // If at least one block in the page has the lightbox, mark the block type as interactive. + if ( $should_load_view_script ) { + $block->block_type->supports['interactivity'] = true; + } - $background_color = wp_get_global_styles( array( 'color', 'background' ) ); - $close_button_icon = ''; + $view_js_file = 'wp-block-image-view'; + if ( ! wp_script_is( $view_js_file ) ) { + $script_handles = $block->block_type->view_script_handles; - return - << - $body_content - - -HTML; + // If the script is not needed, and it is still in the `view_script_handles`, remove it. + if ( ! $should_load_view_script && in_array( $view_js_file, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); + } + // If the script is needed, but it was previously removed, add it again. + if ( $should_load_view_script && ! in_array( $view_js_file, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file ) ); + } } return $processor->get_updated_html(); } -/** - * Registers the `core/image` block on server. - */ + /** + * Registers the `core/image` block on server. + */ function register_block_core_image() { - register_block_type_from_metadata( __DIR__ . '/image', array( @@ -117,4 +87,4 @@ function register_block_core_image() { ) ); } -add_action( 'init', 'register_block_core_image' ); + add_action( 'init', 'register_block_core_image' ); diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js deleted file mode 100644 index 6b6f246b830256..00000000000000 --- a/packages/block-library/src/image/interactivity.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Internal dependencies - */ -import { store } from '../utils/interactivity'; - -const focusableSelectors = [ - 'a[href]', - 'area[href]', - 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', - 'select:not([disabled]):not([aria-hidden])', - 'textarea:not([disabled]):not([aria-hidden])', - 'button:not([disabled]):not([aria-hidden])', - 'iframe', - 'object', - 'embed', - '[contenteditable]', - '[tabindex]:not([tabindex^="-"])', -]; - -store( { - actions: { - core: { - image: { - showLightbox: ( { context } ) => { - context.core.image.initialized = true; - context.core.image.lightboxEnabled = true; - context.core.image.lastFocusedElement = - window.document.activeElement; - context.core.image.scrollPosition = window.scrollY; - document.documentElement.classList.add( - 'has-lightbox-open' - ); - }, - hideLightbox: async ( { context, event } ) => { - if ( context.core.image.lightboxEnabled ) { - // If scrolling, wait a moment before closing the lightbox. - if ( - event.type === 'mousewheel' && - Math.abs( - window.scrollY - - context.core.image.scrollPosition - ) < 5 - ) { - return; - } - document.documentElement.classList.remove( - 'has-lightbox-open' - ); - - context.core.image.lightboxEnabled = false; - context.core.image.lastFocusedElement.focus(); - } - }, - handleKeydown: ( { context, actions, event } ) => { - if ( context.core.image.lightboxEnabled ) { - if ( event.key === 'Tab' || event.keyCode === 9 ) { - // If shift + tab it change the direction - if ( - event.shiftKey && - window.document.activeElement === - context.core.image.firstFocusableElement - ) { - event.preventDefault(); - context.core.image.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.image.lastFocusableElement - ) { - event.preventDefault(); - context.core.image.firstFocusableElement.focus(); - } - } - - if ( event.key === 'Escape' || event.keyCode === 27 ) { - actions.core.image.hideLightbox( { - context, - event, - } ); - } - } - }, - }, - }, - }, - selectors: { - core: { - image: { - roleAttribute: ( { context } ) => { - return context.core.image.lightboxEnabled ? 'dialog' : ''; - }, - }, - }, - }, - effects: { - core: { - image: { - initLightbox: async ( { context, ref } ) => { - if ( context.core.image.lightboxEnabled ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.image.firstFocusableElement = - focusableElements[ 0 ]; - context.core.image.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - - ref.querySelector( '.close-button' ).focus(); - } - }, - }, - }, - }, -} ); diff --git a/packages/block-library/src/image/save.js b/packages/block-library/src/image/save.js index d0fd5ef3d6f98b..81565af09ababf 100644 --- a/packages/block-library/src/image/save.js +++ b/packages/block-library/src/image/save.js @@ -24,6 +24,8 @@ export default function save( { attributes } ) { linkClass, width, height, + aspectRatio, + scale, id, linkTarget, sizeSlug, @@ -52,9 +54,13 @@ export default function save( { attributes } ) { src={ url } alt={ alt } className={ imageClasses || undefined } - style={ borderProps.style } - width={ width } - height={ height } + style={ { + ...borderProps.style, + aspectRatio, + objectFit: scale, + width, + height, + } } title={ title } /> ); diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index e6ad33308bc94c..c7eec224f65878 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -153,10 +153,7 @@ } .wp-lightbox-container { - - .img-container { - position: relative; - } + position: relative; button { border: none; @@ -183,26 +180,53 @@ overflow: hidden; width: 100vw; height: 100vh; + box-sizing: border-box; visibility: hidden; + cursor: zoom-out; .close-button { - font-size: 40px; position: absolute; - top: 20px; - right: 20px; + top: calc(env(safe-area-inset-top) + 12.5px); + right: calc(env(safe-area-inset-right) + 12.5px); + padding: 0; cursor: pointer; z-index: 5000000; } + .lightbox-image-container { + position: absolute; + overflow: hidden; + top: 50%; + left: 50%; + transform-origin: top left; + transform: translate(-50%, -50%); + width: var(--wp--lightbox-container-width); + height: var(--wp--lightbox-container-height); + z-index: 9999999999; + } + .wp-block-image { + position: relative; + transform-origin: 0 0; display: flex; - justify-content: center; - align-items: center; width: 100%; height: 100%; + justify-content: center; + align-items: center; + box-sizing: border-box; z-index: 3000000; - position: absolute; - flex-direction: column; + margin: 0; + + img { + min-width: var(--wp--lightbox-image-width); + min-height: var(--wp--lightbox-image-height); + width: var(--wp--lightbox-image-width); + height: var(--wp--lightbox-image-height); + } + + figcaption { + display: none; + } } button { @@ -219,22 +243,62 @@ opacity: 0.9; } - &.initialized { - animation: both turn-off-visibility 300ms; - + // When fading, make the image come in slightly slower + // or faster than the scrim to give a sense of depth. + &.active { + visibility: visible; + animation: both turn-on-visibility 0.25s; img { - animation: both turn-off-visibility 250ms; + animation: both turn-on-visibility 0.35s; } - - &.active { - visibility: visible; - animation: both turn-on-visibility 250ms; - + } + &.hideanimationenabled { + &:not(.active) { + animation: both turn-off-visibility 0.35s; img { - animation: both turn-on-visibility 300ms; + animation: both turn-off-visibility 0.25s; } } } + + @media (prefers-reduced-motion: no-preference) { + &.zoom { + &.active { + opacity: 1; + visibility: visible; + animation: none; + .lightbox-image-container { + animation: lightbox-zoom-in 0.4s; + // Override fade animation for image + img { + animation: none; + } + } + .scrim { + animation: turn-on-visibility 0.4s forwards; + } + } + &.hideanimationenabled { + &:not(.active) { + animation: none; + .lightbox-image-container { + animation: lightbox-zoom-out 0.4s; + // Override fade animation for image + img { + animation: none; + } + } + .scrim { + animation: turn-off-visibility 0.4s forwards; + } + } + } + } + } +} + +html.wp-has-lightbox-open { + overflow: hidden; } @keyframes turn-on-visibility { @@ -261,6 +325,25 @@ } } -html.has-lightbox-open { - overflow: hidden; +@keyframes lightbox-zoom-in { + 0% { + transform: translate(calc(-50vw + var(--wp--lightbox-initial-left-position)), calc(-50vh + var(--wp--lightbox-initial-top-position))) scale(var(--wp--lightbox-scale)); + } + 100% { + transform: translate(-50%, -50%) scale(1, 1); + } +} + +@keyframes lightbox-zoom-out { + 0% { + visibility: visible; + transform: translate(-50%, -50%) scale(1, 1); + } + 99% { + visibility: visible; + } + 100% { + visibility: hidden; + transform: translate(calc(-50vw + var(--wp--lightbox-initial-left-position)), calc(-50vh + var(--wp--lightbox-initial-top-position))) scale(var(--wp--lightbox-scale)); + } } diff --git a/packages/block-library/src/image/test/edit.native.js b/packages/block-library/src/image/test/edit.native.js index 1f1bdd6ae4eda5..7a0fd15c854470 100644 --- a/packages/block-library/src/image/test/edit.native.js +++ b/packages/block-library/src/image/test/edit.native.js @@ -7,7 +7,6 @@ import { initializeEditor, getEditorHtml, render, - waitFor, setupApiFetch, } from 'test/helpers'; import { Image } from 'react-native'; @@ -27,7 +26,6 @@ import { select, dispatch } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { store as coreStore } from '@wordpress/core-data'; import apiFetch from '@wordpress/api-fetch'; -import '@wordpress/jest-console'; /** * Internal dependencies @@ -173,7 +171,7 @@ describe( 'Image Block', () => { 'wordpress.org' ); fireEvent.press( screen.getByLabelText( 'Apply' ) ); - await waitFor( + await act( () => new Promise( ( resolve ) => setTimeout( resolve, 100 ) ) ); @@ -196,25 +194,16 @@ describe( 'Image Block', () => { const [ imageBlock ] = screen.getAllByLabelText( /Image Block/ ); fireEvent.press( imageBlock ); - // Awaiting navigation event seemingly required due to React Navigation bug - // https://github.com/react-navigation/react-navigation/issues/9701 - await act( () => - fireEvent.press( screen.getByLabelText( 'Open Settings' ) ) - ); + fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); + fireEvent.press( screen.getByText( 'None' ) ); - fireEvent.press( screen.getByText( 'Media File' ) ); - await screen.findByText( 'Custom URL' ); fireEvent.press( screen.getByText( 'Custom URL' ) ); - // Await asynchronous fetch of clipboard - await act( () => clipboardPromise ); fireEvent.changeText( screen.getByPlaceholderText( 'Search or type URL' ), 'wordpress.org' ); fireEvent.press( screen.getByLabelText( 'Apply' ) ); fireEvent.press( await screen.findByText( 'Custom URL' ) ); - // Await asynchronous fetch of clipboard - await act( () => clipboardPromise ); fireEvent.press( screen.getByText( 'Media File' ) ); const expectedHtml = ` @@ -452,7 +441,7 @@ describe( 'Image Block', () => { `; const screen = await initializeEditor( { initialHtml } ); - fireEvent.press( screen.getByText( 'ADD IMAGE' ) ); + fireEvent.press( screen.getByText( 'Add image' ) ); fireEvent.press( screen.getByText( 'WordPress Media Library' ) ); const expectedHtml = ` diff --git a/packages/block-library/src/image/utils.js b/packages/block-library/src/image/utils.js index 839628fa978b00..1ef7973b4e57a3 100644 --- a/packages/block-library/src/image/utils.js +++ b/packages/block-library/src/image/utils.js @@ -3,6 +3,22 @@ */ import { NEW_TAB_REL } from './constants'; +/** + * Evaluates a CSS aspect-ratio property value as a number. + * + * Degenerate or invalid ratios behave as 'auto'. And 'auto' ratios return NaN. + * + * @see https://drafts.csswg.org/css-sizing-4/#aspect-ratio + * + * @param {string} value CSS aspect-ratio property value. + * @return {number} Numerical aspect ratio or NaN if invalid. + */ +export function evalAspectRatio( value ) { + const [ width, height = 1 ] = value.split( '/' ).map( Number ); + const aspectRatio = width / height; + return aspectRatio === Infinity || aspectRatio === 0 ? NaN : aspectRatio; +} + export function removeNewTabRel( currentRel ) { let newRel = currentRel; diff --git a/packages/block-library/src/image/view-interactivity.js b/packages/block-library/src/image/view-interactivity.js new file mode 100644 index 00000000000000..53a2d1f7d567fc --- /dev/null +++ b/packages/block-library/src/image/view-interactivity.js @@ -0,0 +1,447 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; + +const focusableSelectors = [ + 'a[href]', + 'area[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + 'iframe', + 'object', + 'embed', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])', +]; + +store( + { + state: { + core: { + image: { + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + }, + }, + }, + actions: { + core: { + image: { + showLightbox: ( { context, event } ) => { + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + if ( ! context.core.image.imageLoaded ) { + return; + } + context.core.image.initialized = true; + context.core.image.lastFocusedElement = + window.document.activeElement; + context.core.image.scrollDelta = 0; + + context.core.image.lightboxEnabled = true; + setStyles( context, event ); + // Hide overflow only when the animation is in progress, + // otherwise the removal of the scrollbars will draw attention + // to itself and look like an error + document.documentElement.classList.add( + 'wp-has-lightbox-open' + ); + }, + hideLightbox: async ( { context, event } ) => { + context.core.image.hideAnimationEnabled = true; + if ( context.core.image.lightboxEnabled ) { + // If scrolling, wait a moment before closing the lightbox. + if ( + context.core.image.lightboxAnimation === 'fade' + ) { + context.core.image.scrollDelta += event.deltaY; + if ( + event.type === 'mousewheel' && + Math.abs( + window.scrollY - + context.core.image.scrollDelta + ) < 10 + ) { + return; + } + } else if ( + context.core.image.lightboxAnimation === 'zoom' + ) { + // Disable scroll until the zoom animation ends. + // Get the current page scroll position + const scrollTop = + window.pageYOffset || + document.documentElement.scrollTop; + const scrollLeft = + window.pageXOffset || + document.documentElement.scrollLeft; + // if any scroll is attempted, set this to the previous value. + window.onscroll = function () { + window.scrollTo( scrollLeft, scrollTop ); + }; + // Enable scrolling after the animation finishes + setTimeout( function () { + window.onscroll = function () {}; + }, 400 ); + } + + document.documentElement.classList.remove( + 'wp-has-lightbox-open' + ); + + context.core.image.lightboxEnabled = false; + context.core.image.lastFocusedElement.focus( { + preventScroll: true, + } ); + } + }, + handleKeydown: ( { context, actions, event } ) => { + if ( context.core.image.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction + if ( + event.shiftKey && + window.document.activeElement === + context.core.image.firstFocusableElement + ) { + event.preventDefault(); + context.core.image.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + context.core.image.lastFocusableElement + ) { + event.preventDefault(); + context.core.image.firstFocusableElement.focus(); + } + } + + if ( + event.key === 'Escape' || + event.keyCode === 27 + ) { + actions.core.image.hideLightbox( { + context, + event, + } ); + } + } + }, + handleLoad: ( { state, context, effects, ref } ) => { + context.core.image.imageLoaded = true; + context.core.image.imageCurrentSrc = ref.currentSrc; + effects.core.image.setButtonStyles( { + state, + context, + ref, + } ); + }, + }, + }, + }, + selectors: { + core: { + image: { + roleAttribute: ( { context } ) => { + return context.core.image.lightboxEnabled + ? 'dialog' + : ''; + }, + lightboxObjectFit: ( { context } ) => { + if ( context.core.image.initialized ) { + return 'cover'; + } + }, + enlargedImgSrc: ( { context } ) => { + return context.core.image.initialized + ? context.core.image.imageUploadedSrc + : ''; + }, + }, + }, + }, + effects: { + core: { + image: { + setCurrentSrc: ( { context, ref } ) => { + if ( ref.complete ) { + context.core.image.imageLoaded = true; + context.core.image.imageCurrentSrc = ref.currentSrc; + } + }, + initLightbox: async ( { context, ref } ) => { + context.core.image.figureRef = + ref.querySelector( 'figure' ); + context.core.image.imageRef = + ref.querySelector( 'img' ); + if ( context.core.image.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + context.core.image.firstFocusableElement = + focusableElements[ 0 ]; + context.core.image.lastFocusableElement = + focusableElements[ + focusableElements.length - 1 + ]; + + ref.querySelector( '.close-button' ).focus(); + } + }, + setButtonStyles: ( { state, context, ref } ) => { + const { + naturalWidth, + naturalHeight, + offsetWidth, + offsetHeight, + } = ref; + + // If the image isn't loaded yet, we can't + // calculate how big the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } + + // Subscribe to the window dimensions so we can + // recalculate the styles if the window is resized. + if ( + ( state.core.image.windowWidth || + state.core.image.windowHeight ) && + context.core.image.scaleAttr === 'contain' + ) { + // In the case of an image with object-fit: contain, the + // size of the img element can be larger than the image itself, + // so we need to calculate the size of the button to match. + + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio > offsetRatio ) { + // If it reaches the width first, keep + // the width and recalculate the height. + context.core.image.imageButtonWidth = + offsetWidth; + const buttonHeight = offsetWidth / naturalRatio; + context.core.image.imageButtonHeight = + buttonHeight; + context.core.image.imageButtonTop = + ( offsetHeight - buttonHeight ) / 2; + } else { + // If it reaches the height first, keep + // the height and recalculate the width. + context.core.image.imageButtonHeight = + offsetHeight; + const buttonWidth = offsetHeight * naturalRatio; + context.core.image.imageButtonWidth = + buttonWidth; + context.core.image.imageButtonLeft = + ( offsetWidth - buttonWidth ) / 2; + } + } else { + // In all other cases, we can trust that the size of + // the image is the right size for the button as well. + + context.core.image.imageButtonWidth = offsetWidth; + context.core.image.imageButtonHeight = offsetHeight; + } + }, + }, + }, + }, + }, + { + afterLoad: ( { state } ) => { + window.addEventListener( + 'resize', + debounce( () => { + state.core.image.windowWidth = window.innerWidth; + state.core.image.windowHeight = window.innerHeight; + } ) + ); + }, + } +); + +function setStyles( context, event ) { + // The reference img element lies adjacent + // to the event target button in the DOM. + let { + naturalWidth, + naturalHeight, + offsetWidth: originalWidth, + offsetHeight: originalHeight, + } = event.target.nextElementSibling; + let { x: screenPosX, y: screenPosY } = + event.target.nextElementSibling.getBoundingClientRect(); + + // Natural ratio of the image clicked to open the lightbox. + const naturalRatio = naturalWidth / naturalHeight; + // Original ratio of the image clicked to open the lightbox. + let originalRatio = originalWidth / originalHeight; + + // If it has object-fit: contain, recalculate the original sizes + // and the screen position without the blank spaces. + if ( context.core.image.scaleAttr === 'contain' ) { + if ( naturalRatio > originalRatio ) { + const heightWithoutSpace = originalWidth / naturalRatio; + // Recalculate screen position without the top space. + screenPosY += ( originalHeight - heightWithoutSpace ) / 2; + originalHeight = heightWithoutSpace; + } else { + const widthWithoutSpace = originalHeight * naturalRatio; + // Recalculate screen position without the left space. + screenPosX += ( originalWidth - widthWithoutSpace ) / 2; + originalWidth = widthWithoutSpace; + } + } + originalRatio = originalWidth / originalHeight; + + // Typically, we use the image's full-sized dimensions. If those + // dimensions have not been set (i.e. an external image with only one size), + // the image's dimensions in the lightbox are the same + // as those of the image in the content. + let imgMaxWidth = parseFloat( + context.core.image.targetWidth !== 'none' + ? context.core.image.targetWidth + : naturalWidth + ); + let imgMaxHeight = parseFloat( + context.core.image.targetHeight !== 'none' + ? context.core.image.targetHeight + : naturalHeight + ); + + // Ratio of the biggest image stored in the database. + let imgRatio = imgMaxWidth / imgMaxHeight; + let containerMaxWidth = imgMaxWidth; + let containerMaxHeight = imgMaxHeight; + let containerWidth = imgMaxWidth; + let containerHeight = imgMaxHeight; + // Check if the target image has a different ratio than the original one (thumbnail). + // Recalculate the width and height. + if ( naturalRatio.toFixed( 2 ) !== imgRatio.toFixed( 2 ) ) { + if ( naturalRatio > imgRatio ) { + // If the width is reached before the height, we keep the maxWidth + // and recalculate the height. + // Unless the difference between the maxHeight and the reducedHeight + // is higher than the maxWidth, where we keep the reducedHeight and + // recalculate the width. + const reducedHeight = imgMaxWidth / naturalRatio; + if ( imgMaxHeight - reducedHeight > imgMaxWidth ) { + imgMaxHeight = reducedHeight; + imgMaxWidth = reducedHeight * naturalRatio; + } else { + imgMaxHeight = imgMaxWidth / naturalRatio; + } + } else { + // If the height is reached before the width, we keep the maxHeight + // and recalculate the width. + // Unless the difference between the maxWidth and the reducedWidth + // is higher than the maxHeight, where we keep the reducedWidth and + // recalculate the height. + const reducedWidth = imgMaxHeight * naturalRatio; + if ( imgMaxWidth - reducedWidth > imgMaxHeight ) { + imgMaxWidth = reducedWidth; + imgMaxHeight = reducedWidth / naturalRatio; + } else { + imgMaxWidth = imgMaxHeight * naturalRatio; + } + } + containerWidth = imgMaxWidth; + containerHeight = imgMaxHeight; + imgRatio = imgMaxWidth / imgMaxHeight; + + // Calculate the max size of the container. + if ( originalRatio > imgRatio ) { + containerMaxWidth = imgMaxWidth; + containerMaxHeight = containerMaxWidth / originalRatio; + } else { + containerMaxHeight = imgMaxHeight; + containerMaxWidth = containerMaxHeight * originalRatio; + } + } + + // If the image has been pixelated on purpose, keep that size. + if ( originalWidth > containerWidth || originalHeight > containerHeight ) { + containerWidth = originalWidth; + containerHeight = originalHeight; + } + + // Calculate the final lightbox image size and the + // scale factor. MaxWidth is either the window container + // (accounting for padding) or the image resolution. + let horizontalPadding = 0; + if ( window.innerWidth > 480 ) { + horizontalPadding = 80; + } else if ( window.innerWidth > 1920 ) { + horizontalPadding = 160; + } + const verticalPadding = 80; + + const targetMaxWidth = Math.min( + window.innerWidth - horizontalPadding, + containerWidth + ); + const targetMaxHeight = Math.min( + window.innerHeight - verticalPadding, + containerHeight + ); + const targetContainerRatio = targetMaxWidth / targetMaxHeight; + + if ( originalRatio > targetContainerRatio ) { + // If targetMaxWidth is reached before targetMaxHeight + containerWidth = targetMaxWidth; + containerHeight = containerWidth / originalRatio; + } else { + // If targetMaxHeight is reached before targetMaxWidth + containerHeight = targetMaxHeight; + containerWidth = containerHeight * originalRatio; + } + + const containerScale = originalWidth / containerWidth; + const lightboxImgWidth = + imgMaxWidth * ( containerWidth / containerMaxWidth ); + const lightboxImgHeight = + imgMaxHeight * ( containerHeight / containerMaxHeight ); + + // Add the CSS variables needed. + let styleTag = document.getElementById( 'wp-lightbox-styles' ); + if ( ! styleTag ) { + styleTag = document.createElement( 'style' ); + styleTag.id = 'wp-lightbox-styles'; + document.head.appendChild( styleTag ); + } + + // As of this writing, using the calculations above will render the lightbox + // with a small, erroneous whitespace on the left side of the image in iOS Safari, + // perhaps due to an inconsistency in how browsers handle absolute positioning and CSS + // transformation. In any case, adding 1 pixel to the container width and height solves + // the problem, though this can be removed if the issue is fixed in the future. + styleTag.innerHTML = ` + :root { + --wp--lightbox-initial-top-position: ${ screenPosY }px; + --wp--lightbox-initial-left-position: ${ screenPosX }px; + --wp--lightbox-container-width: ${ containerWidth + 1 }px; + --wp--lightbox-container-height: ${ containerHeight + 1 }px; + --wp--lightbox-image-width: ${ lightboxImgWidth }px; + --wp--lightbox-image-height: ${ lightboxImgHeight }px; + --wp--lightbox-scale: ${ containerScale }; + } + `; +} + +function debounce( func, wait = 50 ) { + let timeout; + return () => { + const later = () => { + timeout = null; + func(); + }; + clearTimeout( timeout ); + timeout = setTimeout( later, wait ); + }; +} diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index a0c7b75eac19b8..736b552bf4259b 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -116,6 +116,7 @@ import * as termDescription from './term-description'; import * as textColumns from './text-columns'; import * as verse from './verse'; import * as video from './video'; +import * as footnotes from './footnotes'; import isBlockMetadataExperimental from './utils/is-block-metadata-experimental'; @@ -141,12 +142,12 @@ const getAllBlocks = () => { buttons, calendar, categories, - ...( window.wp && window.wp.oldEditor ? [ classic ] : [] ), // Only add the classic block in WP Context. code, column, columns, commentAuthorAvatar, cover, + details, embed, file, group, @@ -175,6 +176,7 @@ const getAllBlocks = () => { textColumns, verse, video, + footnotes, // theme blocks navigation, @@ -226,9 +228,24 @@ const getAllBlocks = () => { queryTitle, postAuthorBiography, ]; - if ( window?.__experimentalEnableDetailsBlocks ) { - blocks.push( details ); + + // When in a WordPress context, conditionally + // add the classic block and TinyMCE editor + // under any of the following conditions: + // - the current post contains a classic block + // - the experiment to disable TinyMCE isn't active. + // - a query argument specifies that TinyMCE should be loaded + if ( + window?.wp?.oldEditor && + ( window?.wp?.needsClassicBlock || + ! window?.__experimentalDisableTinymce || + !! new URLSearchParams( window?.location?.search ).get( + 'requiresTinymce' + ) ) + ) { + blocks.push( classic ); } + return blocks.filter( Boolean ); }; @@ -265,7 +282,11 @@ export const registerCoreBlocks = ( blocks.forEach( ( { init } ) => init() ); setDefaultBlockName( paragraph.name ); - if ( window.wp && window.wp.oldEditor ) { + if ( + window.wp && + window.wp.oldEditor && + blocks.some( ( { name } ) => name === classic.name ) + ) { setFreeformContentHandlerName( classic.name ); } setUnregisteredTypeHandlerName( missing.name ); diff --git a/packages/block-library/src/index.native.js b/packages/block-library/src/index.native.js index 6a6a5eb11b88d9..dcaf1fd16098f3 100644 --- a/packages/block-library/src/index.native.js +++ b/packages/block-library/src/index.native.js @@ -122,7 +122,6 @@ export const coreBlocks = [ * Function to register a block variations e.g. social icons different types. * * @param {Object} block The block which variations will be registered. - * */ const registerBlockVariations = ( block ) => { const { metadata, settings, name } = block; diff --git a/packages/block-library/src/latest-comments/block.json b/packages/block-library/src/latest-comments/block.json index 80fa4f5d2d063a..0b213e9b7903a4 100644 --- a/packages/block-library/src/latest-comments/block.json +++ b/packages/block-library/src/latest-comments/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/latest-comments", "title": "Latest Comments", "category": "widgets", @@ -29,7 +29,6 @@ }, "supports": { "align": true, - "anchor": true, "html": false, "spacing": { "margin": true, diff --git a/packages/block-library/src/latest-comments/edit.js b/packages/block-library/src/latest-comments/edit.js index 59258f3c0b090c..85e66cf2e9dc60 100644 --- a/packages/block-library/src/latest-comments/edit.js +++ b/packages/block-library/src/latest-comments/edit.js @@ -68,6 +68,7 @@ export default function LatestComments( { attributes, setAttributes } ) { /> diff --git a/packages/block-library/src/latest-posts/block.json b/packages/block-library/src/latest-posts/block.json index 9b451f5875c733..f36164614dd506 100644 --- a/packages/block-library/src/latest-posts/block.json +++ b/packages/block-library/src/latest-posts/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/latest-posts", "title": "Latest Posts", "category": "widgets", @@ -84,7 +84,6 @@ }, "supports": { "align": true, - "anchor": true, "html": false, "color": { "gradients": true, diff --git a/packages/block-library/src/latest-posts/edit.js b/packages/block-library/src/latest-posts/edit.js index 45266f6ab94c4e..7aaf1b3ecf0eda 100644 --- a/packages/block-library/src/latest-posts/edit.js +++ b/packages/block-library/src/latest-posts/edit.js @@ -32,6 +32,7 @@ import { pin, list, grid } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { store as noticeStore } from '@wordpress/notices'; import { useInstanceId } from '@wordpress/compose'; +import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies @@ -229,6 +230,7 @@ export default function LatestPostsEdit( { attributes, setAttributes } ) { displayPostContentRadio === 'excerpt' && ( @@ -358,6 +360,7 @@ export default function LatestPostsEdit( { attributes, setAttributes } ) { { postLayout === 'grid' && ( @@ -479,15 +482,22 @@ export default function LatestPostsEdit( { attributes, setAttributes } ) { .trim() .split( ' ', excerptLength ) .join( ' ' ) } - { /* translators: excerpt truncation character, default … */ } - { __( ' … ' ) } - - { __( 'Read more' ) } - + { createInterpolateElement( + /* translators: excerpt truncation character, default … */ + __( ' … Read more' ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + } + ) } ) : ( excerpt diff --git a/packages/block-library/src/latest-posts/index.php b/packages/block-library/src/latest-posts/index.php index d3945ee2c9ee2f..4cbdf1ca61bed0 100644 --- a/packages/block-library/src/latest-posts/index.php +++ b/packages/block-library/src/latest-posts/index.php @@ -48,7 +48,15 @@ function render_block_core_latest_posts( $attributes ) { $block_core_latest_posts_excerpt_length = $attributes['excerptLength']; add_filter( 'excerpt_length', 'block_core_latest_posts_get_excerpt_length', 20 ); - if ( isset( $attributes['categories'] ) ) { + $filter_latest_posts_excerpt_more = static function( $more ) use ( $attributes ) { + $use_excerpt = 'excerpt' === $attributes['displayPostContentRadio']; + /* translators: %1$s is a URL to a post, excerpt truncation character, default … */ + return $use_excerpt ? sprintf( __( ' … Read more' ), esc_url( get_permalink() ) ) : $more; + }; + + add_filter( 'excerpt_more', $filter_latest_posts_excerpt_more ); + + if ( ! empty( $attributes['categories'] ) ) { $args['category__in'] = array_column( $attributes['categories'], 'id' ); } if ( isset( $attributes['selectedAuthor'] ) ) { diff --git a/packages/block-library/src/list-item/block.json b/packages/block-library/src/list-item/block.json index 745d6e30b4dd66..41221f1c31772e 100644 --- a/packages/block-library/src/list-item/block.json +++ b/packages/block-library/src/list-item/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/list-item", "title": "List item", "category": "text", diff --git a/packages/block-library/src/list-item/hooks/use-merge.js b/packages/block-library/src/list-item/hooks/use-merge.js index 8c186b27f6dc9a..6b456a2a742bdb 100644 --- a/packages/block-library/src/list-item/hooks/use-merge.js +++ b/packages/block-library/src/list-item/hooks/use-merge.js @@ -9,8 +9,6 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; */ import useOutdentListItem from './use-outdent-list-item'; -import { name as listItemName } from '../block.json'; - export default function useMerge( clientId, onMerge ) { const registry = useRegistry(); const { @@ -38,7 +36,7 @@ export default function useMerge( clientId, onMerge ) { const listId = getBlockRootClientId( id ); const parentListItemId = getBlockRootClientId( listId ); if ( ! parentListItemId ) return; - if ( getBlockName( parentListItemId ) !== listItemName ) return; + if ( getBlockName( parentListItemId ) !== 'core/list-item' ) return; return parentListItemId; } @@ -107,11 +105,18 @@ export default function useMerge( clientId, onMerge ) { } else if ( previousBlockClientId ) { const trailingId = getTrailingId( previousBlockClientId ); registry.batch( () => { - moveBlocksToPosition( - getBlockOrder( clientId ), - clientId, - previousBlockClientId - ); + // When merging a list item with a previous trailing list + // item, we also need to move any nested list items. First, + // check if there's a listed list. If there's a nested list, + // append its nested list items to the trailing list. + const [ nestedListClientId ] = getBlockOrder( clientId ); + if ( nestedListClientId ) { + moveBlocksToPosition( + getBlockOrder( nestedListClientId ), + nestedListClientId, + getBlockRootClientId( trailingId ) + ); + } mergeBlocks( trailingId, clientId ); } ); } else { diff --git a/packages/block-library/src/list-item/hooks/use-outdent-list-item.js b/packages/block-library/src/list-item/hooks/use-outdent-list-item.js index 3f566dac30ab5a..14598dc7451cfa 100644 --- a/packages/block-library/src/list-item/hooks/use-outdent-list-item.js +++ b/packages/block-library/src/list-item/hooks/use-outdent-list-item.js @@ -6,11 +6,6 @@ import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { cloneBlock } from '@wordpress/blocks'; -/** - * Internal dependencies - */ -import { name as listItemName } from '../block.json'; - export default function useOutdentListItem( clientId ) { const registry = useRegistry(); const { canOutdent } = useSelect( @@ -21,7 +16,7 @@ export default function useOutdentListItem( clientId ) { getBlockRootClientId( clientId ) ); const grandParentName = getBlockName( grandParentId ); - const isListItem = grandParentName === listItemName; + const isListItem = grandParentName === 'core/list-item'; return { canOutdent: isListItem, @@ -49,7 +44,7 @@ export default function useOutdentListItem( clientId ) { const listId = getBlockRootClientId( id ); const parentListItemId = getBlockRootClientId( listId ); if ( ! parentListItemId ) return; - if ( getBlockName( parentListItemId ) !== listItemName ) return; + if ( getBlockName( parentListItemId ) !== 'core/list-item' ) return; return parentListItemId; } @@ -65,7 +60,7 @@ export default function useOutdentListItem( clientId ) { const firstClientId = clientIds[ 0 ]; // Can't outdent if it's not a list item. - if ( getBlockName( firstClientId ) !== listItemName ) return; + if ( getBlockName( firstClientId ) !== 'core/list-item' ) return; const parentListItemId = getParentListItemId( firstClientId ); diff --git a/packages/block-library/src/list-item/utils.js b/packages/block-library/src/list-item/utils.js index ac7f99ac849e00..5e5b51a8af680c 100644 --- a/packages/block-library/src/list-item/utils.js +++ b/packages/block-library/src/list-item/utils.js @@ -1,40 +1,27 @@ /** * WordPress dependencies */ -import { createBlock, switchToBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { name as listItemName } from './block.json'; -import { name as listName } from '../list/block.json'; -import { name as paragraphName } from '../paragraph/block.json'; - -export function createListItem( listItemAttributes, listAttributes, children ) { - return createBlock( - listItemName, - listItemAttributes, - ! children?.length - ? [] - : [ createBlock( listName, listAttributes, children ) ] - ); -} +import { switchToBlockType } from '@wordpress/blocks'; function convertBlockToList( block ) { - const list = switchToBlockType( block, listName ); - if ( list ) return list; - const paragraph = switchToBlockType( block, paragraphName ); - if ( paragraph ) return switchToBlockType( paragraph, listName ); - return null; + const list = switchToBlockType( block, 'core/list' ); + if ( list ) { + return list; + } + const paragraph = switchToBlockType( block, 'core/paragraph' ); + if ( ! paragraph ) { + return null; + } + return switchToBlockType( paragraph, 'core/list' ); } export function convertToListItems( blocks ) { const listItems = []; for ( let block of blocks ) { - if ( block.name === listItemName ) { + if ( block.name === 'core/list-item' ) { listItems.push( block ); - } else if ( block.name === listName ) { + } else if ( block.name === 'core/list' ) { listItems.push( ...block.innerBlocks ); } else if ( ( block = convertBlockToList( block ) ) ) { for ( const { innerBlocks } of block ) { diff --git a/packages/block-library/src/list/block.json b/packages/block-library/src/list/block.json index 4e1089b0887380..e2fb9e4c9e3b0d 100644 --- a/packages/block-library/src/list/block.json +++ b/packages/block-library/src/list/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/list", "title": "List", "category": "text", @@ -61,10 +61,15 @@ }, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "__unstablePasteTextInline": true, "__experimentalSelector": "ol,ul", + "__experimentalOnMerge": true, "__experimentalSlashInserter": true }, "editorStyle": "wp-block-list-editor", diff --git a/packages/block-library/src/list/edit.js b/packages/block-library/src/list/edit.js index 24d5ead74c47d4..10083d7bbf3a4d 100644 --- a/packages/block-library/src/list/edit.js +++ b/packages/block-library/src/list/edit.js @@ -137,6 +137,7 @@ export default function Edit( { attributes, setAttributes, clientId, style } ) { marginHorizontal: NATIVE_MARGIN_SPACING, renderAppender: false, } ), + __experimentalCaptureToolbars: true, } ); useMigrateOnLoad( attributes, clientId ); const { ordered, type, reversed, start } = attributes; @@ -177,10 +178,12 @@ export default function Edit( { attributes, setAttributes, clientId, style } ) { { controls } { ordered && ( ) } diff --git a/packages/block-library/src/list/ordered-list-settings.js b/packages/block-library/src/list/ordered-list-settings.js index 7fd51a5c7e576e..60e7e33d0ad51f 100644 --- a/packages/block-library/src/list/ordered-list-settings.js +++ b/packages/block-library/src/list/ordered-list-settings.js @@ -3,9 +3,14 @@ */ import { __ } from '@wordpress/i18n'; import { InspectorControls } from '@wordpress/block-editor'; -import { TextControl, PanelBody, ToggleControl } from '@wordpress/components'; +import { + TextControl, + PanelBody, + ToggleControl, + SelectControl, +} from '@wordpress/components'; -const OrderedListSettings = ( { setAttributes, reversed, start } ) => ( +const OrderedListSettings = ( { setAttributes, reversed, start, type } ) => ( ( value={ Number.isInteger( start ) ? start.toString( 10 ) : '' } step="1" /> + setAttributes( { type: newValue } ) } + /> { beforeAll( () => { @@ -172,6 +173,11 @@ describe( 'List block', () => { ); await triggerBlockListLayout( listItemBlock1 ); + // wait until inserter on the newly created indented block is enabled + // this is slightly delayed (by updating block list settings) and would + // trigger an "update not wrapped in act()" warning if not explicitly awaited. + screen.findByRole( 'button', { name: 'Add block', disabled: false } ); + expect( getEditorHtml() ).toMatchSnapshot(); } ); @@ -333,34 +339,125 @@ describe( 'List block', () => { expect( getEditorHtml() ).toMatchSnapshot(); } ); - it( 'merges with other lists', async () => { + it( 'splits empty list items into paragraphs', async () => { + // Arrange const initialHtml = `
      -
    • One
    - -
      +
    • One
    • +
    • Two
    `; + const screen = await initializeEditor( { initialHtml } ); - const screen = await initializeEditor( { - initialHtml, - } ); - - // Select List block - const [ listBlock ] = screen.getAllByLabelText( /List Block\. Row 2/ ); + // Act + const listBlock = screen.getByLabelText( /List Block\. Row 1/ ); fireEvent.press( listBlock ); await triggerBlockListLayout( listBlock ); + const listItemField = screen.getByLabelText( /Text input. .*One.*/ ); + selectRangeInRichText( listItemField, 3 ); + fireEvent( listItemField, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: ENTER, + } ); + const listItemField2 = screen.getByLabelText( /Text input. Empty/ ); + fireEvent( listItemField2, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: ENTER, + } ); - // Select List Item block - const [ listItemBlock ] = within( listBlock ).getAllByLabelText( - /List item Block\. Row 1/ - ); - fireEvent.press( listItemBlock ); + // Assert + expect( getEditorHtml() ).toMatchInlineSnapshot( ` + " +
      +
    • One
    • +
    + - // With cursor positioned at the beginning of the first List Item, press - // backward delete - const listItemField = - within( listItemBlock ).getByLabelText( /Text input. .*Two.*/ ); + +

    + + + +
      +
    • Two
    • +
    + " + ` ); + } ); + + it( 'merges paragraphs into list items', async () => { + const initialHtml = ` +
      +
    • One
    • +
    + + + +

    Two

    + + + +
      +
    • Three
    • +
    + `; + const screen = await initializeEditor( { initialHtml } ); + + // Act + const paragraphField = screen.getByLabelText( /Text input. .*Two.*/ ); + selectRangeInRichText( paragraphField, 0 ); + fireEvent( paragraphField, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: BACKSPACE, + } ); + + // Assert + expect( getEditorHtml() ).toMatchInlineSnapshot( ` + " +
      +
    • One
    • + + + +
    • Two
    • +
    + + + +
      +
    • Three
    • +
    + " + ` ); + } ); + + it( 'merges lists into lists', async () => { + // Arrange + const initialHtml = ` +
      +
    • One
    • + + + +
    • Two
    • +
    + + + +
      +
    • Three
    • +
    + `; + const screen = await initializeEditor( { initialHtml } ); + + // Act + const listBlock = screen.getByLabelText( /List Block\. Row 2/ ); + fireEvent.press( listBlock ); + await triggerBlockListLayout( listBlock ); + const listItemField = screen.getByLabelText( /Text input\..*Three/ ); selectRangeInRichText( listItemField, 0 ); fireEvent( listItemField, 'onKeyDown', { nativeEvent: {}, @@ -368,17 +465,22 @@ describe( 'List block', () => { keyCode: BACKSPACE, } ); + // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` - " -
      -
    • One
    • - - - -
    • Two
    • -
    - " - ` ); + " +
      +
    • One
    • + + + +
    • Two
    • + + + +
    • Three
    • +
    + " + ` ); } ); it( 'unwraps first item when attempting to merge with non-list block', async () => { @@ -476,21 +578,25 @@ describe( 'List block', () => { preventDefault() {}, keyCode: BACKSPACE, } ); + // Inner blocks batch store updates with microtasks. + // To avoid `act` warnings, we let queued microtasks to be executed. + // Reference: https://t.ly/b95nA + await act( async () => {} ); expect( getEditorHtml() ).toMatchInlineSnapshot( ` "

    A quick brown fox.

    - +

    One

    - +
    • Two
    • - +
    • Three
    diff --git a/packages/block-library/src/private-apis.js b/packages/block-library/src/lock-unlock.js similarity index 100% rename from packages/block-library/src/private-apis.js rename to packages/block-library/src/lock-unlock.js diff --git a/packages/block-library/src/loginout/block.json b/packages/block-library/src/loginout/block.json index 3ba18dcf17143f..3593961c09cfdc 100644 --- a/packages/block-library/src/loginout/block.json +++ b/packages/block-library/src/loginout/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/loginout", "title": "Login/out", "category": "theme", @@ -18,7 +18,6 @@ } }, "supports": { - "anchor": true, "className": true, "typography": { "fontSize": true, diff --git a/packages/block-library/src/media-text/block.json b/packages/block-library/src/media-text/block.json index ac88c9ca6d4df9..cdeb4ce13e8f51 100644 --- a/packages/block-library/src/media-text/block.json +++ b/packages/block-library/src/media-text/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/media-text", "title": "Media & Text", "category": "media", @@ -100,6 +100,7 @@ "html": false, "color": { "gradients": true, + "heading": true, "link": true, "__experimentalDefaultControls": { "background": true, diff --git a/packages/block-library/src/media-text/deprecated.js b/packages/block-library/src/media-text/deprecated.js index 93366f827055ba..d03659022b95b4 100644 --- a/packages/block-library/src/media-text/deprecated.js +++ b/packages/block-library/src/media-text/deprecated.js @@ -76,7 +76,7 @@ const migrateDefaultAlign = ( attributes ) => { }; }; -const baseAttributes = { +const v0Attributes = { align: { type: 'string', default: 'wide', @@ -104,12 +104,16 @@ const baseAttributes = { }, isStackedOnMobile: { type: 'boolean', - default: true, + default: false, }, }; const v4ToV5BlockAttributes = { - ...baseAttributes, + ...v0Attributes, + isStackedOnMobile: { + type: 'boolean', + default: true, + }, mediaUrl: { type: 'string', source: 'attribute', @@ -578,7 +582,11 @@ const v4 = { // See: https://github.com/WordPress/gutenberg/pull/21169 const v3 = { attributes: { - ...baseAttributes, + ...v0Attributes, + isStackedOnMobile: { + type: 'boolean', + default: true, + }, backgroundColor: { type: 'string', }, @@ -726,7 +734,7 @@ const v3 = { // See: https://github.com/WordPress/gutenberg/pull/14364 const v2 = { attributes: { - ...baseAttributes, + ...v0Attributes, backgroundColor: { type: 'string', }, @@ -828,7 +836,7 @@ const v2 = { // See: https://github.com/WordPress/gutenberg/pull/11922 const v1 = { attributes: { - ...baseAttributes, + ...v0Attributes, backgroundColor: { type: 'string', }, diff --git a/packages/block-library/src/media-text/edit.js b/packages/block-library/src/media-text/edit.js index dcf8eae4ecf5a9..30181c9044c34d 100644 --- a/packages/block-library/src/media-text/edit.js +++ b/packages/block-library/src/media-text/edit.js @@ -18,7 +18,7 @@ import { __experimentalImageURLInputUI as ImageURLInputUI, __experimentalImageSizeControl as ImageSizeControl, store as blockEditorStore, - privateApis as blockEditorPrivateApis, + useBlockEditingMode, } from '@wordpress/block-editor'; import { PanelBody, @@ -44,9 +44,6 @@ import { LINK_DESTINATION_ATTACHMENT, TEMPLATE, } from './constants'; -import { unlock } from '../private-apis'; - -const { useBlockEditingMode } = unlock( blockEditorPrivateApis ); // this limits the resize to a safe zone to avoid making broken layouts const applyWidthConstraints = ( width ) => diff --git a/packages/block-library/src/media-text/media-container.native.js b/packages/block-library/src/media-text/media-container.native.js index 2057486b73ac9c..dbc30dcf23e7aa 100644 --- a/packages/block-library/src/media-text/media-container.native.js +++ b/packages/block-library/src/media-text/media-container.native.js @@ -316,6 +316,7 @@ class MediaContainer extends Component { onSelect={ this.onSelectMediaUploadOption } allowedTypes={ ALLOWED_MEDIA_TYPES } onFocus={ this.props.onFocus } + className={ 'no-block-outline' } /> ); } diff --git a/packages/block-library/src/missing/block.json b/packages/block-library/src/missing/block.json index 9a44caa2485095..0bc512bbbf709d 100644 --- a/packages/block-library/src/missing/block.json +++ b/packages/block-library/src/missing/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/missing", "title": "Unsupported", "category": "text", diff --git a/packages/block-library/src/missing/edit.js b/packages/block-library/src/missing/edit.js index 1ef143a639ed06..f7aef453b5447c 100644 --- a/packages/block-library/src/missing/edit.js +++ b/packages/block-library/src/missing/edit.js @@ -16,22 +16,46 @@ import { safeHTML } from '@wordpress/dom'; function MissingBlockWarning( { attributes, convertToHTML, clientId } ) { const { originalName, originalUndelimitedContent } = attributes; const hasContent = !! originalUndelimitedContent; - const hasHTMLBlock = useSelect( + const { hasFreeformBlock, hasHTMLBlock } = useSelect( ( select ) => { const { canInsertBlockType, getBlockRootClientId } = select( blockEditorStore ); - return canInsertBlockType( - 'core/html', - getBlockRootClientId( clientId ) - ); + return { + hasFreeformBlock: canInsertBlockType( + 'core/freeform', + getBlockRootClientId( clientId ) + ), + hasHTMLBlock: canInsertBlockType( + 'core/html', + getBlockRootClientId( clientId ) + ), + }; }, [ clientId ] ); const actions = []; let messageHTML; - if ( hasContent && hasHTMLBlock ) { + + const convertToHtmlButton = ( + + ); + + if ( hasContent && ! hasFreeformBlock && ! originalName ) { + if ( hasHTMLBlock ) { + messageHTML = __( + 'It appears you are trying to use the deprecated Classic block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely. Alternatively, you can refresh the page to use the Classic block.' + ); + actions.push( convertToHtmlButton ); + } else { + messageHTML = __( + 'It appears you are trying to use the deprecated Classic block. You can leave this block intact, or remove it entirely. Alternatively, you can refresh the page to use the Classic block.' + ); + } + } else if ( hasContent && hasHTMLBlock ) { messageHTML = sprintf( /* translators: %s: block name */ __( @@ -39,11 +63,7 @@ function MissingBlockWarning( { attributes, convertToHTML, clientId } ) { ), originalName ); - actions.push( - - ); + actions.push( convertToHtmlButton ); } else { messageHTML = sprintf( /* translators: %s: block name */ diff --git a/packages/block-library/src/missing/edit.native.js b/packages/block-library/src/missing/edit.native.js index 8c93e1f604620a..cf590dc0181c4f 100644 --- a/packages/block-library/src/missing/edit.native.js +++ b/packages/block-library/src/missing/edit.native.js @@ -5,7 +5,7 @@ import { View, Text, TouchableWithoutFeedback, - TouchableHighlight, + TouchableOpacity, } from 'react-native'; /** @@ -83,7 +83,7 @@ export class UnsupportedBlockEdit extends Component { ); return ( - - + ); } @@ -255,6 +255,7 @@ export class UnsupportedBlockEdit extends Component { styles.unsupportedBlockSubtitle, styles.unsupportedBlockSubtitleDark ); + const subtitle = ( { __( 'Unsupported' ) } ); @@ -282,12 +283,14 @@ export class UnsupportedBlockEdit extends Component { ) } > { this.renderHelpIcon() } - - { title } + + + { title } + { subtitle } { this.renderSheet( title, originalName ) } diff --git a/packages/block-library/src/missing/style.native.scss b/packages/block-library/src/missing/style.native.scss index 6718713f320198..9a56f82f7e3f0d 100644 --- a/packages/block-library/src/missing/style.native.scss +++ b/packages/block-library/src/missing/style.native.scss @@ -31,11 +31,11 @@ height: 36; padding-top: 8; padding-bottom: 8; - color: $gray-darken-20; + color: $light-secondary; } .infoIconDark { - color: $gray-20; + color: $dark-tertiary; } .infoSheetIcon { @@ -82,7 +82,8 @@ } .unsupportedBlock { - background-color: $gray-lighten-30; + height: 142; + background-color: #e0e0e0; // $light-dim padding-top: 24; padding-bottom: 24; padding-left: 8; @@ -96,31 +97,37 @@ } .unsupportedBlockDark { - background-color: $background-dark-secondary; + background-color: #1f1f1f; // $dark-dim +} + +.unsupportedBlockHeader { + flex-direction: row; + align-items: center; + margin-top: 4; + margin-bottom: 8; } .unsupportedBlockIcon { - color: $gray-dark; + color: $light-secondary; } .unsupportedBlockIconDark { - color: $white; + color: $dark-tertiary; } .unsupportedBlockMessage { - margin-top: 4; text-align: center; - color: $gray-dark; - font-size: 14; - font-weight: 600; + color: $light-secondary; + font-size: 16; + font-weight: 400; + margin-left: 6; } .unsupportedBlockMessageDark { - color: $white; + color: $dark-tertiary; } .unsupportedBlockSubtitle { - margin-top: 2; text-align: center; color: $gray-darken-20; font-size: 12; diff --git a/packages/block-library/src/missing/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/missing/test/__snapshots__/edit.native.js.snap index f0f0db7010b54c..245410a5c5d570 100644 --- a/packages/block-library/src/missing/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/missing/test/__snapshots__/edit.native.js.snap @@ -7,7 +7,11 @@ exports[`Missing block renders without crashing 1`] = ` accessibilityRole="button" accessibilityState={ { + "busy": undefined, + "checked": undefined, "disabled": true, + "expanded": undefined, + "selected": undefined, } } accessible={true} @@ -24,7 +28,25 @@ exports[`Missing block renders without crashing 1`] = ` accessibilityHint="Tap here to show help" accessibilityLabel="Help button" accessibilityRole="button" + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } accessible={true} + collapsable={false} focusable={true} onClick={[Function]} onResponderGrant={[Function]} @@ -33,6 +55,11 @@ exports[`Missing block renders without crashing 1`] = ` onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} + style={ + { + "opacity": 1, + } + } > - - Path - - - missing/block/title - + + + Path + + + missing/block/title + + Unsupported diff --git a/packages/block-library/src/more/block.json b/packages/block-library/src/more/block.json index 25f1df23771f35..bfd95652ea1767 100644 --- a/packages/block-library/src/more/block.json +++ b/packages/block-library/src/more/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/more", "title": "More", "category": "design", diff --git a/packages/block-library/src/more/edit.native.js b/packages/block-library/src/more/edit.native.js index c140bcdc483674..d05e9920ae4571 100644 --- a/packages/block-library/src/more/edit.native.js +++ b/packages/block-library/src/more/edit.native.js @@ -1,15 +1,10 @@ -/** - * External dependencies - */ -import { View } from 'react-native'; -import Hr from 'react-native-hr'; - /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; import { withPreferredColorScheme } from '@wordpress/compose'; +import { HorizontalRule } from '@wordpress/components'; /** * Internal dependencies @@ -41,15 +36,13 @@ export class MoreEdit extends Component { ); return ( - -
    - + ); } } diff --git a/packages/block-library/src/navigation-link/block.json b/packages/block-library/src/navigation-link/block.json index ae151a279e5aca..b2cbeaed63d3e9 100644 --- a/packages/block-library/src/navigation-link/block.json +++ b/packages/block-library/src/navigation-link/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/navigation-link", "title": "Custom Link", "category": "design", diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 1d097f752a9f06..df7ca36d7272af 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -15,7 +15,6 @@ import { ToolbarButton, Tooltip, ToolbarGroup, - KeyboardShortcuts, } from '@wordpress/components'; import { displayShortcut, isKeyboardEvent, ENTER } from '@wordpress/keycodes'; import { __ } from '@wordpress/i18n'; @@ -36,16 +35,12 @@ import { } from '@wordpress/dom'; import { decodeEntities } from '@wordpress/html-entities'; import { link as linkIcon, addSubmenu } from '@wordpress/icons'; -import { - store as coreStore, - useResourcePermissions, -} from '@wordpress/core-data'; +import { store as coreStore } from '@wordpress/core-data'; import { useMergeRefs } from '@wordpress/compose'; /** * Internal dependencies */ -import { name } from './block.json'; import { LinkUI } from './link-ui'; import { updateAttributes } from './update-attributes'; import { getColors } from '../navigation/edit/utils'; @@ -185,8 +180,9 @@ export default function NavigationLinkEdit( { const itemLabelPlaceholder = __( 'Add label…' ); const ref = useRef(); - const pagesPermissions = useResourcePermissions( 'pages' ); - const postsPermissions = useResourcePermissions( 'posts' ); + // Change the label using inspector causes rich text to change focus on firefox. + // This is a workaround to keep the focus on the label field when label filed is focused we don't render the rich text. + const [ isLabelFieldFocused, setIsLabelFieldFocused ] = useState( false ); const { innerBlocks, @@ -209,7 +205,7 @@ export default function NavigationLinkEdit( { innerBlocks: getBlocks( clientId ), isAtMaxNesting: getBlockParentsByBlockName( clientId, [ - name, + 'core/navigation-link', 'core/navigation-submenu', ] ).length >= maxNestingLevel, isTopLevelLink: @@ -323,13 +319,6 @@ export default function NavigationLinkEdit( { setIsLinkOpen( false ); } - let userCanCreate = false; - if ( ! type || type === 'page' ) { - userCanCreate = pagesPermissions.canCreate; - } else if ( type === 'post' ) { - userCanCreate = postsPermissions.canCreate; - } - const { textColor, customTextColor, @@ -340,7 +329,7 @@ export default function NavigationLinkEdit( { function onKeyDown( event ) { if ( isKeyboardEvent.primary( event, 'k' ) || - ( ! url && event.keyCode === ENTER ) + ( ( ! url || isDraft || isInvalid ) && event.keyCode === ENTER ) ) { setIsLinkOpen( true ); } @@ -382,8 +371,8 @@ export default function NavigationLinkEdit( { }, { allowedBlocks: ALLOWED_BLOCKS, - __experimentalDefaultBlock: DEFAULT_BLOCK, - __experimentalDirectInsert: true, + defaultBlock: DEFAULT_BLOCK, + directInsert: true, renderAppender: false, } ); @@ -438,6 +427,8 @@ export default function NavigationLinkEdit( { } } label={ __( 'Label' ) } autoComplete="off" + onFocus={ () => setIsLabelFieldFocused( true ) } + onBlur={ () => setIsLabelFieldFocused( false ) } /> ) : ( <> - { ! isInvalid && ! isDraft && ( - <> - - setAttributes( { - label: labelValue, - } ) - } - onMerge={ mergeBlocks } - onReplace={ onReplace } - __unstableOnSplitAtEnd={ () => - insertBlocksAfter( - createBlock( - 'core/navigation-link' + { ! isInvalid && + ! isDraft && + ! isLabelFieldFocused && ( + <> + + setAttributes( { + label: labelValue, + } ) + } + onMerge={ mergeBlocks } + onReplace={ onReplace } + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( + createBlock( + 'core/navigation-link' + ) ) - ) - } - aria-label={ __( - 'Navigation link text' - ) } - placeholder={ itemLabelPlaceholder } - withoutInteractiveFormatting - allowedFormats={ [ - 'core/bold', - 'core/italic', - 'core/image', - 'core/strikethrough', - ] } - onClick={ () => { - if ( ! url ) { - setIsLinkOpen( true ); } - } } - /> - { description && ( - - { description } - - ) } - - ) } - { ( isInvalid || isDraft ) && ( + aria-label={ __( + 'Navigation link text' + ) } + placeholder={ itemLabelPlaceholder } + withoutInteractiveFormatting + allowedFormats={ [ + 'core/bold', + 'core/italic', + 'core/image', + 'core/strikethrough', + ] } + onClick={ () => { + if ( ! url ) { + setIsLinkOpen( true ); + } + } } + /> + { description && ( + + { description } + + ) } + + ) } + { ( isInvalid || + isDraft || + isLabelFieldFocused ) && (
    - - isSelected && - setIsLinkOpen( true ), - } } - /> @@ -592,12 +584,10 @@ export default function NavigationLinkEdit( { ) } { isLinkOpen && ( setIsLinkOpen( false ) } anchor={ popoverAnchor } - hasCreateSuggestion={ userCanCreate } onRemove={ removeLink } onChange={ ( updatedValue ) => { updateAttributes( diff --git a/packages/block-library/src/navigation-link/hooks.js b/packages/block-library/src/navigation-link/hooks.js index 57a942199e114a..585e4b160df798 100644 --- a/packages/block-library/src/navigation-link/hooks.js +++ b/packages/block-library/src/navigation-link/hooks.js @@ -4,7 +4,7 @@ import { category, page, - postContent, + postList, tag, customPostType, } from '@wordpress/icons'; @@ -12,7 +12,7 @@ import { function getIcon( variationName ) { switch ( variationName ) { case 'post': - return postContent; + return postList; case 'page': return page; case 'tag': diff --git a/packages/block-library/src/navigation-link/link-ui.js b/packages/block-library/src/navigation-link/link-ui.js index 3dede053de3db3..3a7b7d28f9e800 100644 --- a/packages/block-library/src/navigation-link/link-ui.js +++ b/packages/block-library/src/navigation-link/link-ui.js @@ -9,8 +9,11 @@ import { BlockIcon, store as blockEditorStore, } from '@wordpress/block-editor'; -import { createInterpolateElement } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; +import { createInterpolateElement, useMemo } from '@wordpress/element'; +import { + store as coreStore, + useResourcePermissions, +} from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; import { switchToBlockType } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -125,6 +128,8 @@ function LinkControlTransforms( { clientId } ) { export function LinkUI( props ) { const { saveEntityRecord } = useDispatch( coreStore ); + const pagesPermissions = useResourcePermissions( 'pages' ); + const postsPermissions = useResourcePermissions( 'posts' ); async function handleCreate( pageTitle ) { const postType = props.link.type || 'page'; @@ -154,11 +159,24 @@ export function LinkUI( props ) { } const { label, url, opensInNewTab, type, kind } = props.link; - const link = { - url, - opensInNewTab, - title: label && stripHTML( label ), - }; + + let userCanCreate = false; + if ( ! type || type === 'page' ) { + userCanCreate = pagesPermissions.canCreate; + } else if ( type === 'post' ) { + userCanCreate = postsPermissions.canCreate; + } + + // Memoize link value to avoid overriding the LinkControl's internal state. + // This is a temporary fix. See https://github.com/WordPress/gutenberg/issues/50976#issuecomment-1568226407. + const link = useMemo( + () => ( { + url, + opensInNewTab, + title: label && stripHTML( label ), + } ), + [ label, opensInNewTab, url ] + ); return ( { let format; diff --git a/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap b/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap index ac1dbd88a7564b..7d7e2a15f08dcc 100644 --- a/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap +++ b/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap @@ -27,10 +27,10 @@ exports[`hooks enhanceNavigationLinkVariations enhances variations with icon and "description": "A link to a post.", "icon": , "isActive": [Function], diff --git a/packages/block-library/src/navigation-link/update-attributes.js b/packages/block-library/src/navigation-link/update-attributes.js index 5133cae3878338..6aa28d1818d3cc 100644 --- a/packages/block-library/src/navigation-link/update-attributes.js +++ b/packages/block-library/src/navigation-link/update-attributes.js @@ -27,7 +27,6 @@ import { safeDecodeURI } from '@wordpress/url'; * @param {Object} updatedValue New block attributes to update. * @param {Function} setAttributes Block attribute update function. * @param {WPNavigationLinkBlockAttributes} blockAttributes Current block attributes. - * */ export const updateAttributes = ( diff --git a/packages/block-library/src/navigation-submenu/block.json b/packages/block-library/src/navigation-submenu/block.json index f311a9f36e41a7..91364109ea7400 100644 --- a/packages/block-library/src/navigation-submenu/block.json +++ b/packages/block-library/src/navigation-submenu/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/navigation-submenu", "title": "Submenu", "category": "design", diff --git a/packages/block-library/src/navigation-submenu/edit.js b/packages/block-library/src/navigation-submenu/edit.js index 7707a6442111e5..507ea64940c3a6 100644 --- a/packages/block-library/src/navigation-submenu/edit.js +++ b/packages/block-library/src/navigation-submenu/edit.js @@ -39,7 +39,6 @@ import { useMergeRefs, usePrevious } from '@wordpress/compose'; * Internal dependencies */ import { ItemSubmenuIcon } from './icons'; -import { name } from './block.json'; import { LinkUI } from '../navigation-link/link-ui'; import { updateAttributes } from '../navigation-link/update-attributes'; import { @@ -155,8 +154,7 @@ export default function NavigationSubmenuEdit( { const postsPermissions = useResourcePermissions( 'posts' ); const { - isAtMaxNesting, - isTopLevelItem, + parentCount, isParentOfSelectedBlock, isImmediateParentOfSelectedBlock, hasChildren, @@ -191,11 +189,10 @@ export default function NavigationSubmenuEdit( { } return { - isAtMaxNesting: - getBlockParentsByBlockName( clientId, name ).length >= - maxNestingLevel, - isTopLevelItem: - getBlockParentsByBlockName( clientId, name ).length === 0, + parentCount: getBlockParentsByBlockName( + clientId, + 'core/navigation-submenu' + ).length, isParentOfSelectedBlock: hasSelectedInnerBlock( clientId, true @@ -278,7 +275,7 @@ export default function NavigationSubmenuEdit( { customTextColor, backgroundColor, customBackgroundColor, - } = getColors( context, ! isTopLevelItem ); + } = getColors( context, parentCount > 0 ); function onKeyDown( event ) { if ( isKeyboardEvent.primary( event, 'k' ) ) { @@ -310,18 +307,19 @@ export default function NavigationSubmenuEdit( { // Always use overlay colors for submenus. const innerBlocksColors = getColors( context, true ); - const allowedBlocks = isAtMaxNesting - ? ALLOWED_BLOCKS.filter( - ( blockName ) => blockName !== 'core/navigation-submenu' - ) - : ALLOWED_BLOCKS; + const allowedBlocks = + parentCount >= maxNestingLevel + ? ALLOWED_BLOCKS.filter( + ( blockName ) => blockName !== 'core/navigation-submenu' + ) + : ALLOWED_BLOCKS; const navigationChildBlockProps = getNavigationChildBlockProps( innerBlocksColors ); const innerBlocksProps = useInnerBlocksProps( navigationChildBlockProps, { allowedBlocks, - __experimentalDefaultBlock: DEFAULT_BLOCK, - __experimentalDirectInsert: true, + defaultBlock: DEFAULT_BLOCK, + directInsert: true, // Ensure block toolbar is not too far removed from item // being edited. @@ -475,7 +473,6 @@ export default function NavigationSubmenuEdit( { } { ! openSubmenusOnClick && isLinkOpen && ( setIsLinkOpen( false ) } diff --git a/packages/block-library/src/navigation-submenu/index.php b/packages/block-library/src/navigation-submenu/index.php index 748613193bc709..2ae23a92b69c45 100644 --- a/packages/block-library/src/navigation-submenu/index.php +++ b/packages/block-library/src/navigation-submenu/index.php @@ -5,16 +5,6 @@ * @package WordPress */ -/** - * Build an array with CSS classes and inline styles defining the colors - * which will be applied to the navigation markup in the front-end. - * - * @param array $context Navigation block context. - * @param array $attributes Block attributes. - * @param bool $is_sub_menu Whether the block is a sub-menu. - * @return array Colors CSS classes and inline styles. - */ - /** * Build an array with CSS classes and inline styles defining the font sizes * which will be applied to the navigation markup in the front-end. @@ -199,9 +189,9 @@ function render_block_core_navigation_submenu( $attributes, $content, $block ) { $attributes['style']['color']['background'] = $block->context['customOverlayBackgroundColor']; } - // This allows us to be able to get a response from gutenberg_apply_colors_support. + // This allows us to be able to get a response from wp_apply_colors_support. $block->block_type->supports['color'] = true; - $colors_supports = gutenberg_apply_colors_support( $block->block_type, $attributes ); + $colors_supports = wp_apply_colors_support( $block->block_type, $attributes ); $css_classes = 'wp-block-navigation__submenu-container'; if ( array_key_exists( 'class', $colors_supports ) ) { $css_classes .= ' ' . $colors_supports['class']; diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index ce2bed0d8837f6..e45d0535367786 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/navigation", "title": "Navigation", "category": "theme", @@ -91,7 +91,6 @@ }, "supports": { "align": [ "wide", "full" ], - "anchor": true, "html": false, "inserter": true, "typography": { @@ -115,7 +114,7 @@ "blockGap": true } }, - "__experimentalLayout": { + "layout": { "allowSwitching": false, "allowInheriting": false, "allowVerticalAlignment": false, diff --git a/packages/block-library/src/navigation/constants.js b/packages/block-library/src/navigation/constants.js index 9bdf8736d7c2b6..07d71d50dd98ce 100644 --- a/packages/block-library/src/navigation/constants.js +++ b/packages/block-library/src/navigation/constants.js @@ -19,3 +19,20 @@ export const PRIORITIZED_INSERTER_BLOCKS = [ 'core/navigation-link/page', 'core/navigation-link', ]; + +// These parameters must be kept aligned with those in +// lib/compat/wordpress-6.3/navigation-block-preloading.php +// and +// edit-site/src/components/sidebar-navigation-screen-navigation-menus/constants.js +export const PRELOADED_NAVIGATION_MENUS_QUERY = { + per_page: 100, + status: [ 'publish', 'draft' ], + order: 'desc', + orderby: 'date', +}; + +export const SELECT_NAVIGATION_MENUS_ARGS = [ + 'postType', + 'wp_navigation', + PRELOADED_NAVIGATION_MENUS_QUERY, +]; diff --git a/packages/block-library/src/navigation/deprecated.js b/packages/block-library/src/navigation/deprecated.js index c2b1a2fd3bf48d..6f29ab224a4405 100644 --- a/packages/block-library/src/navigation/deprecated.js +++ b/packages/block-library/src/navigation/deprecated.js @@ -121,7 +121,7 @@ const v6 = { blockGap: true, }, }, - __experimentalLayout: { + layout: { allowSwitching: false, allowInheriting: false, default: { diff --git a/packages/block-library/src/navigation/edit/accessible-description.js b/packages/block-library/src/navigation/edit/accessible-description.js new file mode 100644 index 00000000000000..c93ecfa9ceb604 --- /dev/null +++ b/packages/block-library/src/navigation/edit/accessible-description.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { VisuallyHidden } from '@wordpress/components'; + +export default function AccessibleDescription( { id, children } ) { + return ( + +
    + { children } +
    +
    + ); +} diff --git a/packages/block-library/src/navigation/edit/accessible-menu-description.js b/packages/block-library/src/navigation/edit/accessible-menu-description.js new file mode 100644 index 00000000000000..c2c0d1ce764a69 --- /dev/null +++ b/packages/block-library/src/navigation/edit/accessible-menu-description.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { useEntityProp } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import AccessibleDescription from './accessible-description'; + +export default function AccessibleMenuDescription( { id } ) { + const [ menuTitle ] = useEntityProp( 'postType', 'wp_navigation', 'title' ); + /* translators: %s: Title of a Navigation Menu post. */ + const description = sprintf( __( `Navigation menu: "%s"` ), menuTitle ); + + return ( + { description } + ); +} diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 52760e6fdfd9b3..483e8abaab24f1 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -26,6 +26,7 @@ import { __experimentalColorGradientSettingsDropdown as ColorGradientSettingsDropdown, __experimentalUseBlockOverlayActive as useBlockOverlayActive, __experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients, + useBlockEditingMode, } from '@wordpress/block-editor'; import { EntityProvider, store as coreStore } from '@wordpress/core-data'; @@ -37,10 +38,12 @@ import { __experimentalToggleGroupControlOption as ToggleGroupControlOption, Button, Spinner, + Notice, } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; import { close, Icon } from '@wordpress/icons'; +import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies @@ -67,6 +70,9 @@ import { detectColors } from './utils'; import ManageMenusButton from './manage-menus-button'; import MenuInspectorControls from './menu-inspector-controls'; import DeletedNavigationWarning from './deleted-navigation-warning'; +import AccessibleDescription from './accessible-description'; +import AccessibleMenuDescription from './accessible-menu-description'; +import { unlock } from '../../lock-unlock'; function Navigation( { attributes, @@ -114,6 +120,8 @@ function Navigation( { const recursionId = `navigationMenu/${ ref }`; const hasAlreadyRendered = useHasRecursion( recursionId ); + const blockEditingMode = useBlockEditingMode(); + // Preload classic menus, so that they don't suddenly pop-in when viewing // the Select Menu dropdown. const { menus: classicMenus } = useNavigationEntities(); @@ -191,7 +199,7 @@ function Navigation( { convert: convertClassicMenu, status: classicMenuConversionStatus, error: classicMenuConversionError, - } = useConvertClassicToBlockMenu( clientId ); + } = useConvertClassicToBlockMenu( createNavigationMenu ); const isConvertingClassicMenu = classicMenuConversionStatus === CLASSIC_MENU_CONVERSION_PENDING; @@ -218,7 +226,7 @@ function Navigation( { // that automatically saves the menu as an entity when changes are made to the inner blocks. const hasUnsavedBlocks = hasUncontrolledInnerBlocks && ! isEntityAvailable; - const { getNavigationFallbackId } = useSelect( coreStore ); + const { getNavigationFallbackId } = unlock( useSelect( coreStore ) ); const navigationFallbackId = ! ( ref || hasUnsavedBlocks ) ? getNavigationFallbackId() @@ -282,6 +290,7 @@ function Navigation( { const textDecoration = attributes.style?.typography?.textDecoration; const hasBlockOverlay = useBlockOverlayActive( clientId ); + const isResponsive = 'never' !== overlayMenu; const blockProps = useBlockProps( { ref: navRef, className: classnames( className, { @@ -291,7 +300,7 @@ function Navigation( { 'items-justified-center': justifyContent === 'center', 'is-vertical': orientation === 'vertical', 'no-wrap': flexWrap === 'nowrap', - 'is-responsive': 'never' !== overlayMenu, + 'is-responsive': isResponsive, 'has-text-color': !! textColor.color || !! textColor?.class, [ getColorClassName( 'color', textColor?.slug ) ]: !! textColor?.slug, @@ -473,13 +482,32 @@ function Navigation( { const hasManagePermissions = canUserCreateNavigationMenu || canUserUpdateNavigationMenu; - const isResponsive = 'never' !== overlayMenu; const overlayMenuPreviewClasses = classnames( 'wp-block-navigation__overlay-menu-preview', { open: overlayMenuPreview } ); + const submenuAccessibilityNotice = + ! showSubmenuIcon && ! openSubmenusOnClick + ? __( + 'The current menu options offer reduced accessibility for users and are not recommended. Enabling either "Open on Click" or "Show arrow" offers enhanced accessibility by allowing keyboard users to browse submenus selectively.' + ) + : ''; + + const isFirstRender = useRef( true ); // Don't speak on first render. + useEffect( () => { + if ( ! isFirstRender.current && submenuAccessibilityNotice ) { + speak( submenuAccessibilityNotice ); + } + isFirstRender.current = false; + }, [ submenuAccessibilityNotice ] ); + + const overlayMenuPreviewId = useInstanceId( + OverlayMenuPreview, + `overlay-menu-preview` + ); + const colorGradientSettings = useMultipleOriginColorsAndGradients(); const stylingInspectorControls = ( <> @@ -495,6 +523,9 @@ function Navigation( { ! overlayMenuPreview ); } } + aria-label={ __( 'Overlay menu controls' ) } + aria-controls={ overlayMenuPreviewId } + aria-expanded={ overlayMenuPreview } > { hasIcon && ( <> @@ -509,13 +540,16 @@ function Navigation( { ) } - { overlayMenuPreview && ( - - ) } +
    + { overlayMenuPreview && ( +
    ) }

    { __( 'Overlay Menu' ) }

    @@ -573,6 +607,18 @@ function Navigation( { disabled={ attributes.openSubmenusOnClick } label={ __( 'Show arrow' ) } /> + + { submenuAccessibilityNotice && ( +
    + + { submenuAccessibilityNotice } + +
    + ) } ) } @@ -633,12 +679,23 @@ function Navigation( { ); + const accessibleDescriptionId = `${ clientId }-desc`; + const isManageMenusButtonDisabled = ! hasManagePermissions || ! hasResolvedNavigationMenus; if ( hasUnsavedBlocks && ! isCreatingNavigationMenu ) { return ( - + + + { __( 'Unsaved Navigation Menu.' ) } + + - { stylingInspectorControls } + { blockEditingMode === 'default' && stylingInspectorControls } - { stylingInspectorControls } - { isEntityAvailable && ( + { blockEditingMode === 'default' && stylingInspectorControls } + { blockEditingMode === 'default' && isEntityAvailable && ( { hasResolvedCanUserUpdateNavigationMenu && canUserUpdateNavigationMenu && ( @@ -801,7 +861,17 @@ function Navigation( { ) } { ! isLoading && ( - + + ( _updatedAttributes ) => { + if ( ! _insertedBlockClientId ) return; + updateBlockAttributes( _insertedBlockClientId, _updatedAttributes ); + }; + + return ( + { + setInsertedBlock( null ); + } } + onChange={ ( updatedValue ) => { + updateAttributes( + updatedValue, + setInsertedBlockAttributes( insertedBlock?.clientId ), + insertedBlock?.attributes + ); + setInsertedBlock( null ); + } } + onCancel={ () => { + setInsertedBlock( null ); + } } + /> + ); +} const MainContent = ( { clientId, @@ -40,26 +82,13 @@ const MainContent = ( { isNavigationMenuMissing, onCreateNew, } ) => { - const { PrivateListView } = unlock( blockEditorPrivateApis ); - - // Provide a hierarchy of clientIds for the given Navigation block (clientId). - // This is required else the list view will display the entire block tree. - const clientIdsTree = useSelect( + const hasChildren = useSelect( ( select ) => { - const { __unstableGetClientIdsTree } = select( blockEditorStore ); - return __unstableGetClientIdsTree( clientId ); + return !! select( blockEditorStore ).getBlockCount( clientId ); }, [ clientId ] ); - const { updateBlockAttributes } = useDispatch( blockEditorStore ); - - const setInsertedBlockAttributes = - ( _insertedBlockClientId ) => ( _updatedAttributes ) => { - if ( ! _insertedBlockClientId ) return; - updateBlockAttributes( _insertedBlockClientId, _updatedAttributes ); - }; - const { navigationMenu } = useNavigationMenu( currentMenuId ); if ( currentMenuId && isNavigationMenuMissing ) { @@ -74,68 +103,26 @@ const MainContent = ( { ? sprintf( /* translators: %s: The name of a menu. */ __( 'Structure for navigation menu: %s' ), - navigationMenu?.title?.rendered || __( 'Untitled menu' ) + navigationMenu?.title || __( 'Untitled menu' ) ) : __( 'You have not yet created any menus. Displaying a list of your Pages' ); - const renderLinkUI = ( - currentBlock, - lastInsertedBlock, - setLastInsertedBlock - ) => { - const blockSupportsLinkUI = BLOCKS_WITH_LINK_UI_SUPPORT?.includes( - lastInsertedBlock?.name - ); - const currentBlockWasJustInserted = - lastInsertedBlock?.clientId === currentBlock.clientId; - - const shouldShowLinkUIForBlock = - blockSupportsLinkUI && currentBlockWasJustInserted; - - return ( - shouldShowLinkUIForBlock && ( - { - setLastInsertedBlock( null ); - } } - hasCreateSuggestion={ false } - onChange={ ( updatedValue ) => { - updateAttributes( - updatedValue, - setInsertedBlockAttributes( - lastInsertedBlock?.clientId - ), - lastInsertedBlock?.attributes - ); - setLastInsertedBlock( null ); - } } - onCancel={ () => { - setLastInsertedBlock( null ); - } } - /> - ) - ); - }; - return (
    - { clientIdsTree.length === 0 && ( + { ! hasChildren && (

    { __( 'This navigation menu is empty.' ) }

    ) }
    ); @@ -150,6 +137,7 @@ const MenuInspectorControls = ( props ) => { onSelectClassicMenu, onSelectNavigationMenu, isManageMenusButtonDisabled, + blockEditingMode, } = props; return ( @@ -162,22 +150,24 @@ const MenuInspectorControls = ( props ) => { > { __( 'Menu' ) } - + { blockEditingMode === 'default' && ( + + ) } diff --git a/packages/block-library/src/navigation/edit/navigation-menu-selector.js b/packages/block-library/src/navigation/edit/navigation-menu-selector.js index a7458fd3011654..d895de770fdafe 100644 --- a/packages/block-library/src/navigation/edit/navigation-menu-selector.js +++ b/packages/block-library/src/navigation/edit/navigation-menu-selector.js @@ -19,12 +19,22 @@ import { useEntityProp } from '@wordpress/core-data'; import useNavigationMenu from '../use-navigation-menu'; import useNavigationEntities from '../use-navigation-entities'; -function buildMenuLabel( title, id ) { - const label = - decodeEntities( title?.rendered ) || +function buildMenuLabel( title, id, status ) { + if ( ! title ) { /* translators: %s is the index of the menu in the list of menus. */ - sprintf( __( '(no title %s)' ), id ); - return label; + return sprintf( __( '(no title %s)' ), id ); + } + + if ( status === 'publish' ) { + return decodeEntities( title ); + } + + return sprintf( + // translators: %1s: title of the menu; %2s: status of the menu (draft, pending, etc.). + __( '%1$s (%2$s)' ), + decodeEntities( title ), + status + ); } function NavigationMenuSelector( { @@ -61,8 +71,12 @@ function NavigationMenuSelector( { const menuChoices = useMemo( () => { return ( - navigationMenus?.map( ( { id, title }, index ) => { - const label = buildMenuLabel( title, index + 1 ); + navigationMenus?.map( ( { id, title, status }, index ) => { + const label = buildMenuLabel( + title?.rendered, + index + 1, + status + ); return { value: id, @@ -86,7 +100,7 @@ function NavigationMenuSelector( { let selectorLabel = ''; if ( isCreatingMenu || isResolvingNavigationMenus ) { - selectorLabel = __( 'Loading …' ); + selectorLabel = __( 'Loading…' ); } else if ( noMenuSelected || noBlockMenus || menuUnavailable ) { // Note: classic Menus may be available. selectorLabel = __( 'Choose or create a Navigation menu' ); diff --git a/packages/block-library/src/navigation/edit/placeholder/index.js b/packages/block-library/src/navigation/edit/placeholder/index.js index 9914f1a128b3e6..61fd13c8d97798 100644 --- a/packages/block-library/src/navigation/edit/placeholder/index.js +++ b/packages/block-library/src/navigation/edit/placeholder/index.js @@ -32,7 +32,7 @@ export default function NavigationPlaceholder( { } if ( isResolvingMenus ) { - speak( __( 'Loading Navigation block setup options.' ) ); + speak( __( 'Loading navigation block setup options…' ) ); } if ( hasResolvedMenus ) { diff --git a/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js b/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js index 51a8d2aed7fe29..e2ad08c2a99ab1 100644 --- a/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js +++ b/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js @@ -11,7 +11,11 @@ import { useContext, useEffect, useRef, useMemo } from '@wordpress/element'; * Internal dependencies */ import { areBlocksDirty } from './are-blocks-dirty'; -import { DEFAULT_BLOCK, ALLOWED_BLOCKS } from '../constants'; +import { + DEFAULT_BLOCK, + ALLOWED_BLOCKS, + SELECT_NAVIGATION_MENUS_ARGS, +} from '../constants'; const EMPTY_OBJECT = {}; @@ -64,8 +68,8 @@ export default function UnsavedInnerBlocks( { { renderAppender: hasSelection ? undefined : false, allowedBlocks: ALLOWED_BLOCKS, - __experimentalDefaultBlock: DEFAULT_BLOCK, - __experimentalDirectInsert: shouldDirectInsert, + defaultBlock: DEFAULT_BLOCK, + directInsert: shouldDirectInsert, } ); @@ -82,11 +86,7 @@ export default function UnsavedInnerBlocks( { isSaving: isSavingEntityRecord( 'postType', 'wp_navigation' ), hasResolvedAllNavigationMenus: hasFinishedResolution( 'getEntityRecords', - [ - 'postType', - 'wp_navigation', - { per_page: -1, status: [ 'publish', 'draft' ] }, - ] + SELECT_NAVIGATION_MENUS_ARGS ), }; }, diff --git a/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js b/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js index 70f9a6ff4bfeab..300672fa91e8ad 100644 --- a/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js +++ b/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js @@ -9,7 +9,6 @@ import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import useCreateNavigationMenu from './use-create-navigation-menu'; import menuItemsToBlocks from '../menu-items-to-blocks'; export const CLASSIC_MENU_CONVERSION_SUCCESS = 'success'; @@ -21,15 +20,10 @@ export const CLASSIC_MENU_CONVERSION_IDLE = 'idle'; // do not import the same classic menu twice. let classicMenuBeingConvertedId = null; -function useConvertClassicToBlockMenu( clientId ) { - /* - * The wp_navigation post is created as a draft so the changes on the frontend and - * the site editor are not permanent without a save interaction done by the user. - */ - const { create: createNavigationMenu } = useCreateNavigationMenu( - clientId, - 'draft' - ); +function useConvertClassicToBlockMenu( + createNavigationMenu, + { throwOnError = false } = {} +) { const registry = useRegistry(); const { editEntityRecord } = useDispatch( coreStore ); @@ -158,19 +152,21 @@ function useConvertClassicToBlockMenu( clientId ) { classicMenuBeingConvertedId = null; // Rethrow error for debugging. - throw new Error( - sprintf( - // translators: %s: the name of a menu (e.g. Header navigation). - __( `Unable to create Navigation Menu "%s".` ), - menuName - ), - { - cause: err, - } - ); + if ( throwOnError ) { + throw new Error( + sprintf( + // translators: %s: the name of a menu (e.g. Header navigation). + __( `Unable to create Navigation Menu "%s".` ), + menuName + ), + { + cause: err, + } + ); + } } ); }, - [ convertClassicMenuToBlockMenu ] + [ convertClassicMenuToBlockMenu, throwOnError ] ); return { diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index 949a7c773eb6e6..107fb6e6de5fd5 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -503,28 +503,6 @@ body.editor-styles-wrapper .wp-block-navigation__responsive-container.is-menu-op left: 0; } -// Without this, the block cannot be selected, nor does the right container get focus. -// @todo: this is disruptive. Ideally we can retire a few of the containers, -// so focus is applied naturally on the block container. -// It's important the right container has focus, otherwise you can't press -// "Delete" to remove the block. -.wp-block-navigation__responsive-container, -.wp-block-navigation__responsive-close { - @include break-small() { - pointer-events: none; - - .wp-block-navigation__responsive-container-close, - .block-editor-block-list__layout * { - pointer-events: all; - } - } - - // Page List items should remain inert. - .wp-block-pages-list__item__link { - pointer-events: none; - } -} - // The menu and close buttons need higher specificity in the editor. .components-button.wp-block-navigation__responsive-container-open.wp-block-navigation__responsive-container-open, .components-button.wp-block-navigation__responsive-container-close.wp-block-navigation__responsive-container-close { diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 5756724bfa7182..52376aba0d44e1 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -67,6 +67,97 @@ function block_core_navigation_sort_menu_items_by_parent_id( $menu_items ) { } } +if ( gutenberg_should_block_use_interactivity_api( 'core/navigation' ) ) { + + /** + * Add Interactivity API directives to the navigation-submenu and page-list blocks markup using the Tag Processor + * The final HTML of the navigation-submenu and the page-list blocks will look similar to this: + * + *
  • + * + * Title + *
      + * SUBMENU ITEMS + *
    + *
  • + * + * @param string $w Markup of the navigation block. + * @param array $block_attributes Block attributes. + * + * @return string Submenu markup with the directives injected. + */ + function block_core_navigation_add_directives_to_submenu( $w, $block_attributes ) { + while ( $w->next_tag( + array( + 'tag_name' => 'LI', + 'class_name' => 'has-child', + ) + ) ) { + // Add directives to the parent `
  • `. + $w->set_attribute( 'data-wp-interactive', true ); + $w->set_attribute( 'data-wp-context', '{ "core": { "navigation": { "submenuOpenedBy": {}, "type": "submenu" } } }' ); + $w->set_attribute( 'data-wp-effect', 'effects.core.navigation.initMenu' ); + $w->set_attribute( 'data-wp-on--focusout', 'actions.core.navigation.handleMenuFocusout' ); + $w->set_attribute( 'data-wp-on--keydown', 'actions.core.navigation.handleMenuKeydown' ); + if ( ! isset( $block_attributes['openSubmenusOnClick'] ) || false === $block_attributes['openSubmenusOnClick'] ) { + $w->set_attribute( 'data-wp-on--mouseenter', 'actions.core.navigation.openMenuOnHover' ); + $w->set_attribute( 'data-wp-on--mouseleave', 'actions.core.navigation.closeMenuOnHover' ); + } + + // Add directives to the toggle submenu button. + if ( $w->next_tag( + array( + 'tag_name' => 'BUTTON', + 'class_name' => 'wp-block-navigation-submenu__toggle', + ) + ) ) { + $w->set_attribute( 'data-wp-on--click', 'actions.core.navigation.toggleMenuOnClick' ); + $w->set_attribute( 'data-wp-bind--aria-expanded', 'selectors.core.navigation.isMenuOpen' ); + }; + + // Add directives to the submenu. + if ( $w->next_tag( + array( + 'tag_name' => 'UL', + 'class_name' => 'wp-block-navigation__submenu-container', + ) + ) ) { + $w->set_attribute( 'data-wp-on--focusin', 'actions.core.navigation.openMenuOnFocus' ); + } + + // Iterate through subitems if exist. + block_core_navigation_add_directives_to_submenu( $w, $block_attributes ); + } + return $w->get_updated_html(); + }; + + /** + * Replaces view script for the Navigation block with version using Interactivity API. + * + * @param array $metadata Block metadata as read in via block.json. + * + * @return array Filtered block type metadata. + */ + function gutenberg_block_core_navigation_update_interactive_view_script( $metadata ) { + if ( 'core/navigation' === $metadata['name'] ) { + $metadata['viewScript'] = array( 'file:./view-interactivity.min.js' ); + $metadata['supports']['interactivity'] = true; + } + return $metadata; + } + add_filter( 'block_type_metadata', 'gutenberg_block_core_navigation_update_interactive_view_script', 10, 1 ); +} /** @@ -214,7 +305,7 @@ function block_core_navigation_render_submenu_icon() { function block_core_navigation_filter_out_empty_blocks( $parsed_blocks ) { $filtered = array_filter( $parsed_blocks, - function( $block ) { + static function( $block ) { return isset( $block['blockName'] ); } ); @@ -263,7 +354,11 @@ function block_core_navigation_get_fallback_blocks() { // If `core/page-list` is not registered then return empty blocks. $fallback_blocks = $registry->is_registered( 'core/page-list' ) ? $page_list_fallback : array(); - $navigation_post = WP_Navigation_Fallback_Gutenberg::get_fallback(); + if ( class_exists( 'WP_Navigation_Fallback' ) ) { + $navigation_post = WP_Navigation_Fallback::get_fallback(); + } else { + $navigation_post = Gutenberg_Navigation_Fallback::get_fallback(); + } // Use the first non-empty Navigation as fallback if available. if ( $navigation_post ) { @@ -364,15 +459,6 @@ function render_block_core_navigation( $attributes, $content, $block ) { */ $has_old_responsive_attribute = ! empty( $attributes['isResponsive'] ) && $attributes['isResponsive']; $is_responsive_menu = isset( $attributes['overlayMenu'] ) && 'never' !== $attributes['overlayMenu'] || $has_old_responsive_attribute; - $should_load_view_script = ! wp_script_is( 'wp-block-navigation-view' ) && ( $is_responsive_menu || $attributes['openSubmenusOnClick'] || $attributes['showSubmenuIcon'] ); - if ( $should_load_view_script ) { - wp_enqueue_script( 'wp-block-navigation-view' ); - } - - $should_load_modal_view_script = isset( $attributes['overlayMenu'] ) && 'never' !== $attributes['overlayMenu']; - if ( $should_load_modal_view_script ) { - wp_enqueue_script( 'wp-block-navigation-view-modal' ); - } $inner_blocks = $block->inner_blocks; @@ -517,14 +603,44 @@ function render_block_core_navigation( $attributes, $content, $block ) { 'core/site-logo', ); + $block_styles = isset( $attributes['styles'] ) ? $attributes['styles'] : ''; + $style = $block_styles . $colors['inline_styles'] . $font_sizes['inline_styles']; + $class = implode( ' ', $classes ); + + // If the menu name has been used previously then append an ID + // to the name to ensure uniqueness across a given post. + if ( isset( $seen_menu_names[ $nav_menu_name ] ) && $seen_menu_names[ $nav_menu_name ] > 1 ) { + $count = $seen_menu_names[ $nav_menu_name ]; + $nav_menu_name = $nav_menu_name . ' ' . ( $count ); + } + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $class, + 'style' => $style, + 'aria-label' => $nav_menu_name, + ) + ); + + $container_attributes = get_block_wrapper_attributes( + array( + 'class' => 'wp-block-navigation__container ' . $class, + 'style' => $style, + ) + ); + $inner_blocks_html = ''; $is_list_open = false; + $has_submenus = false; foreach ( $inner_blocks as $inner_block ) { $is_list_item = in_array( $inner_block->name, $list_item_nav_blocks, true ); if ( $is_list_item && ! $is_list_open ) { $is_list_open = true; - $inner_blocks_html .= '
      '; + $inner_blocks_html .= sprintf( + '
        ', + $container_attributes + ); } if ( ! $is_list_item && $is_list_open ) { @@ -533,6 +649,15 @@ function render_block_core_navigation( $attributes, $content, $block ) { } $inner_block_content = $inner_block->render(); + $p = new WP_HTML_Tag_Processor( $inner_block_content ); + if ( $p->next_tag( + array( + 'name' => 'LI', + 'class_name' => 'has-child', + ) + ) ) { + $has_submenus = true; + } if ( ! empty( $inner_block_content ) ) { if ( in_array( $inner_block->name, $needs_list_item_wrapper, true ) ) { $inner_blocks_html .= '
      • ' . $inner_block_content . '
      • '; @@ -546,22 +671,41 @@ function render_block_core_navigation( $attributes, $content, $block ) { $inner_blocks_html .= '
      '; } - $block_styles = isset( $attributes['styles'] ) ? $attributes['styles'] : ''; + $needed_script_map = array( + 'wp-block-navigation-view' => ( $has_submenus && ( $attributes['openSubmenusOnClick'] || $attributes['showSubmenuIcon'] ) ), + 'wp-block-navigation-view-2' => $is_responsive_menu, + ); - // If the menu name has been used previously then append an ID - // to the name to ensure uniqueness across a given post. - if ( isset( $seen_menu_names[ $nav_menu_name ] ) && $seen_menu_names[ $nav_menu_name ] > 1 ) { - $count = $seen_menu_names[ $nav_menu_name ]; - $nav_menu_name = $nav_menu_name . ' ' . ( $count ); + $should_load_view_script = false; + if ( gutenberg_should_block_use_interactivity_api( 'core/navigation' ) ) { + // TODO: The script is still loaded even when it isn't needed when the Interactivity API is used. + $should_load_view_script = count( array_filter( $needed_script_map ) ) > 0; + } else { + foreach ( $needed_script_map as $view_script_handle => $is_view_script_needed ) { + + // If the script already exists, there is no point in removing it from viewScript. + if ( wp_script_is( $view_script_handle ) ) { + continue; + } + + $script_handles = $block->block_type->view_script_handles; + + // If the script is not needed, and it is still in the `view_script_handles`, remove it. + if ( ! $is_view_script_needed && in_array( $view_script_handle, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_script_handle ) ); + } + // If the script is needed, but it was previously removed, add it again. + if ( $is_view_script_needed && ! in_array( $view_script_handle, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_script_handle ) ); + } + } } - $wrapper_attributes = get_block_wrapper_attributes( - array( - 'class' => implode( ' ', $classes ), - 'style' => $block_styles . $colors['inline_styles'] . $font_sizes['inline_styles'], - 'aria-label' => $nav_menu_name, - ) - ); + // Add directives to the submenu if needed. + if ( gutenberg_should_block_use_interactivity_api( 'core/navigation' ) && $has_submenus && $should_load_view_script ) { + $w = new WP_HTML_Tag_Processor( $inner_blocks_html ); + $inner_blocks_html = block_core_navigation_add_directives_to_submenu( $w, $attributes ); + } $modal_unique_id = wp_unique_id( 'modal-' ); @@ -600,12 +744,45 @@ function render_block_core_navigation( $attributes, $content, $block ) { $toggle_aria_label_open = $should_display_icon_label ? 'aria-label="' . __( 'Open menu' ) . '"' : ''; // Open button label. $toggle_aria_label_close = $should_display_icon_label ? 'aria-label="' . __( 'Close menu' ) . '"' : ''; // Close button label. + // Add Interactivity API directives to the markup if needed. + $nav_element_directives = ''; + $open_button_directives = ''; + $responsive_container_directives = ''; + $responsive_dialog_directives = ''; + $close_button_directives = ''; + if ( gutenberg_should_block_use_interactivity_api( 'core/navigation' ) && $should_load_view_script ) { + $nav_element_directives = ' + data-wp-interactive + data-wp-context=\'{ "core": { "navigation": { "overlayOpenedBy": {}, "type": "overlay", "roleAttribute": "" } } }\' + '; + $open_button_directives = ' + data-wp-on--click="actions.core.navigation.openMenuOnClick" + data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" + '; + $responsive_container_directives = ' + data-wp-class--has-modal-open="selectors.core.navigation.isMenuOpen" + data-wp-class--is-menu-open="selectors.core.navigation.isMenuOpen" + data-wp-effect="effects.core.navigation.initMenu" + data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" + data-wp-on--focusout="actions.core.navigation.handleMenuFocusout" + tabindex="-1" + '; + $responsive_dialog_directives = ' + data-wp-bind--aria-modal="selectors.core.navigation.isMenuOpen" + data-wp-bind--role="selectors.core.navigation.roleAttribute" + data-wp-effect="effects.core.navigation.focusFirstElement" + '; + $close_button_directives = ' + data-wp-on--click="actions.core.navigation.closeMenuOnClick" + '; + } + $responsive_container_markup = sprintf( - ' -
      + ' +
      -
      - +
      +
      %2$s
      @@ -621,13 +798,18 @@ function render_block_core_navigation( $attributes, $content, $block ) { esc_attr( safecss_filter_attr( $colors['overlay_inline_styles'] ) ), __( 'Menu' ), $toggle_button_content, - $toggle_close_button_content + $toggle_close_button_content, + $open_button_directives, + $responsive_container_directives, + $responsive_dialog_directives, + $close_button_directives ); return sprintf( - '', + '', $wrapper_attributes, - $responsive_container_markup + $responsive_container_markup, + $nav_element_directives ); } @@ -685,6 +867,8 @@ function block_core_navigation_typographic_presets_backcompatibility( $parsed_bl /** * Turns menu item data into a nested array of parsed blocks * + * @deprecated 6.3.0 Use WP_Navigation_Fallback::parse_blocks_from_menu_items() instead. + * * @param array $menu_items An array of menu items that represent * an individual level of a menu. * @param array $menu_items_by_parent_id An array keyed by the id of the @@ -695,7 +879,7 @@ function block_core_navigation_typographic_presets_backcompatibility( $parsed_bl */ function block_core_navigation_parse_blocks_from_menu_items( $menu_items, $menu_items_by_parent_id ) { - _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback_Gutenberg::parse_blocks_from_menu_items' ); + _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback::parse_blocks_from_menu_items' ); if ( empty( $menu_items ) ) { return array(); @@ -740,11 +924,13 @@ function block_core_navigation_parse_blocks_from_menu_items( $menu_items, $menu_ /** * Get the classic navigation menu to use as a fallback. * + * @deprecated 6.3.0 Use WP_Navigation_Fallback::get_classic_menu_fallback() instead. + * * @return object WP_Term The classic navigation. */ function block_core_navigation_get_classic_menu_fallback() { - _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback_Gutenberg::get_classic_menu_fallback' ); + _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback::get_classic_menu_fallback' ); $classic_nav_menus = wp_get_nav_menus(); @@ -771,7 +957,7 @@ function block_core_navigation_get_classic_menu_fallback() { // Otherwise return the most recently created classic menu. usort( $classic_nav_menus, - function( $a, $b ) { + static function( $a, $b ) { return $b->term_id - $a->term_id; } ); @@ -782,12 +968,14 @@ function( $a, $b ) { /** * Converts a classic navigation to blocks. * + * @deprecated 6.3.0 Use WP_Navigation_Fallback::get_classic_menu_fallback_blocks() instead. + * * @param object $classic_nav_menu WP_Term The classic navigation object to convert. * @return array the normalized parsed blocks. */ function block_core_navigation_get_classic_menu_fallback_blocks( $classic_nav_menu ) { - _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback_Gutenberg::get_classic_menu_fallback_blocks' ); + _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback::get_classic_menu_fallback_blocks' ); // BEGIN: Code that already exists in wp_nav_menu(). $menu_items = wp_get_nav_menu_items( $classic_nav_menu->term_id, array( 'update_post_term_cache' => false ) ); @@ -820,13 +1008,15 @@ function block_core_navigation_get_classic_menu_fallback_blocks( $classic_nav_me } /** - * If there's a the classic menu then use it as a fallback. + * If there's a classic menu then use it as a fallback. + * + * @deprecated 6.3.0 Use WP_Navigation_Fallback::create_classic_menu_fallback() instead. * * @return array the normalized parsed blocks. */ function block_core_navigation_maybe_use_classic_menu_fallback() { - _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback_Gutenberg::create_classic_menu_fallback' ); + _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback::create_classic_menu_fallback' ); // See if we have a classic menu. $classic_nav_menu = block_core_navigation_get_classic_menu_fallback(); @@ -865,11 +1055,13 @@ function block_core_navigation_maybe_use_classic_menu_fallback() { /** * Finds the most recently published `wp_navigation` Post. * + * @deprecated 6.3.0 Use WP_Navigation_Fallback::get_most_recently_published_navigation() instead. + * * @return WP_Post|null the first non-empty Navigation or null. */ function block_core_navigation_get_most_recently_published_navigation() { - _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback_Gutenberg::get_most_recently_published_navigation' ); + _deprecated_function( __FUNCTION__, '6.3.0', 'WP_Navigation_Fallback::get_most_recently_published_navigation' ); // Default to the most recently created menu. $parsed_args = array( diff --git a/packages/block-library/src/navigation/interactivity.js b/packages/block-library/src/navigation/interactivity.js deleted file mode 100644 index 80152762c9cd63..00000000000000 --- a/packages/block-library/src/navigation/interactivity.js +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Internal dependencies - */ -import { store } from '../utils/interactivity'; - -const focusableSelectors = [ - 'a[href]', - 'area[href]', - 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', - 'select:not([disabled]):not([aria-hidden])', - 'textarea:not([disabled]):not([aria-hidden])', - 'button:not([disabled]):not([aria-hidden])', - 'iframe', - 'object', - 'embed', - '[contenteditable]', - '[tabindex]:not([tabindex^="-"])', -]; - -store( { - effects: { - core: { - navigation: { - initMenu: ( { context, ref } ) => { - if ( context.core.navigation.isMenuOpen ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.navigation.modal = ref; - context.core.navigation.firstFocusableElement = - focusableElements[ 0 ]; - context.core.navigation.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement: ( { context, ref } ) => { - if ( context.core.navigation.isMenuOpen ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, - }, - }, - selectors: { - core: { - navigation: { - roleAttribute: ( { context } ) => { - return context.core.navigation.overlay && - context.core.navigation.isMenuOpen - ? 'dialog' - : ''; - }, - }, - }, - }, - actions: { - core: { - navigation: { - openMenu: ( { context, ref } ) => { - context.core.navigation.isMenuOpen = true; - context.core.navigation.previousFocus = ref; - if ( context.core.navigation.overlay ) { - // It adds a `has-modal-open` class to the root - document.documentElement.classList.add( - 'has-modal-open' - ); - } - }, - closeMenu: ( { context } ) => { - if ( context.core.navigation.isMenuOpen ) { - context.core.navigation.isMenuOpen = false; - if ( - context.core.navigation.modal.contains( - window.document.activeElement - ) - ) { - context.core.navigation.previousFocus.focus(); - } - context.core.navigation.modal = null; - context.core.navigation.previousFocus = null; - if ( context.core.navigation.overlay ) { - document.documentElement.classList.remove( - 'has-modal-open' - ); - } - } - }, - toggleMenu: ( { context, actions, ref } ) => { - if ( context.core.navigation.isMenuOpen ) { - actions.core.navigation.closeMenu( { context } ); - } else { - actions.core.navigation.openMenu( { context, ref } ); - } - }, - handleMenuKeydown: ( { actions, context, event } ) => { - if ( context.core.navigation.isMenuOpen ) { - // If Escape close the menu - if ( - event?.key === 'Escape' || - event?.keyCode === 27 - ) { - actions.core.navigation.closeMenu( { context } ); - return; - } - - // Trap focus if it is an overlay (main menu) - if ( - context.core.navigation.overlay && - ( event.key === 'Tab' || event.keyCode === 9 ) - ) { - // If shift + tab it change the direction - if ( - event.shiftKey && - window.document.activeElement === - context.core.navigation - .firstFocusableElement - ) { - event.preventDefault(); - context.core.navigation.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.navigation.lastFocusableElement - ) { - event.preventDefault(); - context.core.navigation.firstFocusableElement.focus(); - } - } - } - }, - handleMenuFocusout: ( { actions, context, event } ) => { - if ( context.core.navigation.isMenuOpen ) { - // If focus is outside modal, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outsite the document, `window.document.activeElement` doesn't change - if ( - ! context.core.navigation.modal.contains( - event.relatedTarget - ) && - event.target !== window.document.activeElement - ) { - actions.core.navigation.closeMenu( { context } ); - } - } - }, - }, - }, - }, -} ); diff --git a/packages/block-library/src/navigation/style.scss b/packages/block-library/src/navigation/style.scss index b0c8748075bdd7..180b40b43daca1 100644 --- a/packages/block-library/src/navigation/style.scss +++ b/packages/block-library/src/navigation/style.scss @@ -31,6 +31,7 @@ $navigation-icon-size: 24px; // Menu item container. .wp-block-navigation-item { + background-color: inherit; display: flex; align-items: center; position: relative; @@ -398,15 +399,26 @@ button.wp-block-navigation-item__content { } // Default background and font color. -.wp-block-navigation:not(.has-background) { +.wp-block-navigation:not(.has-background) .wp-block-navigation__submenu-container { + // Set a background color for submenus so that they're not transparent. + // NOTE TO DEVS - if refactoring this code, please double-check that + // submenus have a default background color, this feature has regressed + // several times, so care needs to be taken. + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.15); +} + +// If we do have a background color selected, inherit it from the navigation block +.wp-block-navigation.has-background { .wp-block-navigation__submenu-container { - // Set a background color for submenus so that they're not transparent. - // NOTE TO DEVS - if refactoring this code, please double-check that - // submenus have a default background color, this feature has regressed - // several times, so care needs to be taken. - background-color: #fff; + background-color: inherit; + } +} + +.wp-block-navigation:not(.has-text-color) { + .wp-block-navigation__submenu-container { + // Set a default color for submenu text if none is selected color: #000; - border: 1px solid rgba(0, 0, 0, 0.15); } } @@ -458,7 +470,8 @@ button.wp-block-navigation-item__content { right: 0; bottom: 0; - .wp-block-navigation-link a { + // Low specificity so that themes can override. + & :where(.wp-block-navigation-item a) { color: inherit; } @@ -579,6 +592,7 @@ button.wp-block-navigation-item__content { // Remove background colors for items inside the overlay menu. // Has to be !important to override global styles. .wp-block-navigation-item .wp-block-navigation__submenu-container, + .wp-block-navigation__container, .wp-block-navigation-item, .wp-block-page-list { color: inherit !important; @@ -620,9 +634,14 @@ button.wp-block-navigation-item__content { .wp-block-navigation:not(.has-background) .wp-block-navigation__responsive-container.is-menu-open { background-color: #fff; +} + +.wp-block-navigation:not(.has-text-color) +.wp-block-navigation__responsive-container.is-menu-open { color: #000; } + // Overlay menu toggle button label .wp-block-navigation__toggle_button_label { font-size: 1rem; diff --git a/packages/block-library/src/navigation/test/use-navigation-menu.js b/packages/block-library/src/navigation/test/use-navigation-menu.js index b03776b663eaf8..f82acca835884d 100644 --- a/packages/block-library/src/navigation/test/use-navigation-menu.js +++ b/packages/block-library/src/navigation/test/use-navigation-menu.js @@ -36,16 +36,28 @@ function resolveRecords( registry, menus ) { dispatch.startResolution( 'getEntityRecords', [ 'postType', 'wp_navigation', - { per_page: -1, status: [ 'publish', 'draft' ] }, + { + per_page: 100, + status: [ 'publish', 'draft' ], + order: 'desc', + orderby: 'date', + }, ] ); dispatch.finishResolution( 'getEntityRecords', [ 'postType', 'wp_navigation', - { per_page: -1, status: [ 'publish', 'draft' ] }, + { + per_page: 100, + status: [ 'publish', 'draft' ], + order: 'desc', + orderby: 'date', + }, ] ); dispatch.receiveEntityRecords( 'postType', 'wp_navigation', menus, { - per_page: -1, + per_page: 100, status: [ 'publish', 'draft' ], + order: 'desc', + orderby: 'date', } ); } diff --git a/packages/block-library/src/navigation/use-navigation-menu.js b/packages/block-library/src/navigation/use-navigation-menu.js index 607a92cb82f954..02df0ba2831dcc 100644 --- a/packages/block-library/src/navigation/use-navigation-menu.js +++ b/packages/block-library/src/navigation/use-navigation-menu.js @@ -4,82 +4,61 @@ import { store as coreStore, useResourcePermissions, + useEntityRecords, } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { PRELOADED_NAVIGATION_MENUS_QUERY } from './constants'; + export default function useNavigationMenu( ref ) { const permissions = useResourcePermissions( 'navigation', ref ); - return useSelect( + const { + navigationMenu, + isNavigationMenuResolved, + isNavigationMenuMissing, + } = useSelect( ( select ) => { - const { - canCreate, - canUpdate, - canDelete, - isResolving, - hasResolved, - } = permissions; - - const { - navigationMenus, - isResolvingNavigationMenus, - hasResolvedNavigationMenus, - } = selectNavigationMenus( select ); - - const { - navigationMenu, - isNavigationMenuResolved, - isNavigationMenuMissing, - } = selectExistingMenu( select, ref ); - - return { - navigationMenus, - isResolvingNavigationMenus, - hasResolvedNavigationMenus, - - navigationMenu, - isNavigationMenuResolved, - isNavigationMenuMissing, - - canSwitchNavigationMenu: ref - ? navigationMenus?.length > 1 - : navigationMenus?.length > 0, - - canUserCreateNavigationMenu: canCreate, - isResolvingCanUserCreateNavigationMenu: isResolving, - hasResolvedCanUserCreateNavigationMenu: hasResolved, - - canUserUpdateNavigationMenu: canUpdate, - hasResolvedCanUserUpdateNavigationMenu: ref - ? hasResolved - : undefined, - - canUserDeleteNavigationMenu: canDelete, - hasResolvedCanUserDeleteNavigationMenu: ref - ? hasResolved - : undefined, - }; + return selectExistingMenu( select, ref ); }, - [ ref, permissions ] + [ ref ] ); -} -function selectNavigationMenus( select ) { - const { getEntityRecords, hasFinishedResolution, isResolving } = - select( coreStore ); + const { canCreate, canUpdate, canDelete, isResolving, hasResolved } = + permissions; - const args = [ + const { + records: navigationMenus, + isResolving: isResolvingNavigationMenus, + hasResolved: hasResolvedNavigationMenus, + } = useEntityRecords( 'postType', - 'wp_navigation', - { per_page: -1, status: [ 'publish', 'draft' ] }, - ]; + `wp_navigation`, + PRELOADED_NAVIGATION_MENUS_QUERY + ); + + const canSwitchNavigationMenu = ref + ? navigationMenus?.length > 1 + : navigationMenus?.length > 0; + return { - navigationMenus: getEntityRecords( ...args ), - isResolvingNavigationMenus: isResolving( 'getEntityRecords', args ), - hasResolvedNavigationMenus: hasFinishedResolution( - 'getEntityRecords', - args - ), + navigationMenu, + isNavigationMenuResolved, + isNavigationMenuMissing, + navigationMenus, + isResolvingNavigationMenus, + hasResolvedNavigationMenus, + canSwitchNavigationMenu, + canUserCreateNavigationMenu: canCreate, + isResolvingCanUserCreateNavigationMenu: isResolving, + hasResolvedCanUserCreateNavigationMenu: hasResolved, + canUserUpdateNavigationMenu: canUpdate, + hasResolvedCanUserUpdateNavigationMenu: ref ? hasResolved : undefined, + canUserDeleteNavigationMenu: canDelete, + hasResolvedCanUserDeleteNavigationMenu: ref ? hasResolved : undefined, }; } diff --git a/packages/block-library/src/navigation/view-interactivity.js b/packages/block-library/src/navigation/view-interactivity.js new file mode 100644 index 00000000000000..b0d39ef3ca4d57 --- /dev/null +++ b/packages/block-library/src/navigation/view-interactivity.js @@ -0,0 +1,196 @@ +/** + * WordPress dependencies + */ +import { store as wpStore } from '@wordpress/interactivity'; + +const focusableSelectors = [ + 'a[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])', +]; + +const openMenu = ( store, menuOpenedOn ) => { + const { context, ref, selectors } = store; + selectors.core.navigation.menuOpenedBy( store )[ menuOpenedOn ] = true; + context.core.navigation.previousFocus = ref; + if ( context.core.navigation.type === 'overlay' ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); + } +}; + +const closeMenu = ( store, menuClosedOn ) => { + const { context, selectors } = store; + selectors.core.navigation.menuOpenedBy( store )[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! selectors.core.navigation.isMenuOpen( store ) ) { + if ( + context.core.navigation.modal?.contains( + window.document.activeElement + ) + ) { + context.core.navigation.previousFocus.focus(); + } + context.core.navigation.modal = null; + context.core.navigation.previousFocus = null; + if ( context.core.navigation.type === 'overlay' ) { + document.documentElement.classList.remove( 'has-modal-open' ); + } + } +}; + +wpStore( { + effects: { + core: { + navigation: { + initMenu: ( store ) => { + const { context, selectors, ref } = store; + if ( selectors.core.navigation.isMenuOpen( store ) ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + context.core.navigation.modal = ref; + context.core.navigation.firstFocusableElement = + focusableElements[ 0 ]; + context.core.navigation.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + } + }, + focusFirstElement: ( store ) => { + const { selectors, ref } = store; + if ( selectors.core.navigation.isMenuOpen( store ) ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } + }, + }, + }, + }, + selectors: { + core: { + navigation: { + roleAttribute: ( store ) => { + const { context, selectors } = store; + return context.core.navigation.type === 'overlay' && + selectors.core.navigation.isMenuOpen( store ) + ? 'dialog' + : ''; + }, + isMenuOpen: ( { context } ) => + // The menu is opened if either `click`, `hover` or `focus` is true. + Object.values( + context.core.navigation[ + context.core.navigation.type === 'overlay' + ? 'overlayOpenedBy' + : 'submenuOpenedBy' + ] + ).filter( Boolean ).length > 0, + menuOpenedBy: ( { context } ) => + context.core.navigation[ + context.core.navigation.type === 'overlay' + ? 'overlayOpenedBy' + : 'submenuOpenedBy' + ], + }, + }, + }, + actions: { + core: { + navigation: { + openMenuOnHover( store ) { + const { navigation } = store.context.core; + if ( + navigation.type === 'submenu' && + // Only open on hover if the overlay is closed. + Object.values( + navigation.overlayOpenedBy || {} + ).filter( Boolean ).length === 0 + ) + openMenu( store, 'hover' ); + }, + closeMenuOnHover( store ) { + closeMenu( store, 'hover' ); + }, + openMenuOnClick( store ) { + openMenu( store, 'click' ); + }, + closeMenuOnClick( store ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + }, + openMenuOnFocus( store ) { + openMenu( store, 'focus' ); + }, + toggleMenuOnClick: ( store ) => { + const { selectors } = store; + const menuOpenedBy = + selectors.core.navigation.menuOpenedBy( store ); + if ( menuOpenedBy.click || menuOpenedBy.focus ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + } else { + openMenu( store, 'click' ); + } + }, + handleMenuKeydown: ( store ) => { + const { context, selectors, event } = store; + if ( + selectors.core.navigation.menuOpenedBy( store ).click + ) { + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + return; + } + + // Trap focus if it is an overlay (main menu). + if ( + context.core.navigation.type === 'overlay' && + event.key === 'Tab' + ) { + // If shift + tab it change the direction. + if ( + event.shiftKey && + window.document.activeElement === + context.core.navigation + .firstFocusableElement + ) { + event.preventDefault(); + context.core.navigation.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + context.core.navigation.lastFocusableElement + ) { + event.preventDefault(); + context.core.navigation.firstFocusableElement.focus(); + } + } + } + }, + handleMenuFocusout: ( store ) => { + const { context, event } = store; + // If focus is outside modal, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outsite the document, + // `window.document.activeElement` doesn't change. + if ( + ! context.core.navigation.modal?.contains( + event.relatedTarget + ) && + event.target !== window.document.activeElement + ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + } + }, + }, + }, + }, +} ); diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js index 9477d262816d93..62de6e8808bf0b 100644 --- a/packages/block-library/src/navigation/view-modal.js +++ b/packages/block-library/src/navigation/view-modal.js @@ -1,16 +1,22 @@ +/*eslint-env browser*/ /** * External dependencies */ import MicroModal from 'micromodal'; // Responsive navigation toggle. -function navigationToggleModal( modal ) { + +/** + * Toggles responsive navigation. + * + * @param {HTMLDivElement} modal + * @param {boolean} isHidden + */ +function navigationToggleModal( modal, isHidden ) { const dialogContainer = modal.querySelector( `.wp-block-navigation__responsive-dialog` ); - const isHidden = 'true' === modal.getAttribute( 'aria-hidden' ); - modal.classList.toggle( 'has-modal-open', ! isHidden ); dialogContainer.toggleAttribute( 'aria-modal', ! isHidden ); @@ -23,10 +29,15 @@ function navigationToggleModal( modal ) { } // Add a class to indicate the modal is open. - const htmlElement = document.documentElement; - htmlElement.classList.toggle( 'has-modal-open' ); + document.documentElement.classList.toggle( 'has-modal-open' ); } +/** + * Checks whether the provided link is an anchor on the current page. + * + * @param {HTMLAnchorElement} node + * @return {boolean} Is anchor. + */ function isLinkToAnchorOnCurrentPage( node ) { return ( node.hash && @@ -37,42 +48,80 @@ function isLinkToAnchorOnCurrentPage( node ) { ); } -window.addEventListener( 'load', () => { - MicroModal.init( { - onShow: navigationToggleModal, - onClose: navigationToggleModal, - openClass: 'is-menu-open', +/** + * Handles effects after opening the modal. + * + * @param {HTMLDivElement} modal + */ +function onShow( modal ) { + navigationToggleModal( modal, false ); + modal.addEventListener( 'click', handleAnchorLinkClicksInsideModal, { + passive: true, } ); +} - // Close modal automatically on clicking anchor links inside modal. - const navigationLinks = document.querySelectorAll( - '.wp-block-navigation-item__content' - ); +/** + * Handles effects after closing the modal. + * + * @param {HTMLDivElement} modal + */ +function onClose( modal ) { + navigationToggleModal( modal, true ); + modal.removeEventListener( 'click', handleAnchorLinkClicksInsideModal, { + passive: true, + } ); +} - navigationLinks.forEach( function ( link ) { - // Ignore non-anchor links and anchor links which open on a new tab. - if ( - ! isLinkToAnchorOnCurrentPage( link ) || - link.attributes?.target === '_blank' - ) { - return; - } +/** + * Handle clicks to anchor links in modal using event delegation by closing modal automatically + * + * @param {UIEvent} event + */ +function handleAnchorLinkClicksInsideModal( event ) { + const link = event.target.closest( '.wp-block-navigation-item__content' ); + if ( ! ( link instanceof HTMLAnchorElement ) ) { + return; + } - // Find the specific parent modal for this link - // since .close() won't work without an ID if there are - // multiple navigation menus in a post/page. - const modal = link.closest( - '.wp-block-navigation__responsive-container' - ); - const modalId = modal?.getAttribute( 'id' ); + // Ignore non-anchor links and anchor links which open on a new tab. + if ( + ! isLinkToAnchorOnCurrentPage( link ) || + link.attributes?.target === '_blank' + ) { + return; + } - link.addEventListener( 'click', () => { - // check if modal exists and is open before trying to close it - // otherwise Micromodal will toggle the `has-modal-open` class - // on the html tag which prevents scrolling - if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { - MicroModal.close( modalId ); - } - } ); - } ); -} ); + // Find the specific parent modal for this link + // since .close() won't work without an ID if there are + // multiple navigation menus in a post/page. + const modal = link.closest( '.wp-block-navigation__responsive-container' ); + const modalId = modal?.getAttribute( 'id' ); + if ( ! modalId ) { + return; + } + + // check if modal exists and is open before trying to close it + // otherwise Micromodal will toggle the `has-modal-open` class + // on the html tag which prevents scrolling + if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { + MicroModal.close( modalId ); + } +} + +// MicroModal.init() does not support event delegation for the open trigger, so here MicroModal.show() is called manually. +document.addEventListener( + 'click', + ( event ) => { + /** @type {HTMLElement} */ + const target = event.target; + + if ( target.dataset.micromodalTrigger ) { + MicroModal.show( target.dataset.micromodalTrigger, { + onShow, + onClose, + openClass: 'is-menu-open', + } ); + } + }, + { passive: true } +); diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 19805a44ae4ae2..d808d1707d5bfe 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,62 +1,94 @@ +/*eslint-env browser*/ // Open on click functionality. -function closeSubmenus( element ) { - element + +/** + * Keep track of whether a submenu is open to short-circuit delegated event listeners. + * + * @type {boolean} + */ +let hasOpenSubmenu = false; + +/** + * Close submenu items for a navigation item. + * + * @param {HTMLElement} navigationItem - Either a NAV or LI element. + */ +function closeSubmenus( navigationItem ) { + navigationItem .querySelectorAll( '[aria-expanded="true"]' ) .forEach( function ( toggle ) { toggle.setAttribute( 'aria-expanded', 'false' ); } ); + hasOpenSubmenu = false; } -function toggleSubmenuOnClick( event ) { - const buttonToggle = event.target.closest( '[aria-expanded]' ); - const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' ); +/** + * Toggle submenu on click. + * + * @param {HTMLButtonElement} buttonToggle + */ +function toggleSubmenuOnClick( buttonToggle ) { + const isSubmenuOpen = + buttonToggle.getAttribute( 'aria-expanded' ) === 'true'; + const navigationItem = buttonToggle.closest( '.wp-block-navigation-item' ); - if ( isSubmenuOpen === 'true' ) { - closeSubmenus( buttonToggle.closest( '.wp-block-navigation-item' ) ); + if ( isSubmenuOpen ) { + closeSubmenus( navigationItem ); } else { // Close all sibling submenus. - const parentElement = buttonToggle.closest( - '.wp-block-navigation-item' - ); const navigationParent = buttonToggle.closest( '.wp-block-navigation__submenu-container, .wp-block-navigation__container, .wp-block-page-list' ); navigationParent .querySelectorAll( '.wp-block-navigation-item' ) - .forEach( function ( child ) { - if ( child !== parentElement ) { + .forEach( ( child ) => { + if ( child !== navigationItem ) { closeSubmenus( child ); } } ); + // Open submenu. buttonToggle.setAttribute( 'aria-expanded', 'true' ); + hasOpenSubmenu = true; } } -// Necessary for some themes such as TT1 Blocks, where -// scripts could be loaded before the body. -window.addEventListener( 'load', () => { - const submenuButtons = document.querySelectorAll( - '.wp-block-navigation-submenu__toggle' - ); +// Open on button click or close on click outside. +document.addEventListener( + 'click', + function ( event ) { + const target = event.target; + const button = target.closest( '.wp-block-navigation-submenu__toggle' ); - submenuButtons.forEach( function ( button ) { - button.addEventListener( 'click', toggleSubmenuOnClick ); - } ); + // Close any other open submenus. + if ( hasOpenSubmenu ) { + const navigationBlocks = document.querySelectorAll( + '.wp-block-navigation' + ); + navigationBlocks.forEach( function ( block ) { + if ( ! block.contains( target ) ) { + closeSubmenus( block ); + } + } ); + } + + // Now open the submenu if one was clicked. + if ( button instanceof HTMLButtonElement ) { + toggleSubmenuOnClick( button ); + } + }, + { passive: true } +); + +// Close on focus outside or escape key. +document.addEventListener( + 'keyup', + function ( event ) { + // Abort if there aren't any submenus open anyway. + if ( ! hasOpenSubmenu ) { + return; + } - // Close on click outside. - document.addEventListener( 'click', function ( event ) { - const navigationBlocks = document.querySelectorAll( - '.wp-block-navigation' - ); - navigationBlocks.forEach( function ( block ) { - if ( ! block.contains( event.target ) ) { - closeSubmenus( block ); - } - } ); - } ); - // Close on focus outside or escape key. - document.addEventListener( 'keyup', function ( event ) { const submenuBlocks = document.querySelectorAll( '.wp-block-navigation-item.has-child' ); @@ -70,5 +102,6 @@ window.addEventListener( 'load', () => { toggle?.focus(); } } ); - } ); -} ); + }, + { passive: true } +); diff --git a/packages/block-library/src/nextpage/block.json b/packages/block-library/src/nextpage/block.json index 6a133264d6747c..ab88d4a7be4f0b 100644 --- a/packages/block-library/src/nextpage/block.json +++ b/packages/block-library/src/nextpage/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/nextpage", "title": "Page Break", "category": "design", diff --git a/packages/block-library/src/nextpage/edit.native.js b/packages/block-library/src/nextpage/edit.native.js index 8570b942fd60a6..508ca7e10553a3 100644 --- a/packages/block-library/src/nextpage/edit.native.js +++ b/packages/block-library/src/nextpage/edit.native.js @@ -2,13 +2,13 @@ * External dependencies */ import { View } from 'react-native'; -import Hr from 'react-native-hr'; /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; import { withPreferredColorScheme } from '@wordpress/compose'; +import { HorizontalRule } from '@wordpress/components'; /** * Internal dependencies @@ -44,7 +44,7 @@ export function NextPageEdit( { accessibilityStates={ accessibilityState } onAccessibilityTap={ onFocus } > -
      - { __( 'Edit' ) } + { __( 'Detach' ) }
      diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js index 417ebed5b5e242..e4c28a22111c61 100644 --- a/packages/block-library/src/page-list/edit.js +++ b/packages/block-library/src/page-list/edit.js @@ -169,12 +169,6 @@ export default function PageListEdit( { }, new Map() ); }, [ pages ] ); - const convertToNavigationLinks = useConvertToNavigationLinks( { - clientId, - pages, - parentPageID, - } ); - const blockProps = useBlockProps( { className: classnames( 'wp-block-page-list', { 'has-text-color': !! context.textColor, @@ -189,68 +183,71 @@ export default function PageListEdit( { style: { ...context.style?.color }, } ); - const getBlockList = ( parentId = parentPageID ) => { - const childPages = pagesByParentId.get( parentId ); + const pagesTree = useMemo( + function makePagesTree( parentId = 0, level = 0 ) { + const childPages = pagesByParentId.get( parentId ); - if ( ! childPages?.length ) { - return []; - } - - return childPages.reduce( ( template, page ) => { - const hasChildren = pagesByParentId.has( page.id ); - const pageProps = { - id: page.id, - label: - // translators: displayed when a page has an empty title. - page.title?.rendered?.trim() !== '' - ? page.title?.rendered - : __( '(no title)' ), - title: page.title?.rendered, - link: page.url, - hasChildren, - }; - let item = null; - const children = getBlockList( page.id ); - item = createBlock( 'core/page-list-item', pageProps, children ); - template.push( item ); - - return template; - }, [] ); - }; + if ( ! childPages?.length ) { + return []; + } - const makePagesTree = ( parentId = 0, level = 0 ) => { - const childPages = pagesByParentId.get( parentId ); + return childPages.reduce( ( tree, page ) => { + const hasChildren = pagesByParentId.has( page.id ); + const item = { + value: page.id, + label: '— '.repeat( level ) + page.title.rendered, + rawName: page.title.rendered, + }; + tree.push( item ); + if ( hasChildren ) { + tree.push( ...makePagesTree( page.id, level + 1 ) ); + } + return tree; + }, [] ); + }, + [ pagesByParentId ] + ); - if ( ! childPages?.length ) { - return []; - } + const blockList = useMemo( + function getBlockList( parentId = parentPageID ) { + const childPages = pagesByParentId.get( parentId ); - return childPages.reduce( ( tree, page ) => { - const hasChildren = pagesByParentId.has( page.id ); - const item = { - value: page.id, - label: '— '.repeat( level ) + page.title.rendered, - rawName: page.title.rendered, - }; - tree.push( item ); - if ( hasChildren ) { - tree.push( ...makePagesTree( page.id, level + 1 ) ); + if ( ! childPages?.length ) { + return []; } - return tree; - }, [] ); - }; - const pagesTree = useMemo( makePagesTree, [ pagesByParentId ] ); - - const blockList = useMemo( getBlockList, [ - pagesByParentId, - parentPageID, - ] ); + return childPages.reduce( ( template, page ) => { + const hasChildren = pagesByParentId.has( page.id ); + const pageProps = { + id: page.id, + label: + // translators: displayed when a page has an empty title. + page.title?.rendered?.trim() !== '' + ? page.title?.rendered + : __( '(no title)' ), + title: page.title?.rendered, + link: page.url, + hasChildren, + }; + let item = null; + const children = getBlockList( page.id ); + item = createBlock( + 'core/page-list-item', + pageProps, + children + ); + template.push( item ); + + return template; + }, [] ); + }, + [ pagesByParentId, parentPageID ] + ); const { isNested, hasSelectedChild, - parentBlock, + parentClientId, hasDraggedChild, isChildOfNavigation, } = useSelect( @@ -258,7 +255,6 @@ export default function PageListEdit( { const { getBlockParentsByBlockName, hasSelectedInnerBlock, - getBlockRootClientId, hasDraggedInnerBlock, } = select( blockEditorStore ); const blockParents = getBlockParentsByBlockName( @@ -276,12 +272,19 @@ export default function PageListEdit( { isChildOfNavigation: navigationBlockParents.length > 0, hasSelectedChild: hasSelectedInnerBlock( clientId, true ), hasDraggedChild: hasDraggedInnerBlock( clientId, true ), - parentBlock: getBlockRootClientId( clientId ), + parentClientId: navigationBlockParents[ 0 ], }; }, [ clientId ] ); + const convertToNavigationLinks = useConvertToNavigationLinks( { + clientId, + pages, + parentClientId, + parentPageID, + } ); + const innerBlocksProps = useInnerBlocksProps( blockProps, { allowedBlocks: [ 'core/page-list-item' ], renderAppender: false, @@ -297,12 +300,12 @@ export default function PageListEdit( { useEffect( () => { if ( hasSelectedChild || hasDraggedChild ) { openModal(); - selectBlock( parentBlock ); + selectBlock( parentClientId ); } }, [ hasSelectedChild, hasDraggedChild, - parentBlock, + parentClientId, selectBlock, openModal, ] ); diff --git a/packages/block-library/src/page-list/use-convert-to-navigation-links.js b/packages/block-library/src/page-list/use-convert-to-navigation-links.js index 5c62342fe77cd7..4cbc69d6e6de66 100644 --- a/packages/block-library/src/page-list/use-convert-to-navigation-links.js +++ b/packages/block-library/src/page-list/use-convert-to-navigation-links.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { createBlock } from '@wordpress/blocks'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useDispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; /** @@ -116,28 +116,11 @@ export function convertToNavigationLinks( pages = [], parentPageID = null ) { export function useConvertToNavigationLinks( { clientId, pages, + parentClientId, parentPageID, } ) { const { replaceBlock, selectBlock } = useDispatch( blockEditorStore ); - const { parentNavBlockClientId } = useSelect( - ( select ) => { - const { getSelectedBlockClientId, getBlockParentsByBlockName } = - select( blockEditorStore ); - - const _selectedBlockClientId = getSelectedBlockClientId(); - - return { - parentNavBlockClientId: getBlockParentsByBlockName( - _selectedBlockClientId, - 'core/navigation', - true - )[ 0 ], - }; - }, - [ clientId ] - ); - return () => { const navigationLinks = convertToNavigationLinks( pages, parentPageID ); @@ -145,6 +128,6 @@ export function useConvertToNavigationLinks( { replaceBlock( clientId, navigationLinks ); // Select the Navigation block to reveal the changes. - selectBlock( parentNavBlockClientId ); + selectBlock( parentClientId ); }; } diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index c5c4a71bf79252..85f56f4a838f50 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -1,12 +1,13 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/paragraph", "title": "Paragraph", "category": "text", "description": "Start with the basic building block of all narrative.", "keywords": [ "text" ], "textdomain": "default", + "usesContext": [ "postId" ], "attributes": { "align": { "type": "string" @@ -41,6 +42,7 @@ "text": true } }, + "__experimentalConnections": true, "spacing": { "margin": true, "padding": true, @@ -58,6 +60,7 @@ "__experimentalFontWeight": true, "__experimentalLetterSpacing": true, "__experimentalTextTransform": true, + "__experimentalWritingMode": true, "__experimentalDefaultControls": { "fontSize": true } diff --git a/packages/block-library/src/paragraph/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/paragraph/test/__snapshots__/edit.native.js.snap new file mode 100644 index 00000000000000..adc6ab4210efa5 --- /dev/null +++ b/packages/block-library/src/paragraph/test/__snapshots__/edit.native.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Paragraph block should render without crashing and match snapshot 1`] = ` + + + +`; diff --git a/packages/block-library/src/paragraph/test/edit.native.js b/packages/block-library/src/paragraph/test/edit.native.js index d3ff59c0e42c29..8220ad0888c795 100644 --- a/packages/block-library/src/paragraph/test/edit.native.js +++ b/packages/block-library/src/paragraph/test/edit.native.js @@ -13,6 +13,8 @@ import { setupCoreBlocks, waitFor, within, + withFakeTimers, + waitForElementToBeRemoved, } from 'test/helpers'; import Clipboard from '@react-native-clipboard/clipboard'; @@ -26,6 +28,19 @@ import { ENTER } from '@wordpress/keycodes'; */ import Paragraph from '../edit'; +// Mock debounce to prevent potentially belated state updates. +jest.mock( '@wordpress/compose/src/utils/debounce', () => ( { + debounce: ( fn ) => { + fn.cancel = jest.fn(); + return fn; + }, +} ) ); +// Mock link suggestions that are fetched by the link picker +// when typing a search query. +jest.mock( '@wordpress/core-data/src/fetch', () => ( { + __experimentalFetchLinkSuggestions: jest.fn().mockResolvedValue( [ {} ] ), +} ) ); + setupCoreBlocks(); const getTestComponentWithContent = ( content ) => { @@ -40,9 +55,9 @@ const getTestComponentWithContent = ( content ) => { }; describe( 'Paragraph block', () => { - it( 'renders without crashing', () => { + it( 'should render without crashing and match snapshot', () => { const screen = getTestComponentWithContent( '' ); - expect( screen.container ).toBeTruthy(); + expect( screen.toJSON() ).toMatchSnapshot(); } ); it( 'should bold text', async () => { @@ -238,26 +253,27 @@ describe( 'Paragraph block', () => { // Act const paragraphBlock = getBlock( screen, 'Paragraph' ); fireEvent.press( paragraphBlock ); - // Await React Navigation: https://github.com/WordPress/gutenberg/issues/35685#issuecomment-961919931 - await act( () => fireEvent.press( screen.getByLabelText( 'Link' ) ) ); - // Await React Navigation: https://github.com/WordPress/gutenberg/issues/35685#issuecomment-961919931 - await act( () => - fireEvent.press( - screen.getByLabelText( 'Link to, Search or type URL' ) - ) - ); - fireEvent.changeText( - screen.getByPlaceholderText( 'Search or type URL' ), - 'wordpress.org' - ); + fireEvent.press( screen.getByLabelText( 'Link' ) ); + fireEvent.changeText( screen.getByPlaceholderText( 'Add link text' ), 'WordPress' ); - jest.useFakeTimers(); - fireEvent.press( screen.getByLabelText( 'Apply' ) ); - // Await link picker navigation delay - act( () => jest.runOnlyPendingTimers() ); + fireEvent.press( + screen.getByLabelText( 'Link to, Search or type URL' ) + ); + const typeURLInput = await waitFor( () => + screen.getByPlaceholderText( 'Search or type URL' ) + ); + fireEvent.changeText( typeURLInput, 'wordpress.org' ); + await waitForElementToBeRemoved( () => + screen.getByTestId( 'link-picker-loading' ) + ); + // Back navigation from link picker uses `setTimeout` + await withFakeTimers( () => { + fireEvent.press( screen.getByLabelText( 'Apply' ) ); + act( () => jest.runOnlyPendingTimers() ); + } ); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -265,8 +281,6 @@ describe( 'Paragraph block', () => {

      WordPress

      " ` ); - - jest.useRealTimers(); } ); it( 'should link text with selection', async () => { @@ -287,22 +301,22 @@ describe( 'Paragraph block', () => { finalSelectionEnd: 7, } ); - // Await React Navigation: https://github.com/WordPress/gutenberg/issues/35685#issuecomment-961919931 - await act( () => fireEvent.press( screen.getByLabelText( 'Link' ) ) ); - // Await React Navigation: https://github.com/WordPress/gutenberg/issues/35685#issuecomment-961919931 - await act( () => - fireEvent.press( - screen.getByLabelText( 'Link to, Search or type URL' ) - ) + fireEvent.press( screen.getByLabelText( 'Link' ) ); + fireEvent.press( + screen.getByLabelText( 'Link to, Search or type URL' ) ); - fireEvent.changeText( - screen.getByPlaceholderText( 'Search or type URL' ), - 'wordpress.org' + const typeURLInput = await waitFor( () => + screen.getByPlaceholderText( 'Search or type URL' ) + ); + fireEvent.changeText( typeURLInput, 'wordpress.org' ); + await waitForElementToBeRemoved( () => + screen.getByTestId( 'link-picker-loading' ) ); - jest.useFakeTimers(); - fireEvent.press( screen.getByLabelText( 'Apply' ) ); - // Await link picker navigation delay - act( () => jest.runOnlyPendingTimers() ); + // Back navigation from link picker uses `setTimeout` + await withFakeTimers( () => { + fireEvent.press( screen.getByLabelText( 'Apply' ) ); + act( () => jest.runOnlyPendingTimers() ); + } ); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -310,8 +324,6 @@ describe( 'Paragraph block', () => {

      A quick brown fox jumps over the lazy dog.

      " ` ); - - jest.useRealTimers(); } ); it( 'should link text with clipboard contents', async () => { @@ -402,6 +414,10 @@ describe( 'Paragraph block', () => { // Tap one color fireEvent.press( screen.getByLabelText( 'Pale pink' ) ); + // TODO(jest-console): Fix the warning and remove the expect below. + expect( console ).toHaveWarnedWith( + `Non-serializable values were found in the navigation state. Check:\n\nColor > params.onColorChange (Function)\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.` + ); // Dismiss the Block Settings modal. fireEvent( blockSettingsModal, 'backdropPress' ); @@ -639,4 +655,34 @@ describe( 'Paragraph block', () => { ); expect( contrastCheckElement ).toBeDefined(); } ); + + it( 'should highlight text with selection', async () => { + // Arrange + const screen = await initializeEditor( { withGlobalStyles: true } ); + await addBlock( screen, 'Paragraph' ); + + // Act + const paragraphBlock = getBlock( screen, 'Paragraph' ); + fireEvent.press( paragraphBlock ); + const paragraphTextInput = + within( paragraphBlock ).getByPlaceholderText( 'Start writing…' ); + typeInRichText( + paragraphTextInput, + 'A quick brown fox jumps over the lazy dog.', + { finalSelectionStart: 2, finalSelectionEnd: 7 } + ); + fireEvent.press( screen.getByLabelText( 'Text color' ) ); + fireEvent.press( await screen.findByLabelText( 'Tertiary' ) ); + // TODO(jest-console): Fix the warning and remove the expect below. + expect( console ).toHaveWarnedWith( + `Non-serializable values were found in the navigation state. Check:\n\ntext-color > Palette > params.onColorChange (Function)\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.` + ); + + // Assert + expect( getEditorHtml() ).toMatchInlineSnapshot( ` + " +

      A quick brown fox jumps over the lazy dog.

      + " + ` ); + } ); } ); diff --git a/packages/block-library/src/paragraph/use-enter.js b/packages/block-library/src/paragraph/use-enter.js index 22bef120ef17c9..04c0ba957af91a 100644 --- a/packages/block-library/src/paragraph/use-enter.js +++ b/packages/block-library/src/paragraph/use-enter.js @@ -6,7 +6,11 @@ import { useRefEffect } from '@wordpress/compose'; import { ENTER } from '@wordpress/keycodes'; import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; -import { hasBlockSupport, createBlock } from '@wordpress/blocks'; +import { + hasBlockSupport, + createBlock, + getDefaultBlockName, +} from '@wordpress/blocks'; export function useOnEnter( props ) { const { batch } = useRegistry(); @@ -23,6 +27,7 @@ export function useOnEnter( props ) { getBlockName, getBlock, getNextBlockClientId, + canInsertBlockType, } = useSelect( blockEditorStore ); const propsRef = useRef( props ); propsRef.current = props; @@ -56,22 +61,47 @@ export function useOnEnter( props ) { } const order = getBlockOrder( wrapperClientId ); - - event.preventDefault(); - const position = order.indexOf( clientId ); // If it is the last block, exit. if ( position === order.length - 1 ) { - moveBlocksToPosition( - [ clientId ], - wrapperClientId, - getBlockRootClientId( wrapperClientId ), - getBlockIndex( wrapperClientId ) + 1 - ); + let newWrapperClientId = wrapperClientId; + + while ( + ! canInsertBlockType( + getBlockName( clientId ), + getBlockRootClientId( newWrapperClientId ) + ) + ) { + newWrapperClientId = + getBlockRootClientId( newWrapperClientId ); + } + + if ( typeof newWrapperClientId === 'string' ) { + event.preventDefault(); + moveBlocksToPosition( + [ clientId ], + wrapperClientId, + getBlockRootClientId( newWrapperClientId ), + getBlockIndex( newWrapperClientId ) + 1 + ); + } return; } + const defaultBlockName = getDefaultBlockName(); + + if ( + ! canInsertBlockType( + defaultBlockName, + getBlockRootClientId( wrapperClientId ) + ) + ) { + return; + } + + event.preventDefault(); + // If it is in the middle, split the block in two. const wrapperBlock = getBlock( wrapperClientId ); batch( () => { @@ -87,7 +117,7 @@ export function useOnEnter( props ) { wrapperBlock.innerBlocks.slice( position + 1 ) ); insertBlock( - createBlock( 'core/paragraph' ), + createBlock( defaultBlockName ), blockIndex + 1, getBlockRootClientId( wrapperClientId ), true diff --git a/packages/block-library/src/pattern/block.json b/packages/block-library/src/pattern/block.json index 82372fe1680984..e9a85a9b2f84f1 100644 --- a/packages/block-library/src/pattern/block.json +++ b/packages/block-library/src/pattern/block.json @@ -1,8 +1,8 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/pattern", - "title": "Pattern", + "title": "Pattern placeholder", "category": "theme", "description": "Show a block pattern.", "supports": { @@ -13,10 +13,6 @@ "attributes": { "slug": { "type": "string" - }, - "syncStatus": { - "type": [ "string", "boolean" ], - "enum": [ "full", "partial" ] } } } diff --git a/packages/block-library/src/pattern/edit.js b/packages/block-library/src/pattern/edit.js index c22536a59eb03f..d9f0c2c53cebcd 100644 --- a/packages/block-library/src/pattern/edit.js +++ b/packages/block-library/src/pattern/edit.js @@ -7,77 +7,99 @@ import { useEffect } from '@wordpress/element'; import { store as blockEditorStore, useBlockProps, - useInnerBlocksProps, } from '@wordpress/block-editor'; +import { store as coreStore } from '@wordpress/core-data'; const PatternEdit = ( { attributes, clientId } ) => { - const { slug, syncStatus } = attributes; - const { selectedPattern, innerBlocks } = useSelect( - ( select ) => { - return { - selectedPattern: - select( blockEditorStore ).__experimentalGetParsedPattern( - slug - ), - innerBlocks: - select( blockEditorStore ).getBlock( clientId ) - ?.innerBlocks, - }; - }, - [ slug, clientId ] + const selectedPattern = useSelect( + ( select ) => + select( blockEditorStore ).__experimentalGetParsedPattern( + attributes.slug + ), + [ attributes.slug ] ); - const { - replaceBlocks, - replaceInnerBlocks, - __unstableMarkNextChangeAsNotPersistent, - } = useDispatch( blockEditorStore ); + + const currentThemeStylesheet = useSelect( + ( select ) => select( coreStore ).getCurrentTheme().stylesheet + ); + + const { replaceBlocks, __unstableMarkNextChangeAsNotPersistent } = + useDispatch( blockEditorStore ); + const { setBlockEditingMode } = useDispatch( blockEditorStore ); + const { getBlockRootClientId, getBlockEditingMode } = + useSelect( blockEditorStore ); + + function injectThemeAttributeInBlockTemplateContent( block ) { + if ( + block.innerBlocks.find( + ( innerBlock ) => innerBlock.name === 'core/template-part' + ) + ) { + block.innerBlocks = block.innerBlocks.map( ( innerBlock ) => { + if ( + innerBlock.name === 'core/template-part' && + innerBlock.attributes.theme === undefined + ) { + innerBlock.attributes.theme = currentThemeStylesheet; + } + return innerBlock; + } ); + } + + if ( + block.name === 'core/template-part' && + block.attributes.theme === undefined + ) { + block.attributes.theme = currentThemeStylesheet; + } + return block; + } // Run this effect when the component loads. // This adds the Pattern's contents to the post. + // This change won't be saved. + // It will continue to pull from the pattern file unless changes are made to its respective template part. useEffect( () => { - if ( selectedPattern?.blocks && ! innerBlocks?.length ) { + if ( selectedPattern?.blocks ) { // We batch updates to block list settings to avoid triggering cascading renders // for each container block included in a tree and optimize initial render. // Since the above uses microtasks, we need to use a microtask here as well, // because nested pattern blocks cannot be inserted if the parent block supports // inner blocks but doesn't have blockSettings in the state. window.queueMicrotask( () => { + const rootClientId = getBlockRootClientId( clientId ); // Clone blocks from the pattern before insertion to ensure they receive // distinct client ids. See https://github.com/WordPress/gutenberg/issues/50628. const clonedBlocks = selectedPattern.blocks.map( ( block ) => - cloneBlock( block ) + cloneBlock( + injectThemeAttributeInBlockTemplateContent( block ) + ) ); + const rootEditingMode = getBlockEditingMode( rootClientId ); + // Temporarily set the root block to default mode to allow replacing the pattern. + // This could happen when the page is disabling edits of non-content blocks. + __unstableMarkNextChangeAsNotPersistent(); + setBlockEditingMode( rootClientId, 'default' ); __unstableMarkNextChangeAsNotPersistent(); - if ( syncStatus === 'partial' ) { - replaceInnerBlocks( clientId, clonedBlocks ); - return; - } replaceBlocks( clientId, clonedBlocks ); + // Restore the root block's original mode. + __unstableMarkNextChangeAsNotPersistent(); + setBlockEditingMode( rootClientId, rootEditingMode ); } ); } }, [ clientId, selectedPattern?.blocks, - replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, - innerBlocks, - syncStatus, replaceBlocks, + getBlockEditingMode, + setBlockEditingMode, + getBlockRootClientId, ] ); - const blockProps = useBlockProps( { - className: slug?.replace( '/', '-' ), - } ); - - const innerBlocksProps = useInnerBlocksProps( blockProps, { - templateLock: syncStatus === 'partial' ? 'contentOnly' : false, - } ); - - if ( syncStatus !== 'partial' ) { - return
      ; - } + const props = useBlockProps(); - return
      ; + return
      ; }; export default PatternEdit; diff --git a/packages/block-library/src/pattern/index.js b/packages/block-library/src/pattern/index.js index 27e74510eb5972..e4af712da8bb29 100644 --- a/packages/block-library/src/pattern/index.js +++ b/packages/block-library/src/pattern/index.js @@ -3,14 +3,13 @@ */ import initBlock from '../utils/init-block'; import metadata from './block.json'; -import PatternEditV1 from './v1/edit'; -import PatternEditV2 from './edit'; +import PatternEdit from './edit'; const { name } = metadata; export { metadata, name }; -export const settings = window?.__experimentalEnablePatternEnhancements - ? { edit: PatternEditV2 } - : { edit: PatternEditV1 }; +export const settings = { + edit: PatternEdit, +}; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/pattern/index.php b/packages/block-library/src/pattern/index.php index cb3be0370a4f6f..97a8c3ddc663f8 100644 --- a/packages/block-library/src/pattern/index.php +++ b/packages/block-library/src/pattern/index.php @@ -22,6 +22,8 @@ function register_block_core_pattern() { /** * Renders the `core/pattern` block on the server. * + * @since 6.3.0 Backwards compatibility: blocks with no `syncStatus` attribute do not receive block wrapper. + * * @param array $attributes Block attributes. * * @return string Returns the output of the pattern. @@ -39,19 +41,17 @@ function render_block_core_pattern( $attributes ) { } $pattern = $registry->get_registered( $slug ); - - // Currently all existing blocks should be returned here without a wp-block-pattern wrapper - // as the syncStatus attribute is only used if the gutenberg-pattern-enhancements experiment - // is enabled. - if ( ! isset( $attributes['syncStatus'] ) ) { - return do_blocks( $pattern['content'] ); + $content = _inject_theme_attribute_in_block_template_content( $pattern['content'] ); + + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + if ( $gutenberg_experiments && ! empty( $gutenberg_experiments['gutenberg-auto-inserting-blocks'] ) ) { + // TODO: In the long run, we'd likely want to have a filter in the `WP_Block_Patterns_Registry` class + // instead to allow us plugging in code like this. + $blocks = parse_blocks( $content ); + $content = gutenberg_serialize_blocks( $blocks ); } - $block_classnames = 'wp-block-pattern ' . str_replace( '/', '-', $attributes['slug'] ); - $classnames = isset( $attributes['className'] ) ? $attributes['className'] . ' ' . $block_classnames : $block_classnames; - $wrapper = '
      %s
      '; - - return sprintf( $wrapper, do_blocks( $pattern['content'] ) ); + return do_blocks( $content ); } add_action( 'init', 'register_block_core_pattern' ); diff --git a/packages/block-library/src/pattern/v1/edit.js b/packages/block-library/src/pattern/v1/edit.js deleted file mode 100644 index b4900536ec274f..00000000000000 --- a/packages/block-library/src/pattern/v1/edit.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * WordPress dependencies - */ -import { cloneBlock } from '@wordpress/blocks'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; -import { - store as blockEditorStore, - useBlockProps, -} from '@wordpress/block-editor'; - -const PatternEdit = ( { attributes, clientId } ) => { - const selectedPattern = useSelect( - ( select ) => - select( blockEditorStore ).__experimentalGetParsedPattern( - attributes.slug - ), - [ attributes.slug ] - ); - - const { replaceBlocks, __unstableMarkNextChangeAsNotPersistent } = - useDispatch( blockEditorStore ); - - // Run this effect when the component loads. - // This adds the Pattern's contents to the post. - // This change won't be saved. - // It will continue to pull from the pattern file unless changes are made to its respective template part. - useEffect( () => { - if ( selectedPattern?.blocks ) { - // We batch updates to block list settings to avoid triggering cascading renders - // for each container block included in a tree and optimize initial render. - // Since the above uses microtasks, we need to use a microtask here as well, - // because nested pattern blocks cannot be inserted if the parent block supports - // inner blocks but doesn't have blockSettings in the state. - window.queueMicrotask( () => { - // Clone blocks from the pattern before insertion to ensure they receive - // distinct client ids. See https://github.com/WordPress/gutenberg/issues/50628. - const clonedBlocks = selectedPattern.blocks.map( ( block ) => - cloneBlock( block ) - ); - __unstableMarkNextChangeAsNotPersistent(); - replaceBlocks( clientId, clonedBlocks ); - } ); - } - }, [ - clientId, - selectedPattern?.blocks, - __unstableMarkNextChangeAsNotPersistent, - replaceBlocks, - ] ); - - const props = useBlockProps(); - - return
      ; -}; - -export default PatternEdit; diff --git a/packages/block-library/src/post-author-biography/block.json b/packages/block-library/src/post-author-biography/block.json index a2e5f327acfeb7..5d7a4d4585747d 100644 --- a/packages/block-library/src/post-author-biography/block.json +++ b/packages/block-library/src/post-author-biography/block.json @@ -1,8 +1,8 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-author-biography", - "title": "Post Author Biography", + "title": "Author Biography", "category": "theme", "description": "The author biography.", "textdomain": "default", @@ -13,7 +13,6 @@ }, "usesContext": [ "postType", "postId" ], "supports": { - "anchor": true, "spacing": { "margin": true, "padding": true diff --git a/packages/block-library/src/post-author-name/block.json b/packages/block-library/src/post-author-name/block.json index 2340636e0c63a4..89e4b38de2c281 100644 --- a/packages/block-library/src/post-author-name/block.json +++ b/packages/block-library/src/post-author-name/block.json @@ -1,8 +1,8 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-author-name", - "title": "Post Author Name", + "title": "Author Name", "category": "theme", "description": "The author name.", "textdomain": "default", @@ -21,7 +21,6 @@ }, "usesContext": [ "postType", "postId" ], "supports": { - "anchor": true, "html": false, "spacing": { "margin": true, diff --git a/packages/block-library/src/post-author/block.json b/packages/block-library/src/post-author/block.json index 4a8e0433868e53..47dceef55604f6 100644 --- a/packages/block-library/src/post-author/block.json +++ b/packages/block-library/src/post-author/block.json @@ -1,8 +1,8 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-author", - "title": "Post Author", + "title": "Author", "category": "theme", "description": "Display post author details such as name, avatar, and bio.", "textdomain": "default", @@ -35,7 +35,6 @@ }, "usesContext": [ "postType", "postId", "queryId" ], "supports": { - "anchor": true, "html": false, "spacing": { "margin": true, diff --git a/packages/block-library/src/post-author/edit.js b/packages/block-library/src/post-author/edit.js index a9b7106f49d388..4ee353fdd9bdc0 100644 --- a/packages/block-library/src/post-author/edit.js +++ b/packages/block-library/src/post-author/edit.js @@ -93,14 +93,14 @@ function PostAuthorEdit( { }; const showCombobox = authorOptions.length >= minimumUsersForCombobox; + const showAuthorControl = + !! postId && ! isDescendentOfQueryLoop && authorOptions.length > 0; return ( <> - { !! postId && - ! isDescendentOfQueryLoop && - authorOptions.length && + { showAuthorControl && ( ( showCombobox && ( { + return select( coreStore ).getEntityRecord( + 'postType', + postType, + postId + ); + }, + [ postType, postId ] + ); + + const hasInnerBlocks = !! entityRecord?.content?.raw || blocks?.length; + + const initialInnerBlocks = [ [ 'core/paragraph' ] ]; + const props = useInnerBlocksProps( useBlockProps( { className: 'entry-content' } ), { value: blocks, onInput, onChange, + template: ! hasInnerBlocks ? initialInnerBlocks : undefined, } ); return
      ; @@ -83,7 +103,7 @@ function Placeholder( { layoutClassNames } ) {

      { __( - 'This is the Post Content block, it will display all the blocks in any single post or page.' + 'This is the Content block, it will display all the blocks in any single post or page.' ) }

      @@ -93,7 +113,7 @@ function Placeholder( { layoutClassNames } ) {

      { __( - 'If there are any Custom Post Types registered at your site, the Post Content block can display the contents of those entries as well.' + 'If there are any Custom Post Types registered at your site, the Content block can display the contents of those entries as well.' ) }

      diff --git a/packages/block-library/src/post-content/index.php b/packages/block-library/src/post-content/index.php index 2be1ef77b3b856..dd84574fdea658 100644 --- a/packages/block-library/src/post-content/index.php +++ b/packages/block-library/src/post-content/index.php @@ -35,12 +35,6 @@ function render_block_core_post_content( $attributes, $content, $block ) { $seen_ids[ $post_id ] = true; - // Check is needed for backward compatibility with third-party plugins - // that might rely on the `in_the_loop` check; calling `the_post` sets it to true. - if ( ! in_the_loop() && have_posts() ) { - the_post(); - } - // When inside the main loop, we want to use queried object // so that `the_preview` for the current post can apply. // We force this behavior by omitting the third argument (post ID) from the `get_the_content`. diff --git a/packages/block-library/src/post-date/block.json b/packages/block-library/src/post-date/block.json index 41c45a4a57e26e..11ebc32d9cabec 100644 --- a/packages/block-library/src/post-date/block.json +++ b/packages/block-library/src/post-date/block.json @@ -1,10 +1,10 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-date", - "title": "Post Date", + "title": "Date", "category": "theme", - "description": "Add the date of this post.", + "description": "Display the publish date for an entry such as a post or page.", "textdomain": "default", "attributes": { "textAlign": { @@ -24,7 +24,6 @@ }, "usesContext": [ "postId", "postType", "queryId" ], "supports": { - "anchor": true, "html": false, "color": { "gradients": true, diff --git a/packages/block-library/src/post-date/variations.js b/packages/block-library/src/post-date/variations.js index 0b99b9d5136883..caeed79f93eab4 100644 --- a/packages/block-library/src/post-date/variations.js +++ b/packages/block-library/src/post-date/variations.js @@ -7,7 +7,7 @@ import { postDate } from '@wordpress/icons'; const variations = [ { name: 'post-date-modified', - title: __( 'Post Modified Date' ), + title: __( 'Modified Date' ), description: __( "Display a post's last updated date." ), attributes: { displayType: 'modified' }, scope: [ 'block', 'inserter' ], diff --git a/packages/block-library/src/post-excerpt/block.json b/packages/block-library/src/post-excerpt/block.json index 53a92cb0bda639..33b7818ebed9f2 100644 --- a/packages/block-library/src/post-excerpt/block.json +++ b/packages/block-library/src/post-excerpt/block.json @@ -1,10 +1,10 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-excerpt", - "title": "Post Excerpt", + "title": "Excerpt", "category": "theme", - "description": "Display a post's excerpt.", + "description": "Display the excerpt.", "textdomain": "default", "attributes": { "textAlign": { @@ -24,7 +24,6 @@ }, "usesContext": [ "postId", "postType", "queryId" ], "supports": { - "anchor": true, "html": false, "color": { "gradients": true, diff --git a/packages/block-library/src/post-excerpt/edit.js b/packages/block-library/src/post-excerpt/edit.js index b84abaa4e98d83..c4b53b22833698 100644 --- a/packages/block-library/src/post-excerpt/edit.js +++ b/packages/block-library/src/post-excerpt/edit.js @@ -109,16 +109,7 @@ export default function PostExcerptEditor( { />
      -

      - { __( - 'This is the Post Excerpt block, it will display the excerpt from single posts.' - ) } -

      -

      - { __( - 'If there are any Custom Post Types with support for excerpts, the Post Excerpt block can display the excerpts of those entries as well.' - ) } -

      +

      { __( 'This block will display the excerpt.' ) }

      ); @@ -128,7 +119,7 @@ export default function PostExcerptEditor( {
      { __( - 'There is no excerpt because this is a protected post.' + 'The content is currently protected and does not have the available excerpt.' ) }
      @@ -195,14 +186,14 @@ export default function PostExcerptEditor( { const excerptContent = isEditable ? ( { ! isTrimmed - ? rawOrRenderedExcerpt || __( 'No post excerpt found' ) + ? rawOrRenderedExcerpt || __( 'No excerpt found' ) : trimmedExcerpt + ELLIPSIS }

      ); diff --git a/packages/block-library/src/post-excerpt/index.php b/packages/block-library/src/post-excerpt/index.php index 24f6777b4121de..4ed4edab95078b 100644 --- a/packages/block-library/src/post-excerpt/index.php +++ b/packages/block-library/src/post-excerpt/index.php @@ -31,7 +31,7 @@ function render_block_core_post_excerpt( $attributes, $content, $block ) { } $more_text = ! empty( $attributes['moreText'] ) ? '' . wp_kses_post( $attributes['moreText'] ) . '' : ''; - $filter_excerpt_more = function( $more ) use ( $more_text ) { + $filter_excerpt_more = static function( $more ) use ( $more_text ) { return empty( $more_text ) ? $more : ''; }; /** @@ -87,7 +87,7 @@ function register_block_core_post_excerpt() { defined( 'REST_REQUEST' ) && REST_REQUEST ) { add_filter( 'excerpt_length', - function() { + static function() { return 100; }, PHP_INT_MAX diff --git a/packages/block-library/src/post-featured-image/block.json b/packages/block-library/src/post-featured-image/block.json index c6007785cd82ac..34e3bd6b2325fa 100644 --- a/packages/block-library/src/post-featured-image/block.json +++ b/packages/block-library/src/post-featured-image/block.json @@ -1,8 +1,8 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-featured-image", - "title": "Post Featured Image", + "title": "Featured Image", "category": "theme", "description": "Display a post's featured image.", "textdomain": "default", @@ -56,7 +56,6 @@ "usesContext": [ "postId", "postType", "queryId" ], "supports": { "align": [ "left", "right", "center", "wide", "full" ], - "anchor": true, "color": { "__experimentalDuotone": "img, .wp-block-post-featured-image__placeholder, .components-placeholder__illustration, .components-placeholder::before", "text": false, diff --git a/packages/block-library/src/post-featured-image/index.php b/packages/block-library/src/post-featured-image/index.php index 6cb4110ee000e6..67c889b0befa51 100644 --- a/packages/block-library/src/post-featured-image/index.php +++ b/packages/block-library/src/post-featured-image/index.php @@ -19,12 +19,6 @@ function render_block_core_post_featured_image( $attributes, $content, $block ) } $post_ID = $block->context['postId']; - // Check is needed for backward compatibility with third-party plugins - // that might rely on the `in_the_loop` check; calling `the_post` sets it to true. - if ( ! in_the_loop() && have_posts() ) { - the_post(); - } - $is_link = isset( $attributes['isLink'] ) && $attributes['isLink']; $size_slug = isset( $attributes['sizeSlug'] ) ? $attributes['sizeSlug'] : 'post-thumbnail'; $attr = get_block_core_post_featured_image_border_attributes( $attributes ); diff --git a/packages/block-library/src/post-navigation-link/block.json b/packages/block-library/src/post-navigation-link/block.json index 2bdfa654798ee6..e1b6d4fa90a40c 100644 --- a/packages/block-library/src/post-navigation-link/block.json +++ b/packages/block-library/src/post-navigation-link/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-navigation-link", "title": "Post Navigation Link", "category": "theme", @@ -31,7 +31,6 @@ } }, "supports": { - "anchor": true, "reusable": false, "html": false, "color": { @@ -46,6 +45,7 @@ "__experimentalTextTransform": true, "__experimentalTextDecoration": true, "__experimentalLetterSpacing": true, + "__experimentalWritingMode": true, "__experimentalDefaultControls": { "fontSize": true } diff --git a/packages/block-library/src/post-navigation-link/index.php b/packages/block-library/src/post-navigation-link/index.php index cb1fe568bdf0e7..cb066ad69f2c5d 100644 --- a/packages/block-library/src/post-navigation-link/index.php +++ b/packages/block-library/src/post-navigation-link/index.php @@ -88,9 +88,9 @@ function render_block_core_post_navigation_link( $attributes, $content ) { $arrow = $arrow_map[ $attributes['arrow'] ][ $navigation_type ]; if ( 'next' === $navigation_type ) { - $format = '%link '; + $format = '%link'; } else { - $format = ' %link'; + $format = '%link'; } } diff --git a/packages/block-library/src/post-template/block.json b/packages/block-library/src/post-template/block.json index 6c2056368d6449..48804de75d2cae 100644 --- a/packages/block-library/src/post-template/block.json +++ b/packages/block-library/src/post-template/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-template", "title": "Post Template", "category": "theme", @@ -13,16 +13,14 @@ "queryContext", "displayLayout", "templateSlug", - "previewPostType" + "previewPostType", + "enhancedPagination" ], "supports": { "reusable": false, "html": false, "align": [ "wide", "full" ], - "anchor": true, - "__experimentalLayout": { - "allowEditing": false - }, + "layout": true, "color": { "gradients": true, "link": true, @@ -43,6 +41,14 @@ "__experimentalDefaultControls": { "fontSize": true } + }, + "spacing": { + "blockGap": { + "__experimentalDefault": "1.25em" + }, + "__experimentalDefaultControls": { + "blockGap": true + } } }, "style": "wp-block-post-template", diff --git a/packages/block-library/src/post-template/edit.js b/packages/block-library/src/post-template/edit.js index 1acb3e57191758..f05f81c14082ef 100644 --- a/packages/block-library/src/post-template/edit.js +++ b/packages/block-library/src/post-template/edit.js @@ -10,14 +10,16 @@ import { memo, useMemo, useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { + BlockControls, BlockContextProvider, __experimentalUseBlockPreview as useBlockPreview, useBlockProps, useInnerBlocksProps, store as blockEditorStore, } from '@wordpress/block-editor'; -import { Spinner } from '@wordpress/components'; +import { Spinner, ToolbarGroup } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; +import { list, grid } from '@wordpress/icons'; const TEMPLATE = [ [ 'core/post-title' ], @@ -70,6 +72,7 @@ function PostTemplateBlockPreview( { const MemoizedPostTemplateBlockPreview = memo( PostTemplateBlockPreview ); export default function PostTemplateEdit( { + setAttributes, clientId, context: { query: { @@ -95,11 +98,13 @@ export default function PostTemplateEdit( { } = {}, queryContext = [ { page: 1 } ], templateSlug, - displayLayout: { type: layoutType = 'flex', columns = 1 } = {}, previewPostType, }, + attributes: { layout }, __unstableLayoutClassNames, } ) { + const { type: layoutType, columnCount = 3 } = layout || {}; + const [ { page } ] = queryContext; const [ activeBlockContextId, setActiveBlockContextId ] = useState(); const { posts, blocks } = useSelect( @@ -215,11 +220,11 @@ export default function PostTemplateEdit( { } ) ), [ posts ] ); - const hasLayoutFlex = layoutType === 'flex' && columns > 1; + const blockProps = useBlockProps( { className: classnames( __unstableLayoutClassNames, { - 'is-flex-container': hasLayoutFlex, - [ `columns-${ columns }` ]: hasLayoutFlex, + [ `columns-${ columnCount }` ]: + layoutType === 'grid' && columnCount, // Ensure column count is flagged via classname for backwards compatibility. } ), } ); @@ -235,35 +240,67 @@ export default function PostTemplateEdit( { return

      { __( 'No results found.' ) }

      ; } + const setDisplayLayout = ( newDisplayLayout ) => + setAttributes( { + layout: { ...layout, ...newDisplayLayout }, + } ); + + const displayLayoutControls = [ + { + icon: list, + title: __( 'List view' ), + onClick: () => setDisplayLayout( { type: 'default' } ), + isActive: layoutType === 'default' || layoutType === 'constrained', + }, + { + icon: grid, + title: __( 'Grid view' ), + onClick: () => + setDisplayLayout( { + type: 'grid', + columnCount, + } ), + isActive: layoutType === 'grid', + }, + ]; + // To avoid flicker when switching active block contexts, a preview is rendered // for each block context, but the preview for the active block context is hidden. // This ensures that when it is displayed again, the cached rendering of the // block preview is used, instead of having to re-render the preview from scratch. return ( -
        - { blockContexts && - blockContexts.map( ( blockContext ) => ( - - { blockContext.postId === - ( activeBlockContextId || - blockContexts[ 0 ]?.postId ) ? ( - - ) : null } - - - ) ) } -
      + <> + + + + +
        + { blockContexts && + blockContexts.map( ( blockContext ) => ( + + { blockContext.postId === + ( activeBlockContextId || + blockContexts[ 0 ]?.postId ) ? ( + + ) : null } + + + ) ) } +
      + ); } diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index 3a3c207cf92ee2..e616939514a682 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -34,6 +34,8 @@ function block_core_post_template_uses_featured_image( $inner_blocks ) { /** * Renders the `core/post-template` block on the server. * + * @since 6.3.0 Changed render_block_context priority to `1`. + * * @param array $attributes Block attributes. * @param string $content Block default content. * @param WP_Block $block Block instance. @@ -41,14 +43,26 @@ function block_core_post_template_uses_featured_image( $inner_blocks ) { * @return string Returns the output of the query, structured using the layout defined by the block's inner blocks. */ function render_block_core_post_template( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; // Use global query if needed. $use_global_query = ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ); if ( $use_global_query ) { global $wp_query; - $query = clone $wp_query; + + /* + * If already in the main query loop, duplicate the query instance to not tamper with the main instance. + * Since this is a nested query, it should start at the beginning, therefore rewind posts. + * Otherwise, the main query loop has not started yet and this block is responsible for doing so. + */ + if ( in_the_loop() ) { + $query = clone $wp_query; + $query->rewind_posts(); + } else { + $query = $wp_query; + } } else { $query_args = build_query_vars_from_query_block( $block, $page ); $query = new WP_Query( $query_args ); @@ -72,6 +86,11 @@ function render_block_core_post_template( $attributes, $content, $block ) { $classnames .= ' has-link-color'; } + // Ensure backwards compatibility by flagging the number of columns via classname when using grid layout. + if ( isset( $attributes['layout']['type'] ) && 'grid' === $attributes['layout']['type'] && ! empty( $attributes['layout']['columnCount'] ) ) { + $classnames .= ' ' . sanitize_title( 'columns-' . $attributes['layout']['columnCount'] ); + } + $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => trim( $classnames ) ) ); $content = ''; @@ -85,21 +104,27 @@ function render_block_core_post_template( $attributes, $content, $block ) { // This ensures that for the inner instances of the Post Template block, we do not render any block supports. $block_instance['blockName'] = 'core/null'; + $post_id = get_the_ID(); + $post_type = get_post_type(); + $filter_block_context = static function( $context ) use ( $post_id, $post_type ) { + $context['postType'] = $post_type; + $context['postId'] = $post_id; + return $context; + }; + + // Use an early priority to so that other 'render_block_context' filters have access to the values. + add_filter( 'render_block_context', $filter_block_context, 1 ); // Render the inner blocks of the Post Template block with `dynamic` set to `false` to prevent calling // `render_callback` and ensure that no wrapper markup is included. - $block_content = ( - new WP_Block( - $block_instance, - array( - 'postType' => get_post_type(), - 'postId' => get_the_ID(), - ) - ) - )->render( array( 'dynamic' => false ) ); + $block_content = ( new WP_Block( $block_instance ) )->render( array( 'dynamic' => false ) ); + remove_filter( 'render_block_context', $filter_block_context, 1 ); // Wrap the render inner blocks in a `li` element with the appropriate post classes. $post_classes = implode( ' ', get_post_class( 'wp-block-post' ) ); - $content .= '
    • ' . $block_content . '
    • '; + + $inner_block_directives = $enhanced_pagination ? ' data-wp-key="post-template-item-' . $post_id . '"' : ''; + + $content .= '' . $block_content . ''; } /* diff --git a/packages/block-library/src/post-template/style.scss b/packages/block-library/src/post-template/style.scss index b1cdcf385e223c..00305a17123369 100644 --- a/packages/block-library/src/post-template/style.scss +++ b/packages/block-library/src/post-template/style.scss @@ -9,7 +9,7 @@ &.wp-block-post-template { background: none; } - + // These rules no longer apply but should be kept for backwards compatibility. &.is-flex-container { flex-direction: row; display: flex; @@ -30,3 +30,10 @@ } } } + +@media ( max-width: $break-small ) { + // Temporary specificity bump until "wp-container" layout specificity is revisited. + .wp-block-post-template-is-layout-grid.wp-block-post-template-is-layout-grid.wp-block-post-template-is-layout-grid.wp-block-post-template-is-layout-grid { + grid-template-columns: 1fr; + } +} diff --git a/packages/block-library/src/post-terms/block.json b/packages/block-library/src/post-terms/block.json index 1633c7c01b82ca..0da7fb02f8134f 100644 --- a/packages/block-library/src/post-terms/block.json +++ b/packages/block-library/src/post-terms/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-terms", "title": "Post Terms", "category": "theme", @@ -28,7 +28,6 @@ }, "usesContext": [ "postId", "postType" ], "supports": { - "anchor": true, "html": false, "color": { "gradients": true, diff --git a/packages/block-library/src/post-terms/edit.js b/packages/block-library/src/post-terms/edit.js index d46c738e79ec4e..49e3a4ce801f42 100644 --- a/packages/block-library/src/post-terms/edit.js +++ b/packages/block-library/src/post-terms/edit.js @@ -69,10 +69,6 @@ export default function PostTermsEdit( { } ), } ); - if ( ! hasPost || ! term ) { - return
      { blockInformation.title }
      ; - } - return ( <> @@ -96,7 +92,7 @@ export default function PostTermsEdit( { />
      - { isLoading && } + { isLoading && hasPost && } { ! isLoading && hasPostTerms && ( isSelected || prefix ) && ( ) } - { ! isLoading && + { ( ! hasPost || ! term ) && ( + { blockInformation.title } + ) } + { hasPost && + ! isLoading && hasPostTerms && postTerms .map( ( postTerm ) => ( @@ -132,7 +132,8 @@ export default function PostTermsEdit( { { curr } ) ) } - { ! isLoading && + { hasPost && + ! isLoading && ! hasPostTerms && ( selectedTerm?.labels?.no_terms || __( 'Term items not found.' ) ) } diff --git a/packages/block-library/src/post-terms/hooks.js b/packages/block-library/src/post-terms/hooks.js index 539ea837a30b8d..2851cd145e9a33 100644 --- a/packages/block-library/src/post-terms/hooks.js +++ b/packages/block-library/src/post-terms/hooks.js @@ -16,9 +16,9 @@ export default function enhanceVariations( settings, name ) { } const variations = settings.variations.map( ( variation ) => ( { ...variation, - ...( variationIconMap[ variation.name ] && { - icon: variationIconMap[ variation.name ], - } ), + ...{ + icon: variationIconMap[ variation.name ] ?? postCategories, + }, } ) ); return { ...settings, diff --git a/packages/block-library/src/post-time-to-read/block.json b/packages/block-library/src/post-time-to-read/block.json index 2b7d7936094f7c..281e9bb1f1b210 100644 --- a/packages/block-library/src/post-time-to-read/block.json +++ b/packages/block-library/src/post-time-to-read/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "__experimental": true, "name": "core/post-time-to-read", "title": "Time To Read", @@ -24,7 +24,11 @@ "html": false, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "typography": { "fontSize": true, diff --git a/packages/block-library/src/post-time-to-read/init.js b/packages/block-library/src/post-time-to-read/init.js new file mode 100644 index 00000000000000..79f0492c2cb2f8 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/packages/block-library/src/post-title/block.json b/packages/block-library/src/post-title/block.json index 4a56a6f37b7795..eda5332f240223 100644 --- a/packages/block-library/src/post-title/block.json +++ b/packages/block-library/src/post-title/block.json @@ -1,8 +1,8 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/post-title", - "title": "Post Title", + "title": "Title", "category": "theme", "description": "Displays the title of a post, page, or any other content-type.", "textdomain": "default", @@ -31,7 +31,6 @@ }, "supports": { "align": [ "wide", "full" ], - "anchor": true, "html": false, "color": { "gradients": true, diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js index 8cd71881e06dec..28d9b8cdcfd8ff 100644 --- a/packages/block-library/src/post-title/edit.js +++ b/packages/block-library/src/post-title/edit.js @@ -12,6 +12,8 @@ import { InspectorControls, useBlockProps, PlainText, + HeadingLevelDropdown, + useBlockEditingMode, } from '@wordpress/block-editor'; import { ToggleControl, TextControl, PanelBody } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -21,7 +23,6 @@ import { useEntityProp } from '@wordpress/core-data'; /** * Internal dependencies */ -import HeadingLevelDropdown from '../heading/heading-level-dropdown'; import { useCanEditEntity } from '../utils/hooks'; export default function PostTitleEdit( { @@ -30,7 +31,7 @@ export default function PostTitleEdit( { context: { postType, postId, queryId }, insertBlocksAfter, } ) { - const TagName = 0 === level ? 'p' : 'h' + level; + const TagName = 'h' + level; const isDescendentOfQueryLoop = Number.isFinite( queryId ); /** * Hack: useCanEditEntity may trigger an OPTIONS request to the REST API via the canUser resolver. @@ -58,10 +59,9 @@ export default function PostTitleEdit( { [ `has-text-align-${ textAlign }` ]: textAlign, } ), } ); + const blockEditingMode = useBlockEditingMode(); - let titleElement = ( - { __( 'Post Title' ) } - ); + let titleElement = { __( 'Title' ) }; if ( postType && postId ) { titleElement = userCanEdit ? ( @@ -114,20 +114,22 @@ export default function PostTitleEdit( { return ( <> - - - setAttributes( { level: newLevel } ) - } - /> - { - setAttributes( { textAlign: nextAlign } ); - } } - /> - + { blockEditingMode === 'default' && ( + + + setAttributes( { level: newLevel } ) + } + /> + { + setAttributes( { textAlign: nextAlign } ); + } } + /> + + ) } context['postId'] ); - $title = get_the_title( $post ); + /** + * The `$post` argument is intentionally omitted so that changes are reflected when previewing a post. + * See: https://github.com/WordPress/gutenberg/pull/37622#issuecomment-1000932816. + */ + $title = get_the_title(); if ( ! $title ) { return ''; @@ -28,12 +33,12 @@ function render_block_core_post_title( $attributes, $content, $block ) { $tag_name = 'h2'; if ( isset( $attributes['level'] ) ) { - $tag_name = 0 === $attributes['level'] ? 'p' : 'h' . $attributes['level']; + $tag_name = 'h' . $attributes['level']; } if ( isset( $attributes['isLink'] ) && $attributes['isLink'] ) { $rel = ! empty( $attributes['rel'] ) ? 'rel="' . esc_attr( $attributes['rel'] ) . '"' : ''; - $title = sprintf( '%4$s', get_the_permalink( $post ), esc_attr( $attributes['linkTarget'] ), $rel, $title ); + $title = sprintf( '%4$s', get_the_permalink( $block->context['postId'] ), esc_attr( $attributes['linkTarget'] ), $rel, $title ); } $classes = array(); diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json index bab40b94a7ec46..ec6ea839385eb2 100644 --- a/packages/block-library/src/preformatted/block.json +++ b/packages/block-library/src/preformatted/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/preformatted", "title": "Preformatted", "category": "text", @@ -25,6 +25,10 @@ "text": true } }, + "spacing": { + "padding": true, + "margin": true + }, "typography": { "fontSize": true, "lineHeight": true, diff --git a/packages/block-library/src/preformatted/style.scss b/packages/block-library/src/preformatted/style.scss index 71e60ffe4ea529..783fee74d4f4fa 100644 --- a/packages/block-library/src/preformatted/style.scss +++ b/packages/block-library/src/preformatted/style.scss @@ -1,7 +1,10 @@ .wp-block-preformatted { + // This block has customizable padding, border-box makes that more predictable. + box-sizing: border-box; white-space: pre-wrap; } -.wp-block-preformatted.has-background { +// Add low specificity default padding when a background is used. +:where(.wp-block-preformatted.has-background) { padding: $block-bg-padding--v $block-bg-padding--h; } diff --git a/packages/block-library/src/preformatted/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/preformatted/test/__snapshots__/edit.native.js.snap index 7c0a7aa1dcf48c..db1c80c514206b 100644 --- a/packages/block-library/src/preformatted/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/preformatted/test/__snapshots__/edit.native.js.snap @@ -39,6 +39,7 @@ exports[`Preformatted should match snapshot when content is empty 1`] = ` onSelectionChange={[Function]} placeholder="Write preformatted text…" placeholderTextColor="gray" + selectionColor="black" triggerKeyCodes={[]} value="" /> @@ -85,6 +86,7 @@ exports[`Preformatted should match snapshot when content is not empty 1`] = ` onSelectionChange={[Function]} placeholder="Write preformatted text…" placeholderTextColor="gray" + selectionColor="black" triggerKeyCodes={[]} value="
      Hello World!
      " /> diff --git a/packages/block-library/src/preformatted/test/edit.native.js b/packages/block-library/src/preformatted/test/edit.native.js index 21286a7cdd67df..1fdb4532dacab6 100644 --- a/packages/block-library/src/preformatted/test/edit.native.js +++ b/packages/block-library/src/preformatted/test/edit.native.js @@ -24,24 +24,12 @@ import PreformattedEdit from '../edit'; setupCoreBlocks(); describe( 'Preformatted', () => { - it( 'renders without crashing', () => { - const screen = render( - - ); - - expect( screen.container ).toBeDefined(); - } ); - it( 'should match snapshot when content is empty', () => { const screen = render( styles1 } + getStylesFromColorScheme={ jest.fn() } /> ); expect( screen.toJSON() ).toMatchSnapshot(); diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json index 0732bb52f66bfb..54c4175d3161bd 100644 --- a/packages/block-library/src/pullquote/block.json +++ b/packages/block-library/src/pullquote/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/pullquote", "title": "Pullquote", "category": "text", diff --git a/packages/block-library/src/pullquote/test/edit.native.js b/packages/block-library/src/pullquote/test/edit.native.js index 9346750627edba..c53ad24a33f028 100644 --- a/packages/block-library/src/pullquote/test/edit.native.js +++ b/packages/block-library/src/pullquote/test/edit.native.js @@ -5,6 +5,7 @@ import { addBlock, getBlock, initializeEditor, + selectRangeInRichText, setupCoreBlocks, getEditorHtml, fireEvent, @@ -45,10 +46,13 @@ describe( 'Pullquote', () => { const citationTextInput = within( citationBlock ).getByPlaceholderText( 'Add citation' ); - typeInRichText( citationTextInput, 'A person', { - finalSelectionStart: 2, - finalSelectionEnd: 2, + typeInRichText( citationTextInput, 'A person' ); + fireEvent( citationTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: ENTER, } ); + selectRangeInRichText( citationTextInput, 2 ); fireEvent( citationTextInput, 'onKeyDown', { nativeEvent: {}, preventDefault() {}, @@ -59,7 +63,11 @@ describe( 'Pullquote', () => { expect( getEditorHtml() ).toMatchInlineSnapshot( ` "

      A great statement.
      Again

      A
      person
      - " + + + +

      + " ` ); } ); } ); diff --git a/packages/block-library/src/query-no-results/block.json b/packages/block-library/src/query-no-results/block.json index 789dcc8e66f605..32088752bb0606 100644 --- a/packages/block-library/src/query-no-results/block.json +++ b/packages/block-library/src/query-no-results/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query-no-results", "title": "No results", "category": "theme", @@ -9,7 +9,6 @@ "textdomain": "default", "usesContext": [ "queryId", "query" ], "supports": { - "anchor": true, "align": true, "reusable": false, "html": false, diff --git a/packages/block-library/src/query-no-results/index.php b/packages/block-library/src/query-no-results/index.php index 4342ba57cccbd7..a6f4bd14d01972 100644 --- a/packages/block-library/src/query-no-results/index.php +++ b/packages/block-library/src/query-no-results/index.php @@ -32,14 +32,10 @@ function render_block_core_query_no_results( $attributes, $content, $block ) { $query = new WP_Query( $query_args ); } - if ( $query->have_posts() ) { + if ( $query->post_count > 0 ) { return ''; } - if ( ! $use_global_query ) { - wp_reset_postdata(); - } - $classes = ( isset( $attributes['style']['elements']['link']['color']['text'] ) ) ? 'has-link-color' : ''; $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $classes ) ); return sprintf( diff --git a/packages/block-library/src/query-pagination-next/block.json b/packages/block-library/src/query-pagination-next/block.json index d4861519f149ee..95b1169dc992fd 100644 --- a/packages/block-library/src/query-pagination-next/block.json +++ b/packages/block-library/src/query-pagination-next/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query-pagination-next", "title": "Next Page", "category": "theme", @@ -12,9 +12,14 @@ "type": "string" } }, - "usesContext": [ "queryId", "query", "paginationArrow" ], + "usesContext": [ + "queryId", + "query", + "paginationArrow", + "showLabel", + "enhancedPagination" + ], "supports": { - "anchor": true, "reusable": false, "html": false, "color": { diff --git a/packages/block-library/src/query-pagination-next/edit.js b/packages/block-library/src/query-pagination-next/edit.js index c0b4916abccb3d..ce76889f37c750 100644 --- a/packages/block-library/src/query-pagination-next/edit.js +++ b/packages/block-library/src/query-pagination-next/edit.js @@ -13,7 +13,7 @@ const arrowMap = { export default function QueryPaginationNextEdit( { attributes: { label }, setAttributes, - context: { paginationArrow }, + context: { paginationArrow, showLabel }, } ) { const displayArrow = arrowMap[ paginationArrow ]; return ( @@ -22,16 +22,18 @@ export default function QueryPaginationNextEdit( { onClick={ ( event ) => event.preventDefault() } { ...useBlockProps() } > - - setAttributes( { label: newLabel } ) - } - /> + { showLabel && ( + <PlainText + __experimentalVersion={ 2 } + tagName="span" + aria-label={ __( 'Next page link' ) } + placeholder={ __( 'Next Page' ) } + value={ label } + onChange={ ( newLabel ) => + setAttributes( { label: newLabel } ) + } + /> + ) } { displayArrow && ( <span className={ `wp-block-query-pagination-next-arrow is-arrow-${ paginationArrow }` } diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index e92b58938d53b8..83c177c6fb0a9f 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -15,15 +15,21 @@ * @return string Returns the next posts link for the query pagination. */ function render_block_core_query_pagination_next( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $wrapper_attributes = get_block_wrapper_attributes(); + $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; $default_label = __( 'Next Page' ); - $label = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; + $label_text = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; + $label = $show_label ? $label_text : ''; $pagination_arrow = get_query_pagination_arrow( $block, true ); + if ( ! $label ) { + $wrapper_attributes .= ' aria-label="' . $label_text . '"'; + } if ( $pagination_arrow ) { $label .= $pagination_arrow; } @@ -31,7 +37,7 @@ function render_block_core_query_pagination_next( $attributes, $content, $block // Check if the pagination is for Query that inherits the global context. if ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ) { - $filter_link_attributes = function() use ( $wrapper_attributes ) { + $filter_link_attributes = static function() use ( $wrapper_attributes ) { return $wrapper_attributes; }; add_filter( 'next_posts_link_attributes', $filter_link_attributes ); @@ -56,6 +62,22 @@ function render_block_core_query_pagination_next( $attributes, $content, $block } wp_reset_postdata(); // Restore original Post Data. } + + if ( $enhanced_pagination ) { + $p = new WP_HTML_Tag_Processor( $content ); + if ( $p->next_tag( + array( + 'tag_name' => 'a', + 'class_name' => 'wp-block-query-pagination-next', + ) + ) ) { + $p->set_attribute( 'data-wp-key', 'query-pagination-next' ); + $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); + $content = $p->get_updated_html(); + } + } + return $content; } diff --git a/packages/block-library/src/query-pagination-numbers/block.json b/packages/block-library/src/query-pagination-numbers/block.json index a05faff5f1b52b..f05e269d2ece20 100644 --- a/packages/block-library/src/query-pagination-numbers/block.json +++ b/packages/block-library/src/query-pagination-numbers/block.json @@ -1,15 +1,20 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query-pagination-numbers", "title": "Page Numbers", "category": "theme", "parent": [ "core/query-pagination" ], "description": "Displays a list of page numbers for pagination", "textdomain": "default", - "usesContext": [ "queryId", "query" ], + "attributes": { + "midSize": { + "type": "number", + "default": 2 + } + }, + "usesContext": [ "queryId", "query", "enhancedPagination" ], "supports": { - "anchor": true, "reusable": false, "html": false, "color": { @@ -33,5 +38,5 @@ } } }, - "editorStyle": "query-pagination-numbers-editor" + "editorStyle": "wp-block-query-pagination-numbers-editor" } diff --git a/packages/block-library/src/query-pagination-numbers/edit.js b/packages/block-library/src/query-pagination-numbers/edit.js index 3832f673ea1248..eb83204b2cca2b 100644 --- a/packages/block-library/src/query-pagination-numbers/edit.js +++ b/packages/block-library/src/query-pagination-numbers/edit.js @@ -1,25 +1,73 @@ /** * WordPress dependencies */ -import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { PanelBody, RangeControl } from '@wordpress/components'; const createPaginationItem = ( content, Tag = 'a', extraClass = '' ) => ( - <Tag className={ `page-numbers ${ extraClass }` }>{ content }</Tag> + <Tag key={ content } className={ `page-numbers ${ extraClass }` }> + { content } + </Tag> ); -const previewPaginationNumbers = () => ( - <> - { createPaginationItem( 1 ) } - { createPaginationItem( 2 ) } - { createPaginationItem( 3, 'span', 'current' ) } - { createPaginationItem( 4 ) } - { createPaginationItem( 5 ) } - { createPaginationItem( '...', 'span', 'dots' ) } - { createPaginationItem( 8 ) } - </> -); +const previewPaginationNumbers = ( midSize ) => { + const paginationItems = []; + + // First set of pagination items. + for ( let i = 1; i <= midSize; i++ ) { + paginationItems.push( createPaginationItem( i ) ); + } + + // Current pagination item. + paginationItems.push( + createPaginationItem( midSize + 1, 'span', 'current' ) + ); + + // Second set of pagination items. + for ( let i = 1; i <= midSize; i++ ) { + paginationItems.push( createPaginationItem( midSize + 1 + i ) ); + } + + // Dots. + paginationItems.push( createPaginationItem( '...', 'span', 'dots' ) ); + + // Last pagination item. + paginationItems.push( createPaginationItem( midSize * 2 + 3 ) ); + + return <>{ paginationItems }</>; +}; -export default function QueryPaginationNumbersEdit() { - const paginationNumbers = previewPaginationNumbers(); - return <div { ...useBlockProps() }>{ paginationNumbers }</div>; +export default function QueryPaginationNumbersEdit( { + attributes, + setAttributes, +} ) { + const { midSize } = attributes; + const paginationNumbers = previewPaginationNumbers( + parseInt( midSize, 10 ) + ); + return ( + <> + <InspectorControls> + <PanelBody title={ __( 'Settings' ) }> + <RangeControl + label={ __( 'Number of links' ) } + help={ __( + 'Specify how many links can appear before and after the current page number. Links to the first, current and last page are always visible.' + ) } + value={ midSize } + onChange={ ( value ) => { + setAttributes( { + midSize: parseInt( value, 10 ), + } ); + } } + min={ 0 } + max={ 5 } + withInputField={ false } + /> + </PanelBody> + </InspectorControls> + <div { ...useBlockProps() }>{ paginationNumbers }</div> + </> + ); } diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 60fe85efa1f8dc..98098533adac7d 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -15,13 +15,15 @@ * @return string Returns the pagination numbers for the Query. */ function render_block_core_query_pagination_numbers( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $wrapper_attributes = get_block_wrapper_attributes(); $content = ''; global $wp_query; + $mid_size = isset( $block->attributes['midSize'] ) ? (int) $block->attributes['midSize'] : null; if ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ) { // Take into account if we have set a bigger `max page` // than what the query has. @@ -30,7 +32,10 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo 'prev_next' => false, 'total' => $total, ); - $content = paginate_links( $paginate_args ); + if ( null !== $mid_size ) { + $paginate_args['mid_size'] = $mid_size; + } + $content = paginate_links( $paginate_args ); } else { $block_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); // `paginate_links` works with the global $wp_query, so we have to @@ -45,6 +50,9 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo 'total' => $total, 'prev_next' => false, ); + if ( null !== $mid_size ) { + $paginate_args['mid_size'] = $mid_size; + } if ( 1 !== $page ) { /** * `paginate_links` doesn't use the provided `format` when the page is `1`. @@ -77,9 +85,24 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo wp_reset_postdata(); // Restore original Post Data. $wp_query = $prev_wp_query; } + if ( empty( $content ) ) { return ''; } + + if ( $enhanced_pagination ) { + $p = new WP_HTML_Tag_Processor( $content ); + while ( $p->next_tag( + array( + 'tag_name' => 'a', + 'class_name' => 'page-numbers', + ) + ) ) { + $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); + } + $content = $p->get_updated_html(); + } + return sprintf( '<div %1$s>%2$s</div>', $wrapper_attributes, diff --git a/packages/block-library/src/query-pagination-previous/block.json b/packages/block-library/src/query-pagination-previous/block.json index 823808b0fb054d..fbaac543c1da35 100644 --- a/packages/block-library/src/query-pagination-previous/block.json +++ b/packages/block-library/src/query-pagination-previous/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query-pagination-previous", "title": "Previous Page", "category": "theme", @@ -12,9 +12,14 @@ "type": "string" } }, - "usesContext": [ "queryId", "query", "paginationArrow" ], + "usesContext": [ + "queryId", + "query", + "paginationArrow", + "showLabel", + "enhancedPagination" + ], "supports": { - "anchor": true, "reusable": false, "html": false, "color": { diff --git a/packages/block-library/src/query-pagination-previous/edit.js b/packages/block-library/src/query-pagination-previous/edit.js index c863d637f7fb51..3d8b3cfbbef72a 100644 --- a/packages/block-library/src/query-pagination-previous/edit.js +++ b/packages/block-library/src/query-pagination-previous/edit.js @@ -13,7 +13,7 @@ const arrowMap = { export default function QueryPaginationPreviousEdit( { attributes: { label }, setAttributes, - context: { paginationArrow }, + context: { paginationArrow, showLabel }, } ) { const displayArrow = arrowMap[ paginationArrow ]; return ( @@ -30,16 +30,18 @@ export default function QueryPaginationPreviousEdit( { { displayArrow } </span> ) } - <PlainText - __experimentalVersion={ 2 } - tagName="span" - aria-label={ __( 'Previous page link' ) } - placeholder={ __( 'Previous Page' ) } - value={ label } - onChange={ ( newLabel ) => - setAttributes( { label: newLabel } ) - } - /> + { showLabel && ( + <PlainText + __experimentalVersion={ 2 } + tagName="span" + aria-label={ __( 'Previous page link' ) } + placeholder={ __( 'Previous Page' ) } + value={ label } + onChange={ ( newLabel ) => + setAttributes( { label: newLabel } ) + } + /> + ) } </a> ); } diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index 9bb9f0d1f2bfbf..a580880f0f04c8 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -15,13 +15,19 @@ * @return string Returns the previous posts link for the query. */ function render_block_core_query_pagination_previous( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; $wrapper_attributes = get_block_wrapper_attributes(); + $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; $default_label = __( 'Previous Page' ); - $label = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; + $label_text = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; + $label = $show_label ? $label_text : ''; $pagination_arrow = get_query_pagination_arrow( $block, false ); + if ( ! $label ) { + $wrapper_attributes .= ' aria-label="' . $label_text . '"'; + } if ( $pagination_arrow ) { $label = $pagination_arrow . $label; } @@ -29,7 +35,7 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl // Check if the pagination is for Query that inherits the global context // and handle appropriately. if ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ) { - $filter_link_attributes = function() use ( $wrapper_attributes ) { + $filter_link_attributes = static function() use ( $wrapper_attributes ) { return $wrapper_attributes; }; @@ -44,6 +50,22 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl $label ); } + + if ( $enhanced_pagination ) { + $p = new WP_HTML_Tag_Processor( $content ); + if ( $p->next_tag( + array( + 'tag_name' => 'a', + 'class_name' => 'wp-block-query-pagination-previous', + ) + ) ) { + $p->set_attribute( 'data-wp-key', 'query-pagination-previous' ); + $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); + $content = $p->get_updated_html(); + } + } + return $content; } diff --git a/packages/block-library/src/query-pagination/block.json b/packages/block-library/src/query-pagination/block.json index fa980575ec969d..e32a9ba9b495ff 100644 --- a/packages/block-library/src/query-pagination/block.json +++ b/packages/block-library/src/query-pagination/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query-pagination", "title": "Pagination", "category": "theme", @@ -11,14 +11,18 @@ "paginationArrow": { "type": "string", "default": "none" + }, + "showLabel": { + "type": "boolean", + "default": true } }, "usesContext": [ "queryId", "query" ], "providesContext": { - "paginationArrow": "paginationArrow" + "paginationArrow": "paginationArrow", + "showLabel": "showLabel" }, "supports": { - "anchor": true, "align": true, "reusable": false, "html": false, @@ -31,7 +35,7 @@ "link": true } }, - "__experimentalLayout": { + "layout": { "allowSwitching": false, "allowInheriting": false, "default": { diff --git a/packages/block-library/src/query-pagination/edit.js b/packages/block-library/src/query-pagination/edit.js index 28e6af19ace347..7598eba5c1cacf 100644 --- a/packages/block-library/src/query-pagination/edit.js +++ b/packages/block-library/src/query-pagination/edit.js @@ -10,11 +10,13 @@ import { } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; import { PanelBody } from '@wordpress/components'; +import { useEffect } from '@wordpress/element'; /** * Internal dependencies */ import { QueryPaginationArrowControls } from './query-pagination-arrow-controls'; +import { QueryPaginationLabelControl } from './query-pagination-label-control'; const TEMPLATE = [ [ 'core/query-pagination-previous' ], @@ -28,29 +30,38 @@ const ALLOWED_BLOCKS = [ ]; export default function QueryPaginationEdit( { - attributes: { paginationArrow }, + attributes: { paginationArrow, showLabel }, setAttributes, clientId, } ) { - const hasNextPreviousBlocks = useSelect( ( select ) => { - const { getBlocks } = select( blockEditorStore ); - const innerBlocks = getBlocks( clientId ); - /** - * Show the `paginationArrow` control only if a - * `QueryPaginationNext/Previous` block exists. - */ - return innerBlocks?.find( ( innerBlock ) => { - return [ - 'core/query-pagination-next', - 'core/query-pagination-previous', - ].includes( innerBlock.name ); - } ); - }, [] ); + const hasNextPreviousBlocks = useSelect( + ( select ) => { + const { getBlocks } = select( blockEditorStore ); + const innerBlocks = getBlocks( clientId ); + /** + * Show the `paginationArrow` and `showLabel` controls only if a + * `QueryPaginationNext/Previous` block exists. + */ + return innerBlocks?.find( ( innerBlock ) => { + return [ + 'core/query-pagination-next', + 'core/query-pagination-previous', + ].includes( innerBlock.name ); + } ); + }, + [ clientId ] + ); const blockProps = useBlockProps(); const innerBlocksProps = useInnerBlocksProps( blockProps, { template: TEMPLATE, allowedBlocks: ALLOWED_BLOCKS, } ); + // Always show label text if paginationArrow is set to 'none'. + useEffect( () => { + if ( paginationArrow === 'none' && ! showLabel ) { + setAttributes( { showLabel: true } ); + } + }, [ paginationArrow, setAttributes, showLabel ] ); return ( <> { hasNextPreviousBlocks && ( @@ -62,6 +73,14 @@ export default function QueryPaginationEdit( { setAttributes( { paginationArrow: value } ); } } /> + { paginationArrow !== 'none' && ( + <QueryPaginationLabelControl + value={ showLabel } + onChange={ ( value ) => { + setAttributes( { showLabel: value } ); + } } + /> + ) } </PanelBody> </InspectorControls> ) } diff --git a/packages/block-library/src/query-pagination/query-pagination-label-control.js b/packages/block-library/src/query-pagination/query-pagination-label-control.js new file mode 100644 index 00000000000000..9ff80a663adeb5 --- /dev/null +++ b/packages/block-library/src/query-pagination/query-pagination-label-control.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { ToggleControl } from '@wordpress/components'; + +export function QueryPaginationLabelControl( { value, onChange } ) { + return ( + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Show label text' ) } + help={ __( + 'Toggle off to hide the label text, e.g. "Next Page".' + ) } + onChange={ onChange } + checked={ value === true } + /> + ); +} diff --git a/packages/block-library/src/query-title/block.json b/packages/block-library/src/query-title/block.json index 029762c321e399..2db349e55db90a 100644 --- a/packages/block-library/src/query-title/block.json +++ b/packages/block-library/src/query-title/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query-title", "title": "Query Title", "category": "theme", @@ -27,7 +27,6 @@ } }, "supports": { - "anchor": true, "align": [ "wide", "full" ], "html": false, "color": { diff --git a/packages/block-library/src/query-title/edit.js b/packages/block-library/src/query-title/edit.js index da321bead7c0b8..b74e03e7583b2d 100644 --- a/packages/block-library/src/query-title/edit.js +++ b/packages/block-library/src/query-title/edit.js @@ -12,14 +12,12 @@ import { InspectorControls, useBlockProps, Warning, + HeadingLevelDropdown, + store as blockEditorStore, } from '@wordpress/block-editor'; import { ToggleControl, PanelBody } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import HeadingLevelDropdown from '../heading/heading-level-dropdown'; +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; const SUPPORTED_TYPES = [ 'archive', 'search' ]; @@ -27,6 +25,18 @@ export default function QueryTitleEdit( { attributes: { type, level, textAlign, showPrefix, showSearchTerm }, setAttributes, } ) { + const { archiveTypeTitle, archiveNameLabel } = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const { + __experimentalArchiveTitleNameLabel, + __experimentalArchiveTitleTypeLabel, + } = getSettings(); + return { + archiveTypeTitle: __experimentalArchiveTitleTypeLabel, + archiveNameLabel: __experimentalArchiveTitleNameLabel, + }; + } ); + const TagName = `h${ level }`; const blockProps = useBlockProps( { className: classnames( 'wp-block-query-title__placeholder', { @@ -44,6 +54,38 @@ export default function QueryTitleEdit( { let titleElement; if ( type === 'archive' ) { + let title; + if ( archiveTypeTitle ) { + if ( showPrefix ) { + if ( archiveNameLabel ) { + title = sprintf( + /* translators: 1: Archive type title e.g: "Category", 2: Label of the archive e.g: "Shoes" */ + __( '%1$s: %2$s' ), + archiveTypeTitle, + archiveNameLabel + ); + } else { + title = sprintf( + /* translators: %s: Archive type title e.g: "Category", "Tag"... */ + __( '%s: Name' ), + archiveTypeTitle + ); + } + } else if ( archiveNameLabel ) { + title = archiveNameLabel; + } else { + title = sprintf( + /* translators: %s: Archive type title e.g: "Category", "Tag"... */ + __( '%s name' ), + archiveTypeTitle + ); + } + } else { + title = showPrefix + ? __( 'Archive type: Name' ) + : __( 'Archive title' ); + } + titleElement = ( <> <InspectorControls> @@ -58,11 +100,7 @@ export default function QueryTitleEdit( { /> </PanelBody> </InspectorControls> - <TagName { ...blockProps }> - { showPrefix - ? __( 'Archive type: Name' ) - : __( 'Archive title' ) } - </TagName> + <TagName { ...blockProps }>{ title }</TagName> </> ); } @@ -98,7 +136,7 @@ export default function QueryTitleEdit( { <> <BlockControls group="block"> <HeadingLevelDropdown - selectedLevel={ level } + value={ level } onChange={ ( newLevel ) => setAttributes( { level: newLevel } ) } diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json index bcff0e3ac63b17..d30eccf3765792 100644 --- a/packages/block-library/src/query/block.json +++ b/packages/block-library/src/query/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/query", "title": "Query Loop", "category": "theme", @@ -32,26 +32,26 @@ "type": "string", "default": "div" }, - "displayLayout": { - "type": "object", - "default": { - "type": "list" - } - }, "namespace": { "type": "string" + }, + "enhancedPagination": { + "type": "boolean", + "default": false } }, "providesContext": { "queryId": "queryId", "query": "query", - "displayLayout": "displayLayout" + "displayLayout": "displayLayout", + "enhancedPagination": "enhancedPagination" }, "supports": { "align": [ "wide", "full" ], - "anchor": true, "html": false, - "__experimentalLayout": true + "layout": true }, - "editorStyle": "wp-block-query-editor" + "editorStyle": "wp-block-query-editor", + "style": "wp-block-query", + "viewScript": "file:./view.min.js" } diff --git a/packages/block-library/src/query/deprecated.js b/packages/block-library/src/query/deprecated.js index 05ee6217a288d6..6ec50198973ca6 100644 --- a/packages/block-library/src/query/deprecated.js +++ b/packages/block-library/src/query/deprecated.js @@ -12,7 +12,7 @@ import { /** * Internal dependencies */ -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; const { cleanEmptyObject } = unlock( blockEditorPrivateApis ); @@ -126,6 +126,86 @@ const migrateColors = ( attributes, innerBlocks ) => { const hasSingleInnerGroupBlock = ( innerBlocks = [] ) => innerBlocks.length === 1 && innerBlocks[ 0 ].name === 'core/group'; +const migrateToConstrainedLayout = ( attributes ) => { + const { layout = null } = attributes; + if ( ! layout ) { + return attributes; + } + const { inherit = null, contentSize = null, ...newLayout } = layout; + + if ( inherit || contentSize ) { + return { + ...attributes, + layout: { + ...newLayout, + contentSize, + type: 'constrained', + }, + }; + } + + return attributes; +}; + +const findPostTemplateBlock = ( innerBlocks = [] ) => { + let foundBlock = null; + for ( const block of innerBlocks ) { + if ( block.name === 'core/post-template' ) { + foundBlock = block; + break; + } else if ( block.innerBlocks.length ) { + foundBlock = findPostTemplateBlock( block.innerBlocks ); + } + } + return foundBlock; +}; + +const replacePostTemplateBlock = ( innerBlocks = [], replacementBlock ) => { + innerBlocks.forEach( ( block, index ) => { + if ( block.name === 'core/post-template' ) { + innerBlocks.splice( index, 1, replacementBlock ); + } else if ( block.innerBlocks.length ) { + block.innerBlocks = replacePostTemplateBlock( + block.innerBlocks, + replacementBlock + ); + } + } ); + return innerBlocks; +}; + +const migrateDisplayLayout = ( attributes, innerBlocks ) => { + const { displayLayout = null, ...newAttributes } = attributes; + if ( ! displayLayout ) { + return [ attributes, innerBlocks ]; + } + const postTemplateBlock = findPostTemplateBlock( innerBlocks ); + if ( ! postTemplateBlock ) { + return [ attributes, innerBlocks ]; + } + + const { type, columns } = displayLayout; + + // Convert custom displayLayout values to canonical layout types. + const updatedLayoutType = type === 'flex' ? 'grid' : 'default'; + + const newPostTemplateBlock = createBlock( + 'core/post-template', + { + ...postTemplateBlock.attributes, + layout: { + type: updatedLayoutType, + ...( columns && { columnCount: columns } ), + }, + }, + postTemplateBlock.innerBlocks + ); + return [ + newAttributes, + replacePostTemplateBlock( innerBlocks, newPostTemplateBlock ), + ]; +}; + // Version with NO wrapper `div` element. const v1 = { attributes: { @@ -160,13 +240,14 @@ const v1 = { supports: { html: false, }, - migrate( attributes ) { + migrate( attributes, innerBlocks ) { const withTaxQuery = migrateToTaxQuery( attributes ); const { layout, ...restWithTaxQuery } = withTaxQuery; - return { + const newAttributes = { ...restWithTaxQuery, displayLayout: withTaxQuery.layout, }; + return migrateDisplayLayout( newAttributes, innerBlocks ); }, save() { return <InnerBlocks.Content />; @@ -215,13 +296,22 @@ const v2 = { gradients: true, link: true, }, - __experimentalLayout: true, + layout: true, }, isEligible: ( { query: { categoryIds, tagIds } = {} } ) => categoryIds || tagIds, migrate( attributes, innerBlocks ) { const withTaxQuery = migrateToTaxQuery( attributes ); - return migrateColors( withTaxQuery, innerBlocks ); + const [ withColorAttributes, withColorInnerBlocks ] = migrateColors( + withTaxQuery, + innerBlocks + ); + const withConstrainedLayoutAttributes = + migrateToConstrainedLayout( withColorAttributes ); + return migrateDisplayLayout( + withConstrainedLayoutAttributes, + withColorInnerBlocks + ); }, save( { attributes: { tagName: Tag = 'div' } } ) { const blockProps = useBlockProps.save(); @@ -279,7 +369,7 @@ const v3 = { text: true, }, }, - __experimentalLayout: true, + layout: true, }, isEligible( attributes ) { const { style, backgroundColor, gradient, textColor } = attributes; @@ -291,7 +381,18 @@ const v3 = { style?.elements?.link ); }, - migrate: migrateColors, + migrate( attributes, innerBlocks ) { + const [ withColorAttributes, withColorInnerBlocks ] = migrateColors( + attributes, + innerBlocks + ); + const withConstrainedLayoutAttributes = + migrateToConstrainedLayout( withColorAttributes ); + return migrateDisplayLayout( + withConstrainedLayoutAttributes, + withColorInnerBlocks + ); + }, save( { attributes: { tagName: Tag = 'div' } } ) { const blockProps = useBlockProps.save(); const innerBlocksProps = useInnerBlocksProps.save( blockProps ); @@ -347,7 +448,7 @@ const v4 = { text: true, }, }, - __experimentalLayout: true, + layout: true, }, save( { attributes: { tagName: Tag = 'div' } } ) { const blockProps = useBlockProps.save(); @@ -355,26 +456,72 @@ const v4 = { return <Tag { ...innerBlocksProps } />; }, isEligible: ( { layout } ) => - ! layout || - layout.inherit || - ( layout.contentSize && layout.type !== 'constrained' ), - migrate: ( attributes ) => { - const { layout = null } = attributes; - if ( ! layout ) { - return attributes; - } - if ( layout.inherit || layout.contentSize ) { - return { - ...attributes, - layout: { - ...layout, - type: 'constrained', - }, - }; - } + layout?.inherit || + ( layout?.contentSize && layout?.type !== 'constrained' ), + migrate( attributes, innerBlocks ) { + const withConstrainedLayoutAttributes = + migrateToConstrainedLayout( attributes ); + return migrateDisplayLayout( + withConstrainedLayoutAttributes, + innerBlocks + ); + }, +}; + +const v5 = { + attributes: { + queryId: { + type: 'number', + }, + query: { + type: 'object', + default: { + perPage: null, + pages: 0, + offset: 0, + postType: 'post', + order: 'desc', + orderBy: 'date', + author: '', + search: '', + exclude: [], + sticky: '', + inherit: true, + taxQuery: null, + parents: [], + }, + }, + tagName: { + type: 'string', + default: 'div', + }, + displayLayout: { + type: 'object', + default: { + type: 'list', + }, + }, + namespace: { + type: 'string', + }, + }, + supports: { + align: [ 'wide', 'full' ], + anchor: true, + html: false, + layout: true, + }, + save( { attributes: { tagName: Tag = 'div' } } ) { + const blockProps = useBlockProps.save(); + const innerBlocksProps = useInnerBlocksProps.save( blockProps ); + return <Tag { ...innerBlocksProps } />; + }, + isEligible: ( { displayLayout } ) => { + return !! displayLayout; }, + migrate: migrateDisplayLayout, }; -const deprecated = [ v4, v3, v2, v1 ]; +const deprecated = [ v5, v4, v3, v2, v1 ]; export default deprecated; 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 2222f7d2d1eff3..492f276ccf6151 100644 --- a/packages/block-library/src/query/edit/inspector-controls/index.js +++ b/packages/block-library/src/query/edit/inspector-controls/index.js @@ -17,7 +17,8 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { debounce } from '@wordpress/compose'; -import { useEffect, useState, useCallback } from '@wordpress/element'; +import { useEffect, useState, useCallback, useRef } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; /** * Internal dependencies @@ -28,7 +29,7 @@ import ParentControl from './parent-control'; import { TaxonomyControls } from './taxonomy-controls'; import StickyControl from './sticky-control'; import CreateNewPostLink from './create-new-post-link'; -import { unlock } from '../../../private-apis'; +import { unlock } from '../../../lock-unlock'; import { usePostTypes, useIsPostTypeHierarchical, @@ -40,8 +41,8 @@ import { const { BlockInfo } = unlock( blockEditorPrivateApis ); export default function QueryInspectorControls( props ) { - const { attributes, setQuery, setDisplayLayout } = props; - const { query, displayLayout } = attributes; + const { attributes, setQuery, setDisplayLayout, setAttributes } = props; + const { query, displayLayout, enhancedPagination } = attributes; const { order, orderBy, @@ -101,7 +102,7 @@ export default function QueryInspectorControls( props ) { const showInheritControl = isControlAllowed( allowedControls, 'inherit' ); const showPostTypeControl = ! inherit && isControlAllowed( allowedControls, 'postType' ); - const showColumnsControl = displayLayout?.type === 'flex'; + const showColumnsControl = false; const showOrderControl = ! inherit && isControlAllowed( allowedControls, 'order' ); const showStickyControl = @@ -123,6 +124,18 @@ export default function QueryInspectorControls( props ) { isControlAllowed( allowedControls, 'parents' ) && isPostTypeHierarchical; + const enhancedPaginationNotice = __( + 'Enhanced Pagination might cause interactive blocks within the Post Template to stop working. Disable it if you experience any issues.' + ); + + const isFirstRender = useRef( true ); // Don't speak on first render. + useEffect( () => { + if ( ! isFirstRender.current && enhancedPagination ) { + speak( enhancedPaginationNotice ); + } + isFirstRender.current = false; + }, [ enhancedPagination, enhancedPaginationNotice ] ); + const showFiltersPanel = showTaxControl || showAuthorControl || @@ -169,7 +182,9 @@ export default function QueryInspectorControls( props ) { label={ __( 'Columns' ) } value={ displayLayout.columns } onChange={ ( value ) => - setDisplayLayout( { columns: value } ) + setDisplayLayout( { + columns: value, + } ) } min={ 2 } max={ Math.max( 6, displayLayout.columns ) } @@ -278,6 +293,36 @@ export default function QueryInspectorControls( props ) { </ToolsPanel> </InspectorControls> ) } + <InspectorControls> + <PanelBody + title={ __( 'User Experience' ) } + initialOpen={ false } + > + <ToggleControl + label={ __( 'Enhanced pagination' ) } + help={ __( + "Don't refresh the page when paginating to another page." + ) } + checked={ !! enhancedPagination } + onChange={ ( value ) => + setAttributes( { + enhancedPagination: !! value, + } ) + } + /> + { enhancedPagination && ( + <div> + <Notice + spokenMessage={ null } + status="warning" + isDismissible={ false } + > + { enhancedPaginationNotice } + </Notice> + </div> + ) } + </PanelBody> + </InspectorControls> </> ); } diff --git a/packages/block-library/src/query/edit/query-content.js b/packages/block-library/src/query/edit/query-content.js index 9563673917f57e..89c6efa2809795 100644 --- a/packages/block-library/src/query/edit/query-content.js +++ b/packages/block-library/src/query/edit/query-content.js @@ -13,6 +13,7 @@ import { } from '@wordpress/block-editor'; import { SelectControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -35,6 +36,7 @@ export default function QueryContent( { query, displayLayout, tagName: TagName = 'div', + query: { inherit } = {}, } = attributes; const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( blockEditorStore ); @@ -45,9 +47,12 @@ export default function QueryContent( { } ); const { postsPerPage } = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); + const { getEntityRecord, canUser } = select( coreStore ); + const settingPerPage = canUser( 'read', 'settings' ) + ? +getEntityRecord( 'root', 'site' )?.posts_per_page + : +getSettings().postsPerPage; return { - postsPerPage: - +getSettings().postsPerPage || DEFAULTS_POSTS_PER_PAGE, + postsPerPage: settingPerPage || DEFAULTS_POSTS_PER_PAGE, }; }, [] ); // There are some effects running where some initialization logic is @@ -61,14 +66,18 @@ export default function QueryContent( { // would cause to override previous wanted changes. useEffect( () => { const newQuery = {}; - if ( ! query.perPage && postsPerPage ) { + // When we inherit from global query always need to set the `perPage` + // based on the reading settings. + if ( inherit && query.perPage !== postsPerPage ) { + newQuery.perPage = postsPerPage; + } else if ( ! query.perPage && postsPerPage ) { newQuery.perPage = postsPerPage; } if ( !! Object.keys( newQuery ).length ) { __unstableMarkNextChangeAsNotPersistent(); updateQuery( newQuery ); } - }, [ query.perPage ] ); + }, [ query.perPage, postsPerPage, inherit ] ); // We need this for multi-query block pagination. // Query parameters for each block are scoped to their ID. useEffect( () => { @@ -100,6 +109,7 @@ export default function QueryContent( { attributes={ attributes } setQuery={ updateQuery } setDisplayLayout={ updateDisplayLayout } + setAttributes={ setAttributes } /> <BlockControls> <QueryToolbar @@ -107,7 +117,6 @@ export default function QueryContent( { clientId={ clientId } attributes={ attributes } setQuery={ updateQuery } - setDisplayLayout={ updateDisplayLayout } openPatternSelectionModal={ openPatternSelectionModal } /> </BlockControls> diff --git a/packages/block-library/src/query/edit/query-toolbar.js b/packages/block-library/src/query/edit/query-toolbar.js index 1d079eb399fb80..7b02290ae4c76c 100644 --- a/packages/block-library/src/query/edit/query-toolbar.js +++ b/packages/block-library/src/query/edit/query-toolbar.js @@ -10,7 +10,7 @@ import { } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; -import { settings, list, grid } from '@wordpress/icons'; +import { settings } from '@wordpress/icons'; /** * Internal dependencies @@ -18,9 +18,8 @@ import { settings, list, grid } from '@wordpress/icons'; import { usePatterns } from '../utils'; export default function QueryToolbar( { - attributes: { query, displayLayout }, + attributes: { query }, setQuery, - setDisplayLayout, openPatternSelectionModal, name, clientId, @@ -30,24 +29,7 @@ export default function QueryToolbar( { QueryToolbar, 'blocks-query-pagination-max-page-input' ); - const displayLayoutControls = [ - { - icon: list, - title: __( 'List view' ), - onClick: () => setDisplayLayout( { type: 'list' } ), - isActive: displayLayout?.type === 'list', - }, - { - icon: grid, - title: __( 'Grid view' ), - onClick: () => - setDisplayLayout( { - type: 'flex', - columns: displayLayout?.columns || 3, - } ), - isActive: displayLayout?.type === 'flex', - }, - ]; + return ( <> { ! query.inherit && ( @@ -144,7 +126,6 @@ export default function QueryToolbar( { </ToolbarButton> </ToolbarGroup> ) } - <ToolbarGroup controls={ displayLayoutControls } /> </> ); } diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 2d22338a97df3e..f06073cc952000 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -5,12 +5,105 @@ * @package WordPress */ +/** + * Modifies the static `core/query` block on the server. + * + * @since X.X.X + * + * @param array $attributes Block attributes. + * @param string $content Block default content. + * @param string $block Block instance. + * + * @return string Returns the modified output of the query block. + */ +function render_block_core_query( $attributes, $content, $block ) { + if ( $attributes['enhancedPagination'] ) { + $p = new WP_HTML_Tag_Processor( $content ); + if ( $p->next_tag() ) { + // Add the necessary directives. + $p->set_attribute( 'data-wp-interactive', true ); + $p->set_attribute( 'data-wp-navigation-id', 'query-' . $attributes['queryId'] ); + $p->set_attribute( + 'data-wp-context', + wp_json_encode( array( 'core' => array( 'query' => (object) array() ) ) ) + ); + $content = $p->get_updated_html(); + + // Mark the block as interactive. + $block->block_type->supports['interactivity'] = true; + + // Add a div to announce messages using `aria-live`. + $last_div_position = strripos( $content, '</div>' ); + $content = substr_replace( + $content, + '<div + class="wp-block-query__enhanced-pagination-navigation-announce" + aria-live="polite" + data-wp-text="context.core.query.message" + ></div> + <div + class="wp-block-query__enhanced-pagination-animation" + data-wp-class--start-animation="selectors.core.query.startAnimation" + data-wp-class--finish-animation="selectors.core.query.finishAnimation" + ></div>', + $last_div_position, + 0 + ); + + // Use state to send translated strings. + wp_store( + array( + 'state' => array( + 'core' => array( + 'query' => array( + 'loadingText' => __( 'Loading page, please wait.' ), + 'loadedText' => __( 'Page Loaded.' ), + ), + ), + ), + ) + ); + } + } + + $view_asset = 'wp-block-query-view'; + if ( ! wp_script_is( $view_asset ) ) { + $script_handles = $block->block_type->view_script_handles; + // If the script is not needed, and it is still in the `view_script_handles`, remove it. + if ( ! $attributes['enhancedPagination'] && in_array( $view_asset, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_asset ) ); + } + // If the script is needed, but it was previously removed, add it again. + if ( $attributes['enhancedPagination'] && ! in_array( $view_asset, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_asset ) ); + } + } + + $style_asset = 'wp-block-query'; + if ( ! wp_style_is( $style_asset ) ) { + $style_handles = $block->block_type->style_handles; + // If the styles are not needed, and they are still in the `style_handles`, remove them. + if ( ! $attributes['enhancedPagination'] && in_array( $style_asset, $style_handles, true ) ) { + $block->block_type->style_handles = array_diff( $style_handles, array( $style_asset ) ); + } + // If the styles are needed, but they were previously removed, add them again. + if ( $attributes['enhancedPagination'] && ! in_array( $style_asset, $style_handles, true ) ) { + $block->block_type->style_handles = array_merge( $style_handles, array( $style_asset ) ); + } + } + + return $content; +} + /** * Registers the `core/query` block on the server. */ function register_block_core_query() { register_block_type_from_metadata( - __DIR__ . '/query' + __DIR__ . '/query', + array( + 'render_callback' => 'render_block_core_query', + ) ); } add_action( 'init', 'register_block_core_query' ); diff --git a/packages/block-library/src/query/style.scss b/packages/block-library/src/query/style.scss new file mode 100644 index 00000000000000..c560018056d7f0 --- /dev/null +++ b/packages/block-library/src/query/style.scss @@ -0,0 +1,63 @@ +.wp-block-query__enhanced-pagination-animation { + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0; + width: 100vw; + max-width: 100vw !important; + height: 4px; + background-color: var(--wp--preset--color--primary, #000); + opacity: 0; + + &.start-animation { + animation: + wp-block-query__enhanced-pagination-start-animation + 30s + cubic-bezier(0, 1, 0, 1) + infinite; + } + + &.finish-animation { + animation: + wp-block-query__enhanced-pagination-finish-animation + 300ms + ease-in; + } +} + +@keyframes wp-block-query__enhanced-pagination-start-animation { + 0% { + transform: scaleX(0); + transform-origin: 0% 0%; + opacity: 1; + } + 100% { + transform: scaleX(1); + transform-origin: 0% 0%; + opacity: 1; + } +} + +@keyframes wp-block-query__enhanced-pagination-finish-animation { + 0% { + opacity: 1; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.wp-block-query__enhanced-pagination-navigation-announce { + position: absolute; + clip: rect(0, 0, 0, 0); + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + border: 0; +} diff --git a/packages/block-library/src/query/utils.js b/packages/block-library/src/query/utils.js index 9bd88de020cc83..4e787be1d029f5 100644 --- a/packages/block-library/src/query/utils.js +++ b/packages/block-library/src/query/utils.js @@ -8,11 +8,6 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { decodeEntities } from '@wordpress/html-entities'; import { cloneBlock, store as blocksStore } from '@wordpress/blocks'; -/** - * Internal dependencies - */ -import { name as queryLoopName } from './block.json'; - /** @typedef {import('@wordpress/blocks').WPBlockVariation} WPBlockVariation */ /** @@ -175,7 +170,7 @@ export function useAllowedControls( attributes ) { return useSelect( ( select ) => select( blocksStore ).getActiveBlockVariation( - queryLoopName, + 'core/query', attributes )?.allowedControls, @@ -249,25 +244,29 @@ export function useBlockNameForPatterns( clientId, attributes ) { const activeVariationName = useSelect( ( select ) => select( blocksStore ).getActiveBlockVariation( - queryLoopName, + 'core/query', attributes )?.name, [ attributes ] ); - const blockName = `${ queryLoopName }/${ activeVariationName }`; - const activeVariationPatterns = useSelect( + const blockName = `core/query/${ activeVariationName }`; + const hasActiveVariationPatterns = useSelect( ( select ) => { if ( ! activeVariationName ) { - return; + return false; } const { getBlockRootClientId, getPatternsByBlockTypes } = select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); - return getPatternsByBlockTypes( blockName, rootClientId ); + const activePatterns = getPatternsByBlockTypes( + blockName, + rootClientId + ); + return activePatterns.length > 0; }, - [ clientId, activeVariationName ] + [ clientId, activeVariationName, blockName ] ); - return activeVariationPatterns?.length ? blockName : queryLoopName; + return hasActiveVariationPatterns ? blockName : 'core/query'; } /** @@ -300,10 +299,10 @@ export function useScopedBlockVariations( attributes ) { select( blocksStore ); return { activeVariationName: getActiveBlockVariation( - queryLoopName, + 'core/query', attributes )?.name, - blockVariations: getBlockVariations( queryLoopName, 'block' ), + blockVariations: getBlockVariations( 'core/query', 'block' ), }; }, [ attributes ] diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js new file mode 100644 index 00000000000000..cbd5573e05c6f9 --- /dev/null +++ b/packages/block-library/src/query/view.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { store, navigate, prefetch } from '@wordpress/interactivity'; + +const isValidLink = ( ref ) => + ref && + ref instanceof window.HTMLAnchorElement && + ref.href && + ( ! ref.target || ref.target === '_self' ) && + ref.origin === window.location.origin; + +const isValidEvent = ( event ) => + event.button === 0 && // left clicks only + ! event.metaKey && // open in new tab (mac) + ! event.ctrlKey && // open in new tab (windows) + ! event.altKey && // download + ! event.shiftKey && + ! event.defaultPrevented; + +store( { + selectors: { + core: { + query: { + startAnimation: ( { context } ) => + context.core.query.animation === 'start', + finishAnimation: ( { context } ) => + context.core.query.animation === 'finish', + }, + }, + }, + actions: { + core: { + query: { + navigate: async ( { event, ref, context, state } ) => { + if ( isValidLink( ref ) && isValidEvent( event ) ) { + event.preventDefault(); + + const id = ref.closest( '[data-wp-navigation-id]' ) + .dataset.wpNavigationId; + + // Don't announce the navigation immediately, wait 300 ms. + const timeout = setTimeout( () => { + context.core.query.message = + state.core.query.loadingText; + context.core.query.animation = 'start'; + }, 300 ); + + await navigate( ref.href ); + + // Dismiss loading message if it hasn't been added yet. + clearTimeout( timeout ); + + // 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 + context.core.query.message = + state.core.query.loadedText + + ( context.core.query.message === + state.core.query.loadedText + ? '\u00A0' + : '' ); + + context.core.query.animation = 'finish'; + + // Focus the first anchor of the Query block. + document + .querySelector( + `[data-wp-navigation-id=${ id }] a[href]` + ) + ?.focus(); + } + }, + prefetch: async ( { ref } ) => { + if ( isValidLink( ref ) ) { + await prefetch( ref.href ); + } + }, + }, + }, + }, +} ); diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json index 485774ceb0a9cf..eff4649230a580 100644 --- a/packages/block-library/src/quote/block.json +++ b/packages/block-library/src/quote/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/quote", "title": "Quote", "category": "text", @@ -31,6 +31,7 @@ "anchor": true, "html": false, "__experimentalOnEnter": true, + "__experimentalOnMerge": true, "typography": { "fontSize": true, "lineHeight": true, @@ -47,6 +48,7 @@ }, "color": { "gradients": true, + "heading": true, "link": true, "__experimentalDefaultControls": { "background": true, diff --git a/packages/block-library/src/quote/edit.js b/packages/block-library/src/quote/edit.js index e6c560078e5010..32c99bf7d097db 100644 --- a/packages/block-library/src/quote/edit.js +++ b/packages/block-library/src/quote/edit.js @@ -93,6 +93,7 @@ export default function QuoteEdit( { const innerBlocksProps = useInnerBlocksProps( blockProps, { template: TEMPLATE, templateInsertUpdatesSelection: true, + __experimentalCaptureToolbars: true, } ); return ( diff --git a/packages/block-library/src/quote/test/__snapshots__/transforms.native.js.snap b/packages/block-library/src/quote/test/__snapshots__/transforms.native.js.snap index 5b5df918f2beeb..65d87d5b0d7bd4 100644 --- a/packages/block-library/src/quote/test/__snapshots__/transforms.native.js.snap +++ b/packages/block-library/src/quote/test/__snapshots__/transforms.native.js.snap @@ -22,6 +22,16 @@ exports[`Quote block transforms to Group block 1`] = ` <!-- /wp:group -->" `; +exports[`Quote block transforms to Paragraph block 1`] = ` +"<!-- wp:paragraph --> +<p>"This will make running your own blog a viable alternative again."</p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p>— <a href="https://twitter.com/azumbrunnen_/status/1019347243084800005">Adrian Zumbrunnen</a></p> +<!-- /wp:paragraph -->" +`; + exports[`Quote block transforms to Pullquote block 1`] = ` "<!-- wp:pullquote --> <figure class="wp-block-pullquote"><blockquote><p>"This will make running your own blog a viable alternative again."</p><cite>— <a href="https://twitter.com/azumbrunnen_/status/1019347243084800005">Adrian Zumbrunnen</a></cite></blockquote></figure> diff --git a/packages/block-library/src/quote/test/edit.native.js b/packages/block-library/src/quote/test/edit.native.js index 388ef1b8d15f77..d2f2aad6669f98 100644 --- a/packages/block-library/src/quote/test/edit.native.js +++ b/packages/block-library/src/quote/test/edit.native.js @@ -5,6 +5,7 @@ import { addBlock, getBlock, initializeEditor, + selectRangeInRichText, setupCoreBlocks, getEditorHtml, fireEvent, @@ -62,10 +63,13 @@ describe( 'Quote', () => { typeInRichText( quoteTextInput, 'Again.' ); const citationTextInput = within( citationBlock ).getByPlaceholderText( 'Add citation' ); - typeInRichText( citationTextInput, 'A person', { - finalSelectionStart: 2, - finalSelectionEnd: 2, + typeInRichText( citationTextInput, 'A person' ); + fireEvent( citationTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: ENTER, } ); + selectRangeInRichText( citationTextInput, 2 ); fireEvent( citationTextInput, 'onKeyDown', { nativeEvent: {}, preventDefault() {}, @@ -82,7 +86,11 @@ describe( 'Quote', () => { <!-- wp:paragraph --> <p>Again.</p> <!-- /wp:paragraph --><cite>A <br>person</cite></blockquote> - <!-- /wp:quote -->" + <!-- /wp:quote --> + + <!-- wp:paragraph --> + <p></p> + <!-- /wp:paragraph -->" ` ); } ); } ); diff --git a/packages/block-library/src/quote/test/transforms.native.js b/packages/block-library/src/quote/test/transforms.native.js index 46c4eb2b6f9727..25030e0a018d41 100644 --- a/packages/block-library/src/quote/test/transforms.native.js +++ b/packages/block-library/src/quote/test/transforms.native.js @@ -21,7 +21,11 @@ const initialHtml = ` <!-- /wp:quote -->`; const transformsWithInnerBlocks = [ 'Columns', 'Group' ]; -const blockTransforms = [ 'Pullquote', ...transformsWithInnerBlocks ]; +const blockTransforms = [ + 'Pullquote', + 'Paragraph', + ...transformsWithInnerBlocks, +]; setupCoreBlocks(); diff --git a/packages/block-library/src/quote/transforms.js b/packages/block-library/src/quote/transforms.js index d4cd77177bf030..4e153a6399029f 100644 --- a/packages/block-library/src/quote/transforms.js +++ b/packages/block-library/src/quote/transforms.js @@ -109,6 +109,19 @@ const transforms = { } ); }, }, + { + type: 'block', + blocks: [ 'core/paragraph' ], + transform: ( { citation }, innerBlocks ) => + citation + ? [ + ...innerBlocks, + createBlock( 'core/paragraph', { + content: citation, + } ), + ] + : innerBlocks, + }, { type: 'block', blocks: [ 'core/group' ], diff --git a/packages/block-library/src/read-more/block.json b/packages/block-library/src/read-more/block.json index ed2b23c3b7f0fd..d3386a49d66b82 100644 --- a/packages/block-library/src/read-more/block.json +++ b/packages/block-library/src/read-more/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/read-more", "title": "Read More", "category": "theme", @@ -17,7 +17,6 @@ }, "usesContext": [ "postId" ], "supports": { - "anchor": true, "html": false, "color": { "gradients": true, diff --git a/packages/block-library/src/rss/block.json b/packages/block-library/src/rss/block.json index 2e3fd4b2d385e1..2535eda5946fbd 100644 --- a/packages/block-library/src/rss/block.json +++ b/packages/block-library/src/rss/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/rss", "title": "RSS", "category": "widgets", @@ -43,7 +43,6 @@ }, "supports": { "align": true, - "anchor": true, "html": false }, "editorStyle": "wp-block-rss-editor", diff --git a/packages/block-library/src/rss/edit.js b/packages/block-library/src/rss/edit.js index 2fd94cdd2a021a..d24de5c291d511 100644 --- a/packages/block-library/src/rss/edit.js +++ b/packages/block-library/src/rss/edit.js @@ -116,6 +116,7 @@ export default function RSSEdit( { attributes, setAttributes } ) { <PanelBody title={ __( 'Settings' ) }> <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Number of items' ) } value={ itemsToShow } onChange={ ( value ) => @@ -146,6 +147,7 @@ export default function RSSEdit( { attributes, setAttributes } ) { { displayExcerpt && ( <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Max number of words in excerpt' ) } value={ excerptLength } onChange={ ( value ) => @@ -159,6 +161,7 @@ export default function RSSEdit( { attributes, setAttributes } ) { { blockLayout === 'grid' && ( <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Columns' ) } value={ columns } onChange={ ( value ) => diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json index 387295ebb36dea..b2873bfa8e5729 100644 --- a/packages/block-library/src/search/block.json +++ b/packages/block-library/src/search/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/search", "title": "Search", "category": "widgets", @@ -42,11 +42,18 @@ "query": { "type": "object", "default": {} + }, + "buttonBehavior": { + "type": "string", + "default": "expand-searchfield" + }, + "isSearchFieldHidden": { + "type": "boolean", + "default": false } }, "supports": { "align": [ "left", "center", "right" ], - "anchor": true, "color": { "gradients": true, "__experimentalSkipSerialization": true, @@ -83,6 +90,7 @@ }, "html": false }, + "viewScript": "file:./view.min.js", "editorStyle": "wp-block-search-editor", "style": "wp-block-search" } diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index 5d82ced145926f..ff957b575c7a4f 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -19,7 +19,7 @@ import { useSetting, } from '@wordpress/block-editor'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; import { ToolbarDropdownMenu, ToolbarGroup, @@ -52,13 +52,15 @@ import { PC_WIDTH_DEFAULT, PX_WIDTH_DEFAULT, MIN_WIDTH, - MIN_WIDTH_UNIT, + isPercentageUnit, } from './utils.js'; // Used to calculate border radius adjustment to avoid "fat" corners when // button is placed inside wrapper. const DEFAULT_INNER_PADDING = '4px'; +const BUTTON_BEHAVIOR_EXPAND = 'expand-searchfield'; + export default function SearchEdit( { className, attributes, @@ -77,6 +79,8 @@ export default function SearchEdit( { buttonText, buttonPosition, buttonUseIcon, + buttonBehavior, + isSearchFieldHidden, style, } = attributes; @@ -93,8 +97,8 @@ export default function SearchEdit( { ); const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( blockEditorStore ); - useEffect( () => { - if ( ! insertedInNavigationBlock ) return; + + if ( insertedInNavigationBlock ) { // This side-effect should not create an undo level. __unstableMarkNextChangeAsNotPersistent(); setAttributes( { @@ -102,7 +106,8 @@ export default function SearchEdit( { buttonUseIcon: true, buttonPosition: 'button-inside', } ); - }, [ insertedInNavigationBlock ] ); + } + const borderRadius = style?.border?.radius; const borderProps = useBorderProps( attributes ); @@ -130,12 +135,33 @@ export default function SearchEdit( { const isButtonPositionOutside = 'button-outside' === buttonPosition; const hasNoButton = 'no-button' === buttonPosition; const hasOnlyButton = 'button-only' === buttonPosition; + const searchFieldRef = useRef(); + const buttonRef = useRef(); const units = useCustomUnits( { availableUnits: [ '%', 'px' ], defaultValues: { '%': PC_WIDTH_DEFAULT, px: PX_WIDTH_DEFAULT }, } ); + useEffect( () => { + if ( hasOnlyButton && ! isSelected ) { + setAttributes( { + isSearchFieldHidden: true, + } ); + } + }, [ hasOnlyButton, isSelected, setAttributes ] ); + + // Show the search field when width changes. + useEffect( () => { + if ( ! hasOnlyButton || ! isSelected ) { + return; + } + + setAttributes( { + isSearchFieldHidden: false, + } ); + }, [ hasOnlyButton, isSelected, setAttributes, width ] ); + const getBlockClassNames = () => { return classnames( className, @@ -152,6 +178,12 @@ export default function SearchEdit( { : undefined, buttonUseIcon && ! hasNoButton ? 'wp-block-search__icon-button' + : undefined, + hasOnlyButton && BUTTON_BEHAVIOR_EXPAND === buttonBehavior + ? 'wp-block-search__button-behavior-expand' + : undefined, + hasOnlyButton && isSearchFieldHidden + ? 'wp-block-search__searchfield-hidden' : undefined ); }; @@ -165,6 +197,7 @@ export default function SearchEdit( { onClick: () => { setAttributes( { buttonPosition: 'button-outside', + isSearchFieldHidden: false, } ); }, }, @@ -176,6 +209,7 @@ export default function SearchEdit( { onClick: () => { setAttributes( { buttonPosition: 'button-inside', + isSearchFieldHidden: false, } ); }, }, @@ -187,6 +221,19 @@ export default function SearchEdit( { onClick: () => { setAttributes( { buttonPosition: 'no-button', + isSearchFieldHidden: false, + } ); + }, + }, + { + role: 'menuitemradio', + title: __( 'Button only' ), + isActive: buttonPosition === 'button-only', + icon: buttonOnly, + onClick: () => { + setAttributes( { + buttonPosition: 'button-only', + isSearchFieldHidden: true, } ); }, }, @@ -247,6 +294,7 @@ export default function SearchEdit( { onChange={ ( event ) => setAttributes( { placeholder: event.target.value } ) } + ref={ searchFieldRef } /> ); }; @@ -268,6 +316,13 @@ export default function SearchEdit( { ? { borderRadius } : borderProps.style ), }; + const handleButtonClick = () => { + if ( hasOnlyButton && BUTTON_BEHAVIOR_EXPAND === buttonBehavior ) { + setAttributes( { + isSearchFieldHidden: ! isSearchFieldHidden, + } ); + } + }; return ( <> @@ -281,6 +336,8 @@ export default function SearchEdit( { ? stripHTML( buttonText ) : __( 'Search' ) } + onClick={ handleButtonClick } + ref={ buttonRef } > <Icon icon={ search } /> </button> @@ -297,6 +354,7 @@ export default function SearchEdit( { onChange={ ( html ) => setAttributes( { buttonText: html } ) } + onClick={ handleButtonClick } /> ) } </> @@ -347,7 +405,13 @@ export default function SearchEdit( { > <UnitControl id={ unitControlInputId } - min={ `${ MIN_WIDTH }${ MIN_WIDTH_UNIT }` } + min={ + isPercentageUnit( widthUnit ) ? 0 : MIN_WIDTH + } + max={ + isPercentageUnit( widthUnit ) ? 100 : undefined + } + step={ 1 } onChange={ ( newWidth ) => { const filteredWidth = widthUnit === '%' && @@ -383,8 +447,8 @@ export default function SearchEdit( { key={ widthValue } isSmall variant={ - `${ widthValue }%` === - `${ width }${ widthUnit }` + widthValue === width && + widthUnit === '%' ? 'primary' : undefined } @@ -516,14 +580,15 @@ export default function SearchEdit( { } } showHandle={ isSelected } > - { ( isButtonPositionInside || isButtonPositionOutside ) && ( + { ( isButtonPositionInside || + isButtonPositionOutside || + hasOnlyButton ) && ( <> { renderTextField() } { renderButton() } </> ) } - { hasOnlyButton && renderButton() } { hasNoButton && renderTextField() } </ResizableBox> </div> diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index f42622551fc562..670ceb0eb66c54 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -8,11 +8,15 @@ /** * Dynamically renders the `core/search` block. * - * @param array $attributes The block attributes. + * @since 6.3.0 Using block.json `viewScript` to register script, and update `view_script_handles()` only when needed. + * + * @param array $attributes The block attributes. + * @param string $content The saved content. + * @param WP_Block $block The parsed block. * * @return string The search block markup. */ -function render_block_core_search( $attributes ) { +function render_block_core_search( $attributes, $content, $block ) { // Older versions of the Search block defaulted the label and buttonText // attributes to `__( 'Search' )` meaning that many posts contain `<!-- // wp:search /-->`. Support these by defaulting an undefined label and @@ -29,11 +33,11 @@ function render_block_core_search( $attributes ) { $classnames = classnames_for_block_core_search( $attributes ); $show_label = ( ! empty( $attributes['showLabel'] ) ) ? true : false; $use_icon_button = ( ! empty( $attributes['buttonUseIcon'] ) ) ? true : false; - $show_input = ( ! empty( $attributes['buttonPosition'] ) && 'button-only' === $attributes['buttonPosition'] ) ? false : true; $show_button = ( ! empty( $attributes['buttonPosition'] ) && 'no-button' === $attributes['buttonPosition'] ) ? false : true; + $button_position = $show_button ? $attributes['buttonPosition'] : null; $query_params = ( ! empty( $attributes['query'] ) ) ? $attributes['query'] : array(); - $input_markup = ''; - $button_markup = ''; + $button_behavior = ( ! empty( $attributes['buttonBehavior'] ) ) ? $attributes['buttonBehavior'] : 'default'; + $button = ''; $query_params_markup = ''; $inline_styles = styles_for_block_core_search( $attributes ); $color_classes = get_color_classes_for_block_core_search( $attributes ); @@ -44,42 +48,53 @@ function render_block_core_search( $attributes ) { $border_color_classes = get_border_color_classes_for_block_core_search( $attributes ); $label_inner_html = empty( $attributes['label'] ) ? __( 'Search' ) : wp_kses_post( $attributes['label'] ); - - $label_markup = sprintf( - '<label for="%1$s" class="wp-block-search__label screen-reader-text">%2$s</label>', - esc_attr( $input_id ), - $label_inner_html - ); - if ( $show_label && ! empty( $attributes['label'] ) ) { - $label_classes = array( 'wp-block-search__label' ); - if ( ! empty( $typography_classes ) ) { - $label_classes[] = $typography_classes; + $label = new WP_HTML_Tag_Processor( sprintf( '<label %1$s>%2$s</label>', $inline_styles['label'], $label_inner_html ) ); + if ( $label->next_tag() ) { + $label->set_attribute( 'for', $input_id ); + $label->add_class( 'wp-block-search__label' ); + if ( $show_label && ! empty( $attributes['label'] ) ) { + if ( ! empty( $typography_classes ) ) { + $label->add_class( $typography_classes ); + } + } else { + $label->add_class( 'screen-reader-text' ); } - $label_markup = sprintf( - '<label for="%1$s" class="%2$s" %3$s>%4$s</label>', - esc_attr( $input_id ), - esc_attr( implode( ' ', $label_classes ) ), - $inline_styles['label'], - $label_inner_html - ); } - if ( $show_input ) { - $input_classes = array( 'wp-block-search__input' ); - if ( ! $is_button_inside && ! empty( $border_color_classes ) ) { - $input_classes[] = $border_color_classes; + $input = new WP_HTML_Tag_Processor( sprintf( '<input type="search" name="s" required %s/>', $inline_styles['input'] ) ); + $input_classes = array( 'wp-block-search__input' ); + if ( ! $is_button_inside && ! empty( $border_color_classes ) ) { + $input_classes[] = $border_color_classes; + } + if ( ! empty( $typography_classes ) ) { + $input_classes[] = $typography_classes; + } + if ( $input->next_tag() ) { + $input->add_class( implode( ' ', $input_classes ) ); + $input->set_attribute( 'id', $input_id ); + $input->set_attribute( 'value', get_search_query() ); + $input->set_attribute( 'placeholder', $attributes['placeholder'] ); + + $is_expandable_searchfield = 'button-only' === $button_position && 'expand-searchfield' === $button_behavior; + if ( $is_expandable_searchfield ) { + $input->set_attribute( 'aria-hidden', 'true' ); + $input->set_attribute( 'tabindex', '-1' ); } - if ( ! empty( $typography_classes ) ) { - $input_classes[] = $typography_classes; + + // If the script already exists, there is no point in removing it from viewScript. + $view_js_file = 'wp-block-search-view'; + if ( ! wp_script_is( $view_js_file ) ) { + $script_handles = $block->block_type->view_script_handles; + + // If the script is not needed, and it is still in the `view_script_handles`, remove it. + if ( ! $is_expandable_searchfield && in_array( $view_js_file, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file ) ); + } + // If the script is needed, but it was previously removed, add it again. + if ( $is_expandable_searchfield && ! in_array( $view_js_file, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file ) ); + } } - $input_markup = sprintf( - '<input type="search" id="%s" class="%s" name="s" value="%s" placeholder="%s" %s required />', - $input_id, - esc_attr( implode( ' ', $input_classes ) ), - get_search_query(), - esc_attr( $attributes['placeholder'] ), - $inline_styles['input'] - ); } if ( count( $query_params ) > 0 ) { @@ -101,7 +116,6 @@ function render_block_core_search( $attributes ) { if ( ! empty( $typography_classes ) ) { $button_classes[] = $typography_classes; } - $aria_label = ''; if ( ! $is_button_inside && ! empty( $border_color_classes ) ) { $button_classes[] = $border_color_classes; @@ -111,9 +125,7 @@ function render_block_core_search( $attributes ) { $button_internal_markup = wp_kses_post( $attributes['buttonText'] ); } } else { - $aria_label = sprintf( 'aria-label="%s"', esc_attr( wp_strip_all_tags( $attributes['buttonText'] ) ) ); - $button_classes[] = 'has-icon'; - + $button_classes[] = 'has-icon'; $button_internal_markup = '<svg class="search-icon" viewBox="0 0 24 24" width="24" height="24"> <path d="M13 5c-3.3 0-6 2.7-6 6 0 1.4.5 2.7 1.3 3.7l-3.8 3.8 1.1 1.1 3.8-3.8c1 .8 2.3 1.3 3.7 1.3 3.3 0 6-2.7 6-6S16.3 5 13 5zm0 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"></path> @@ -122,13 +134,20 @@ function render_block_core_search( $attributes ) { // Include the button element class. $button_classes[] = wp_theme_get_element_class_name( 'button' ); - $button_markup = sprintf( - '<button type="submit" class="%s" %s %s>%s</button>', - esc_attr( implode( ' ', $button_classes ) ), - $inline_styles['button'], - $aria_label, - $button_internal_markup - ); + $button = new WP_HTML_Tag_Processor( sprintf( '<button type="submit" %s>%s</button>', $inline_styles['button'], $button_internal_markup ) ); + + if ( $button->next_tag() ) { + $button->add_class( implode( ' ', $button_classes ) ); + if ( 'expand-searchfield' === $attributes['buttonBehavior'] && 'button-only' === $attributes['buttonPosition'] ) { + $button->set_attribute( 'aria-label', __( 'Expand search field' ) ); + $button->set_attribute( 'data-toggled-aria-label', __( 'Submit Search' ) ); + $button->set_attribute( 'aria-controls', 'wp-block-search__input-' . $input_id ); + $button->set_attribute( 'aria-expanded', 'false' ); + $button->set_attribute( 'type', 'button' ); // Will be set to submit after clicking. + } else { + $button->set_attribute( 'aria-label', wp_strip_all_tags( $attributes['buttonText'] ) ); + } + } } $field_markup_classes = $is_button_inside ? $border_color_classes : ''; @@ -136,7 +155,7 @@ function render_block_core_search( $attributes ) { '<div class="wp-block-search__inside-wrapper %s" %s>%s</div>', esc_attr( $field_markup_classes ), $inline_styles['wrapper'], - $input_markup . $query_params_markup . $button_markup + $input . $query_params_markup . $button ); $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $classnames ) @@ -146,7 +165,7 @@ function render_block_core_search( $attributes ) { '<form role="search" method="get" action="%s" %s>%s</form>', esc_url( home_url( '/' ) ), $wrapper_attributes, - $label_markup . $field_markup + $label . $field_markup ); } @@ -188,6 +207,9 @@ function classnames_for_block_core_search( $attributes ) { if ( 'button-only' === $attributes['buttonPosition'] ) { $classnames[] = 'wp-block-search__button-only'; + if ( ! empty( $attributes['buttonBehavior'] ) && 'expand-searchfield' === $attributes['buttonBehavior'] ) { + $classnames[] = 'wp-block-search__button-behavior-expand wp-block-search__searchfield-hidden'; + } } } @@ -294,10 +316,9 @@ function styles_for_block_core_search( $attributes ) { $show_label = ( isset( $attributes['showLabel'] ) ) && false !== $attributes['showLabel']; // Add width styles. - $has_width = ! empty( $attributes['width'] ) && ! empty( $attributes['widthUnit'] ); - $button_only = ! empty( $attributes['buttonPosition'] ) && 'button-only' === $attributes['buttonPosition']; + $has_width = ! empty( $attributes['width'] ) && ! empty( $attributes['widthUnit'] ); - if ( $has_width && ! $button_only ) { + if ( $has_width ) { $wrapper_styles[] = sprintf( 'width: %d%s;', esc_attr( $attributes['width'] ), diff --git a/packages/block-library/src/search/style.scss b/packages/block-library/src/search/style.scss index 6f6c550dacbc15..967b2282ba6d57 100644 --- a/packages/block-library/src/search/style.scss +++ b/packages/block-library/src/search/style.scss @@ -53,6 +53,10 @@ $button-spacing-y: math.div($grid-unit-15, 2); // 6px .wp-block-search.wp-block-search__button-only { .wp-block-search__button { margin-left: 0; + // Prevent unintended text wrapping. + flex-shrink: 0; + // Ensure minimum input field width in small viewports. + max-width: calc(100% - 100px); } } @@ -81,3 +85,42 @@ $button-spacing-y: math.div($grid-unit-15, 2); // 6px .wp-block-search.aligncenter .wp-block-search__inside-wrapper { margin: auto; } + +.wp-block-search__button-behavior-expand { + .wp-block-search__inside-wrapper { + transition-property: width; + min-width: 0 !important; + } + + .wp-block-search__input { + transition-duration: 300ms; + flex-basis: 100%; + } + + // !important here to override inline styles on button only deselected view. + &.wp-block-search__searchfield-hidden { + overflow: hidden; + + .wp-block-search__inside-wrapper { + overflow: hidden; + } + + .wp-block-search__input { + width: 0 !important; + min-width: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + border-left-width: 0 !important; + border-right-width: 0 !important; + flex-grow: 0; + margin: 0; + flex-basis: 0; + } + } +} + +.wp-block[data-align="right"] .wp-block-search__button-behavior-expand { + .wp-block-search__inside-wrapper { + float: right; + } +} diff --git a/packages/block-library/src/search/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/search/test/__snapshots__/edit.native.js.snap index 47652cd448c328..dd0c7aa1694f4d 100644 --- a/packages/block-library/src/search/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/search/test/__snapshots__/edit.native.js.snap @@ -19,57 +19,81 @@ exports[`Search Block renders block with button inside option 1`] = ` ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ { - "tag": "p", + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, } } - disableEditingMenu={false} + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="Add label…" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": undefined, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="Add label…" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": undefined, + "minHeight": 0, + } } - } - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search</p>", + } + } + triggerKeyCodes={[]} + /> + </View> </View> </View> <View @@ -134,59 +158,83 @@ exports[`Search Block renders block with button inside option 1`] = ` ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ { - "tag": "p", + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, } } - disableEditingMenu={false} + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - minWidth={75} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": NaN, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search Button</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + minWidth={75} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": NaN, + "minHeight": 0, + } } - } - textAlign="center" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search Button</p>", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> + </View> </View> </View> </View> @@ -213,57 +261,81 @@ exports[`Search Block renders block with icon button option matches snapshot 1`] ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ { - "tag": "p", + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, } } - disableEditingMenu={false} + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="Add label…" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": undefined, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="Add label…" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": undefined, + "minHeight": 0, + } } - } - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search</p>", + } + } + triggerKeyCodes={[]} + /> + </View> </View> </View> <View @@ -398,59 +470,83 @@ exports[`Search Block renders block with label hidden matches snapshot 1`] = ` ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ { - "tag": "p", + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, } } - disableEditingMenu={false} + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - minWidth={75} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": NaN, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search Button</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + minWidth={75} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": NaN, + "minHeight": 0, + } } - } - textAlign="center" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search Button</p>", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> + </View> </View> </View> </View> @@ -477,57 +573,81 @@ exports[`Search Block renders with default configuration matches snapshot 1`] = ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ { - "tag": "p", + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, } } - disableEditingMenu={false} + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="Add label…" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": undefined, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="Add label…" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": undefined, + "minHeight": 0, + } } - } - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search</p>", + } + } + triggerKeyCodes={[]} + /> + </View> </View> </View> <View @@ -592,59 +712,83 @@ exports[`Search Block renders with default configuration matches snapshot 1`] = ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ { - "tag": "p", + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, } } - disableEditingMenu={false} + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - minWidth={75} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": NaN, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search Button</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + minWidth={75} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": NaN, + "minHeight": 0, + } } - } - textAlign="center" - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search Button</p>", + } + } + textAlign="center" + triggerKeyCodes={[]} + /> + </View> </View> </View> </View> @@ -671,57 +815,81 @@ exports[`Search Block renders with no-button option matches snapshot 1`] = ` ] } > - <RCTAztecView - accessible={true} - activeFormats={[]} - blockType={ + <View + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ { - "tag": "p", + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, } } - disableEditingMenu={false} + accessible={false} + collapsable={false} focusable={true} - fontFamily="serif" - fontSize={16} - isMultiline={false} - maxImagesWidth={200} - onBackspace={[Function]} onBlur={[Function]} - onChange={[Function]} onClick={[Function]} - onContentSizeChange={[Function]} - onEnter={[Function]} onFocus={[Function]} - onHTMLContentWithCursor={[Function]} - onKeyDown={[Function]} - onPaste={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} - onSelectionChange={[Function]} onStartShouldSetResponder={[Function]} - placeholder="Add label…" - placeholderTextColor="gray" - style={ - { - "backgroundColor": undefined, - "maxWidth": undefined, - "minHeight": 0, + > + <RCTAztecView + activeFormats={[]} + blockType={ + { + "tag": "p", + } } - } - text={ - { - "eventCount": undefined, - "linkTextColor": undefined, - "selection": null, - "tag": "p", - "text": "<p>Search</p>", + disableEditingMenu={false} + fontFamily="serif" + fontSize={16} + isMultiline={false} + maxImagesWidth={200} + onBackspace={[Function]} + onBlur={[Function]} + onChange={[Function]} + onContentSizeChange={[Function]} + onEnter={[Function]} + onFocus={[Function]} + onHTMLContentWithCursor={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelectionChange={[Function]} + placeholder="Add label…" + placeholderTextColor="gray" + selectionColor="black" + style={ + { + "backgroundColor": undefined, + "maxWidth": undefined, + "minHeight": 0, + } } - } - triggerKeyCodes={[]} - /> + text={ + { + "eventCount": undefined, + "linkTextColor": undefined, + "selection": null, + "tag": "p", + "text": "<p>Search</p>", + } + } + triggerKeyCodes={[]} + /> + </View> </View> </View> <View diff --git a/packages/block-library/src/search/utils.js b/packages/block-library/src/search/utils.js index b54048b609eb33..8438dd7148a013 100644 --- a/packages/block-library/src/search/utils.js +++ b/packages/block-library/src/search/utils.js @@ -4,7 +4,6 @@ export const PC_WIDTH_DEFAULT = 50; export const PX_WIDTH_DEFAULT = 350; export const MIN_WIDTH = 220; -export const MIN_WIDTH_UNIT = 'px'; /** * Returns a boolean whether passed unit is percentage diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js new file mode 100644 index 00000000000000..5aaf1dd1ef3add --- /dev/null +++ b/packages/block-library/src/search/view.js @@ -0,0 +1,172 @@ +/*eslint-env browser*/ + +/** @type {?HTMLFormElement} */ +let expandedSearchBlock = null; + +const hiddenClass = 'wp-block-search__searchfield-hidden'; + +/** + * Toggles aria-label with data-toggled-aria-label. + * + * @param {HTMLElement} element + */ +function toggleAriaLabel( element ) { + if ( ! ( 'toggledAriaLabel' in element.dataset ) ) { + throw new Error( 'Element lacks toggledAriaLabel in dataset.' ); + } + + const ariaLabel = element.dataset.toggledAriaLabel; + element.dataset.toggledAriaLabel = element.ariaLabel; + element.ariaLabel = ariaLabel; +} + +/** + * Gets search input. + * + * @param {HTMLFormElement} block Search block. + * @return {HTMLInputElement} Search input. + */ +function getSearchInput( block ) { + return block.querySelector( '.wp-block-search__input' ); +} + +/** + * Gets search button. + * + * @param {HTMLFormElement} block Search block. + * @return {HTMLButtonElement} Search button. + */ +function getSearchButton( block ) { + return block.querySelector( '.wp-block-search__button' ); +} + +/** + * Handles keydown event to collapse an expanded Search block (when pressing Escape key). + * + * @param {KeyboardEvent} event + */ +function handleKeydownEvent( event ) { + if ( ! expandedSearchBlock ) { + // In case the event listener wasn't removed in time. + return; + } + + if ( event.key === 'Escape' ) { + const block = expandedSearchBlock; // This is nullified by collapseExpandedSearchBlock(). + collapseExpandedSearchBlock(); + getSearchButton( block ).focus(); + } +} + +/** + * Handles keyup event to collapse an expanded Search block (e.g. when tabbing out of expanded Search block). + * + * @param {KeyboardEvent} event + */ +function handleKeyupEvent( event ) { + if ( ! expandedSearchBlock ) { + // In case the event listener wasn't removed in time. + return; + } + + if ( event.target.closest( '.wp-block-search' ) !== expandedSearchBlock ) { + collapseExpandedSearchBlock(); + } +} + +/** + * Expands search block. + * + * Inverse of what is done in collapseExpandedSearchBlock(). + * + * @param {HTMLFormElement} block Search block. + */ +function expandSearchBlock( block ) { + // Make sure only one is open at a time. + if ( expandedSearchBlock ) { + collapseExpandedSearchBlock(); + } + + const searchField = getSearchInput( block ); + const searchButton = getSearchButton( block ); + + searchButton.type = 'submit'; + searchField.ariaHidden = 'false'; + searchField.tabIndex = 0; + searchButton.ariaExpanded = 'true'; + searchButton.removeAttribute( 'aria-controls' ); // Note: Seemingly not reflected with searchButton.ariaControls. + toggleAriaLabel( searchButton ); + block.classList.remove( hiddenClass ); + + searchField.focus(); // Note that Chrome seems to do this automatically. + + // The following two must be inverse of what is done in collapseExpandedSearchBlock(). + document.addEventListener( 'keydown', handleKeydownEvent, { + passive: true, + } ); + document.addEventListener( 'keyup', handleKeyupEvent, { + passive: true, + } ); + + expandedSearchBlock = block; +} + +/** + * Collapses the expanded search block. + * + * Inverse of what is done in expandSearchBlock(). + */ +function collapseExpandedSearchBlock() { + if ( ! expandedSearchBlock ) { + throw new Error( 'Expected expandedSearchBlock to be defined.' ); + } + const block = expandedSearchBlock; + const searchField = getSearchInput( block ); + const searchButton = getSearchButton( block ); + + searchButton.type = 'button'; + searchField.ariaHidden = 'true'; + searchField.tabIndex = -1; + searchButton.ariaExpanded = 'false'; + searchButton.setAttribute( 'aria-controls', searchField.id ); // Note: Seemingly not reflected with searchButton.ariaControls. + toggleAriaLabel( searchButton ); + block.classList.add( hiddenClass ); + + // The following two must be inverse of what is done in expandSearchBlock(). + document.removeEventListener( 'keydown', handleKeydownEvent, { + passive: true, + } ); + document.removeEventListener( 'keyup', handleKeyupEvent, { + passive: true, + } ); + + expandedSearchBlock = null; +} + +// Listen for click events anywhere on the document so this script can be loaded asynchronously in the head. +document.addEventListener( + 'click', + ( event ) => { + // Get the ancestor expandable Search block of the clicked element. + const block = event.target.closest( + '.wp-block-search__button-behavior-expand' + ); + + /* + * If there is already an expanded search block and either the current click was not for a Search block or it was + * for another block, then collapse the currently-expanded block. + */ + if ( expandedSearchBlock && block !== expandedSearchBlock ) { + collapseExpandedSearchBlock(); + } + + // If the click was on or inside a collapsed Search block, expand it. + if ( + block instanceof HTMLFormElement && + block.classList.contains( hiddenClass ) + ) { + expandSearchBlock( block ); + } + }, + { passive: true } +); diff --git a/packages/block-library/src/separator/block.json b/packages/block-library/src/separator/block.json index bee358d208516d..970f6b5cbb5821 100644 --- a/packages/block-library/src/separator/block.json +++ b/packages/block-library/src/separator/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/separator", "title": "Separator", "category": "design", diff --git a/packages/block-library/src/shortcode/block.json b/packages/block-library/src/shortcode/block.json index 5b36d141dd93eb..22d838a7198e1e 100644 --- a/packages/block-library/src/shortcode/block.json +++ b/packages/block-library/src/shortcode/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/shortcode", "title": "Shortcode", "category": "widgets", @@ -9,7 +9,7 @@ "attributes": { "text": { "type": "string", - "source": "html" + "source": "raw" } }, "supports": { diff --git a/packages/block-library/src/site-logo/block.json b/packages/block-library/src/site-logo/block.json index 8eba39b91c7ad4..d1e3d1b20c3dad 100644 --- a/packages/block-library/src/site-logo/block.json +++ b/packages/block-library/src/site-logo/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/site-logo", "title": "Site Logo", "category": "theme", @@ -31,7 +31,6 @@ }, "supports": { "html": false, - "anchor": true, "align": true, "alignWide": false, "color": { @@ -41,7 +40,11 @@ }, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } } }, "styles": [ diff --git a/packages/block-library/src/site-logo/edit.js b/packages/block-library/src/site-logo/edit.js index 8fe86d6d4a6dec..cddfb5dfdc4fc4 100644 --- a/packages/block-library/src/site-logo/edit.js +++ b/packages/block-library/src/site-logo/edit.js @@ -293,6 +293,7 @@ const SiteLogo = ( { <PanelBody title={ __( 'Settings' ) }> <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Image width' ) } onChange={ ( newWidth ) => setAttributes( { width: newWidth } ) diff --git a/packages/block-library/src/site-logo/index.php b/packages/block-library/src/site-logo/index.php index f7dde9d7b05135..f1819fcaac8d03 100644 --- a/packages/block-library/src/site-logo/index.php +++ b/packages/block-library/src/site-logo/index.php @@ -13,7 +13,7 @@ * @return string The render. */ function render_block_core_site_logo( $attributes ) { - $adjust_width_height_filter = function ( $image ) use ( $attributes ) { + $adjust_width_height_filter = static function ( $image ) use ( $attributes ) { if ( empty( $attributes['width'] ) || empty( $image ) || ! $image[1] || ! $image[2] ) { return $image; } diff --git a/packages/block-library/src/site-logo/style.scss b/packages/block-library/src/site-logo/style.scss index 2fae735f369b57..ca9c92539b6760 100644 --- a/packages/block-library/src/site-logo/style.scss +++ b/packages/block-library/src/site-logo/style.scss @@ -5,6 +5,7 @@ a { display: inline-block; + line-height: 0; } // Provide a sane starting point for the size. diff --git a/packages/block-library/src/site-tagline/block.json b/packages/block-library/src/site-tagline/block.json index a11eab4fbc243b..22fb59aab5ead7 100644 --- a/packages/block-library/src/site-tagline/block.json +++ b/packages/block-library/src/site-tagline/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/site-tagline", "title": "Site Tagline", "category": "theme", @@ -14,7 +14,6 @@ }, "example": {}, "supports": { - "anchor": true, "align": [ "wide", "full" ], "html": false, "color": { @@ -26,7 +25,11 @@ }, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "typography": { "fontSize": true, diff --git a/packages/block-library/src/site-tagline/icon.js b/packages/block-library/src/site-tagline/icon.js index 9e708e94fafd79..908d7149aa112a 100644 --- a/packages/block-library/src/site-tagline/icon.js +++ b/packages/block-library/src/site-tagline/icon.js @@ -5,7 +5,6 @@ import { SVG, Path } from '@wordpress/components'; export default ( <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24"> - <Path fill="none" d="M0 0h24v24H0z" /> - <Path d="M4 9h16v2H4V9zm0 4h10v2H4v-2z" /> + <Path d="M4 10.5h16V9H4v1.5ZM4 15h9v-1.5H4V15Z" /> </SVG> ); diff --git a/packages/block-library/src/site-title/block.json b/packages/block-library/src/site-title/block.json index b69acda934fda2..e936bad0e45152 100644 --- a/packages/block-library/src/site-title/block.json +++ b/packages/block-library/src/site-title/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/site-title", "title": "Site Title", "category": "theme", @@ -27,7 +27,6 @@ "viewportWidth": 500 }, "supports": { - "anchor": true, "align": [ "wide", "full" ], "html": false, "color": { @@ -41,7 +40,11 @@ }, "spacing": { "padding": true, - "margin": true + "margin": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "typography": { "fontSize": true, diff --git a/packages/block-library/src/site-title/edit/index.js b/packages/block-library/src/site-title/edit/index.js index d3224176a7e3a6..ad3136d106557f 100644 --- a/packages/block-library/src/site-title/edit/index.js +++ b/packages/block-library/src/site-title/edit/index.js @@ -15,15 +15,13 @@ import { InspectorControls, BlockControls, useBlockProps, + HeadingLevelDropdown, } from '@wordpress/block-editor'; import { ToggleControl, PanelBody } from '@wordpress/components'; import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/html-entities'; -/** - * Internal dependencies - */ -import LevelControl from './level-toolbar'; +const HEADING_LEVELS = [ 0, 1, 2, 3, 4, 5, 6 ]; export default function SiteTitleEdit( { attributes, @@ -95,8 +93,9 @@ export default function SiteTitleEdit( { return ( <> <BlockControls group="block"> - <LevelControl - level={ level } + <HeadingLevelDropdown + options={ HEADING_LEVELS } + value={ level } onChange={ ( newLevel ) => setAttributes( { level: newLevel } ) } diff --git a/packages/block-library/src/site-title/edit/level-icon.js b/packages/block-library/src/site-title/edit/level-icon.js deleted file mode 100644 index 95295208b6e599..00000000000000 --- a/packages/block-library/src/site-title/edit/level-icon.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * WordPress dependencies - */ -import { SVG, Path } from '@wordpress/components'; -import { paragraph } from '@wordpress/icons'; - -export default function LevelIcon( { level, isPressed = false } ) { - if ( level === 0 ) return paragraph; - const levelToPath = { - 1: 'M9 5h2v10H9v-4H5v4H3V5h2v4h4V5zm6.6 0c-.6.9-1.5 1.7-2.6 2v1h2v7h2V5h-1.4z', - 2: 'M7 5h2v10H7v-4H3v4H1V5h2v4h4V5zm8 8c.5-.4.6-.6 1.1-1.1.4-.4.8-.8 1.2-1.3.3-.4.6-.8.9-1.3.2-.4.3-.8.3-1.3 0-.4-.1-.9-.3-1.3-.2-.4-.4-.7-.8-1-.3-.3-.7-.5-1.2-.6-.5-.2-1-.2-1.5-.2-.4 0-.7 0-1.1.1-.3.1-.7.2-1 .3-.3.1-.6.3-.9.5-.3.2-.6.4-.8.7l1.2 1.2c.3-.3.6-.5 1-.7.4-.2.7-.3 1.2-.3s.9.1 1.3.4c.3.3.5.7.5 1.1 0 .4-.1.8-.4 1.1-.3.5-.6.9-1 1.2-.4.4-1 .9-1.6 1.4-.6.5-1.4 1.1-2.2 1.6V15h8v-2H15z', - 3: 'M12.1 12.2c.4.3.8.5 1.2.7.4.2.9.3 1.4.3.5 0 1-.1 1.4-.3.3-.1.5-.5.5-.8 0-.2 0-.4-.1-.6-.1-.2-.3-.3-.5-.4-.3-.1-.7-.2-1-.3-.5-.1-1-.1-1.5-.1V9.1c.7.1 1.5-.1 2.2-.4.4-.2.6-.5.6-.9 0-.3-.1-.6-.4-.8-.3-.2-.7-.3-1.1-.3-.4 0-.8.1-1.1.3-.4.2-.7.4-1.1.6l-1.2-1.4c.5-.4 1.1-.7 1.6-.9.5-.2 1.2-.3 1.8-.3.5 0 1 .1 1.6.2.4.1.8.3 1.2.5.3.2.6.5.8.8.2.3.3.7.3 1.1 0 .5-.2.9-.5 1.3-.4.4-.9.7-1.5.9v.1c.6.1 1.2.4 1.6.8.4.4.7.9.7 1.5 0 .4-.1.8-.3 1.2-.2.4-.5.7-.9.9-.4.3-.9.4-1.3.5-.5.1-1 .2-1.6.2-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1l1.1-1.4zM7 9H3V5H1v10h2v-4h4v4h2V5H7v4z', - 4: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm10-2h-1v2h-2v-2h-5v-2l4-6h3v6h1v2zm-3-2V7l-2.8 4H16z', - 5: 'M12.1 12.2c.4.3.7.5 1.1.7.4.2.9.3 1.3.3.5 0 1-.1 1.4-.4.4-.3.6-.7.6-1.1 0-.4-.2-.9-.6-1.1-.4-.3-.9-.4-1.4-.4H14c-.1 0-.3 0-.4.1l-.4.1-.5.2-1-.6.3-5h6.4v1.9h-4.3L14 8.8c.2-.1.5-.1.7-.2.2 0 .5-.1.7-.1.5 0 .9.1 1.4.2.4.1.8.3 1.1.6.3.2.6.6.8.9.2.4.3.9.3 1.4 0 .5-.1 1-.3 1.4-.2.4-.5.8-.9 1.1-.4.3-.8.5-1.3.7-.5.2-1 .3-1.5.3-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1-.1-.1 1-1.5 1-1.5zM9 15H7v-4H3v4H1V5h2v4h4V5h2v10z', - 6: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm8.6-7.5c-.2-.2-.5-.4-.8-.5-.6-.2-1.3-.2-1.9 0-.3.1-.6.3-.8.5l-.6.9c-.2.5-.2.9-.2 1.4.4-.3.8-.6 1.2-.8.4-.2.8-.3 1.3-.3.4 0 .8 0 1.2.2.4.1.7.3 1 .6.3.3.5.6.7.9.2.4.3.8.3 1.3s-.1.9-.3 1.4c-.2.4-.5.7-.8 1-.4.3-.8.5-1.2.6-1 .3-2 .3-3 0-.5-.2-1-.5-1.4-.9-.4-.4-.8-.9-1-1.5-.2-.6-.3-1.3-.3-2.1s.1-1.6.4-2.3c.2-.6.6-1.2 1-1.6.4-.4.9-.7 1.4-.9.6-.3 1.1-.4 1.7-.4.7 0 1.4.1 2 .3.5.2 1 .5 1.4.8 0 .1-1.3 1.4-1.3 1.4zm-2.4 5.8c.2 0 .4 0 .6-.1.2 0 .4-.1.5-.2.1-.1.3-.3.4-.5.1-.2.1-.5.1-.7 0-.4-.1-.8-.4-1.1-.3-.2-.7-.3-1.1-.3-.3 0-.7.1-1 .2-.4.2-.7.4-1 .7 0 .3.1.7.3 1 .1.2.3.4.4.6.2.1.3.3.5.3.2.1.5.2.7.1z', - }; - return ( - <SVG - width="20" - height="20" - viewBox="0 0 20 20" - xmlns="http://www.w3.org/2000/svg" - isPressed={ isPressed } - > - <Path d={ levelToPath[ level ] } /> - </SVG> - ); -} diff --git a/packages/block-library/src/site-title/edit/level-toolbar.js b/packages/block-library/src/site-title/edit/level-toolbar.js deleted file mode 100644 index 704c948d336731..00000000000000 --- a/packages/block-library/src/site-title/edit/level-toolbar.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * WordPress dependencies - */ -import { ToolbarDropdownMenu } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import LevelIcon from './level-icon'; - -export default function LevelControl( { level, onChange } ) { - const allControls = [ 1, 2, 3, 4, 5, 6, 0 ].map( ( currentLevel ) => { - const isActive = currentLevel === level; - return { - icon: <LevelIcon level={ currentLevel } isPressed={ isActive } />, - title: - currentLevel === 0 - ? __( 'Paragraph' ) - : // translators: %s: heading level e.g: "1", "2", "3" - sprintf( __( 'Heading %d' ), currentLevel ), - isActive, - onClick: () => onChange( currentLevel ), - role: 'menuitemradio', - }; - } ); - return ( - <ToolbarDropdownMenu - label={ __( 'Change heading level' ) } - icon={ <LevelIcon level={ level } /> } - controls={ allControls } - /> - ); -} diff --git a/packages/block-library/src/social-link/block.json b/packages/block-library/src/social-link/block.json index e81894591b4b33..50e95efedb630c 100644 --- a/packages/block-library/src/social-link/block.json +++ b/packages/block-library/src/social-link/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/social-link", "title": "Social Icon", "category": "widgets", @@ -24,11 +24,12 @@ "usesContext": [ "openInNewTab", "showLabels", + "iconColor", "iconColorValue", + "iconBackgroundColor", "iconBackgroundColorValue" ], "supports": { - "anchor": true, "reusable": false, "html": false }, diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js index 738d70e3bdd5ee..110bc397136992 100644 --- a/packages/block-library/src/social-link/edit.js +++ b/packages/block-library/src/social-link/edit.js @@ -92,10 +92,19 @@ const SocialLinkEdit = ( { clientId, } ) => { const { url, service, label, rel } = attributes; - const { showLabels, iconColorValue, iconBackgroundColorValue } = context; + const { + showLabels, + iconColor, + iconColorValue, + iconBackgroundColor, + iconBackgroundColorValue, + } = context; const [ showURLPopover, setPopover ] = useState( false ); const classes = classNames( 'wp-social-link', 'wp-social-link-' + service, { 'wp-social-link__is-incomplete': ! url, + [ `has-${ iconColor }-color` ]: iconColor, + [ `has-${ iconBackgroundColor }-background-color` ]: + iconBackgroundColor, } ); // Use internal state instead of a ref to make sure that the component diff --git a/packages/block-library/src/social-link/edit.native.js b/packages/block-library/src/social-link/edit.native.js index bc3d2e47db9619..cdfca2cb17abd5 100644 --- a/packages/block-library/src/social-link/edit.native.js +++ b/packages/block-library/src/social-link/edit.native.js @@ -119,6 +119,7 @@ const SocialLinkEdit = ( { toValue: 1, duration: ANIMATION_DURATION, easing: Easing.circle, + useNativeDriver: false, } ), ] ).start( () => setHasUrl( true ) ); } diff --git a/packages/block-library/src/social-link/icons/index.js b/packages/block-library/src/social-link/icons/index.js index 80b547c2d1710e..62c32d2d5f35c7 100644 --- a/packages/block-library/src/social-link/icons/index.js +++ b/packages/block-library/src/social-link/icons/index.js @@ -31,6 +31,7 @@ export * from './snapchat'; export * from './soundcloud'; export * from './spotify'; export * from './telegram'; +export * from './threads'; export * from './tiktok'; export * from './tumblr'; export * from './twitch'; diff --git a/packages/block-library/src/social-link/icons/threads.js b/packages/block-library/src/social-link/icons/threads.js new file mode 100644 index 00000000000000..12859743cb7032 --- /dev/null +++ b/packages/block-library/src/social-link/icons/threads.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/primitives'; + +export const ThreadsIcon = () => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M16.3 11.3c-.1 0-.2-.1-.2-.1-.1-2.6-1.5-4-3.9-4-1.4 0-2.6.6-3.3 1.7l1.3.9c.5-.8 1.4-1 2-1 .8 0 1.4.2 1.7.7.3.3.5.8.5 1.3-.7-.1-1.4-.2-2.2-.1-2.2.1-3.7 1.4-3.6 3.2 0 .9.5 1.7 1.3 2.2.7.4 1.5.6 2.4.6 1.2-.1 2.1-.5 2.7-1.3.5-.6.8-1.4.9-2.4.6.3 1 .8 1.2 1.3.4.9.4 2.4-.8 3.6-1.1 1.1-2.3 1.5-4.3 1.5-2.1 0-3.8-.7-4.8-2S5.7 14.3 5.7 12c0-2.3.5-4.1 1.5-5.4 1.1-1.3 2.7-2 4.8-2 2.2 0 3.8.7 4.9 2 .5.7.9 1.5 1.2 2.5l1.5-.4c-.3-1.2-.8-2.2-1.5-3.1-1.3-1.7-3.3-2.6-6-2.6-2.6 0-4.7.9-6 2.6C4.9 7.2 4.3 9.3 4.3 12s.6 4.8 1.9 6.4c1.4 1.7 3.4 2.6 6 2.6 2.3 0 4-.6 5.3-2 1.8-1.8 1.7-4 1.1-5.4-.4-.9-1.2-1.7-2.3-2.3zm-4 3.8c-1 .1-2-.4-2-1.3 0-.7.5-1.5 2.1-1.6h.5c.6 0 1.1.1 1.6.2-.2 2.3-1.3 2.7-2.2 2.7z" /> + </SVG> +); diff --git a/packages/block-library/src/social-link/index.php b/packages/block-library/src/social-link/index.php index 53af6a2e5f485a..1ce60ff49fb41e 100644 --- a/packages/block-library/src/social-link/index.php +++ b/packages/block-library/src/social-link/index.php @@ -47,7 +47,7 @@ function render_block_core_social_link( $attributes, $content, $block ) { $icon = block_core_social_link_get_icon( $service ); $wrapper_attributes = get_block_wrapper_attributes( array( - 'class' => 'wp-social-link wp-social-link-' . $service, + 'class' => 'wp-social-link wp-social-link-' . $service . block_core_social_link_get_color_classes( $block->context ), 'style' => block_core_social_link_get_color_styles( $block->context ), ) ); @@ -258,6 +258,10 @@ function block_core_social_link_services( $service = '', $field = '' ) { 'name' => 'Telegram', 'icon' => '<svg width="24" height="24" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M28.9700376,63.3244248 C47.6273373,55.1957357 60.0684594,49.8368063 66.2934036,47.2476366 C84.0668845,39.855031 87.7600616,38.5708563 90.1672227,38.528 C90.6966555,38.5191258 91.8804274,38.6503351 92.6472251,39.2725385 C93.294694,39.7979149 93.4728387,40.5076237 93.5580865,41.0057381 C93.6433345,41.5038525 93.7494885,42.63857 93.6651041,43.5252052 C92.7019529,53.6451182 88.5344133,78.2034783 86.4142057,89.5379542 C85.5170662,94.3339958 83.750571,95.9420841 82.0403991,96.0994568 C78.3237996,96.4414641 75.5015827,93.6432685 71.9018743,91.2836143 C66.2690414,87.5912212 63.0868492,85.2926952 57.6192095,81.6896017 C51.3004058,77.5256038 55.3966232,75.2369981 58.9976911,71.4967761 C59.9401076,70.5179421 76.3155302,55.6232293 76.6324771,54.2720454 C76.6721165,54.1030573 76.7089039,53.4731496 76.3346867,53.1405352 C75.9604695,52.8079208 75.4081573,52.921662 75.0095933,53.0121213 C74.444641,53.1403447 65.4461175,59.0880351 48.0140228,70.8551922 C45.4598218,72.6091037 43.1463059,73.4636682 41.0734751,73.4188859 C38.7883453,73.3695169 34.3926725,72.1268388 31.1249416,71.0646282 C27.1169366,69.7617838 23.931454,69.0729605 24.208838,66.8603276 C24.3533167,65.7078514 25.9403832,64.5292172 28.9700376,63.3244248 Z" /></svg>', ), + 'threads' => array( + 'name' => 'Threads', + 'icon' => '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M16.3 11.3c-.1 0-.2-.1-.2-.1-.1-2.6-1.5-4-3.9-4-1.4 0-2.6.6-3.3 1.7l1.3.9c.5-.8 1.4-1 2-1 .8 0 1.4.2 1.7.7.3.3.5.8.5 1.3-.7-.1-1.4-.2-2.2-.1-2.2.1-3.7 1.4-3.6 3.2 0 .9.5 1.7 1.3 2.2.7.4 1.5.6 2.4.6 1.2-.1 2.1-.5 2.7-1.3.5-.6.8-1.4.9-2.4.6.3 1 .8 1.2 1.3.4.9.4 2.4-.8 3.6-1.1 1.1-2.3 1.5-4.3 1.5-2.1 0-3.8-.7-4.8-2S5.7 14.3 5.7 12c0-2.3.5-4.1 1.5-5.4 1.1-1.3 2.7-2 4.8-2 2.2 0 3.8.7 4.9 2 .5.7.9 1.5 1.2 2.5l1.5-.4c-.3-1.2-.8-2.2-1.5-3.1-1.3-1.7-3.3-2.6-6-2.6-2.6 0-4.7.9-6 2.6C4.9 7.2 4.3 9.3 4.3 12s.6 4.8 1.9 6.4c1.4 1.7 3.4 2.6 6 2.6 2.3 0 4-.6 5.3-2 1.8-1.8 1.7-4 1.1-5.4-.4-.9-1.2-1.7-2.3-2.3zm-4 3.8c-1 .1-2-.4-2-1.3 0-.7.5-1.5 2.1-1.6h.5c.6 0 1.1.1 1.6.2-.2 2.3-1.3 2.7-2.2 2.7z"/></svg>', + ), 'tiktok' => array( 'name' => 'TikTok', 'icon' => '<svg width="24" height="24" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M16.708 0.027c1.745-0.027 3.48-0.011 5.213-0.027 0.105 2.041 0.839 4.12 2.333 5.563 1.491 1.479 3.6 2.156 5.652 2.385v5.369c-1.923-0.063-3.855-0.463-5.6-1.291-0.76-0.344-1.468-0.787-2.161-1.24-0.009 3.896 0.016 7.787-0.025 11.667-0.104 1.864-0.719 3.719-1.803 5.255-1.744 2.557-4.771 4.224-7.88 4.276-1.907 0.109-3.812-0.411-5.437-1.369-2.693-1.588-4.588-4.495-4.864-7.615-0.032-0.667-0.043-1.333-0.016-1.984 0.24-2.537 1.495-4.964 3.443-6.615 2.208-1.923 5.301-2.839 8.197-2.297 0.027 1.975-0.052 3.948-0.052 5.923-1.323-0.428-2.869-0.308-4.025 0.495-0.844 0.547-1.485 1.385-1.819 2.333-0.276 0.676-0.197 1.427-0.181 2.145 0.317 2.188 2.421 4.027 4.667 3.828 1.489-0.016 2.916-0.88 3.692-2.145 0.251-0.443 0.532-0.896 0.547-1.417 0.131-2.385 0.079-4.76 0.095-7.145 0.011-5.375-0.016-10.735 0.025-16.093z" /></svg>', @@ -337,3 +341,24 @@ function block_core_social_link_get_color_styles( $context ) { return implode( '', $styles ); } + +/** + * Returns CSS classes for icon and icon background colors. + * + * @param array $context Block context passed to Social Sharing Link. + * + * @return string CSS classes for link's icon and background colors. + */ +function block_core_social_link_get_color_classes( $context ) { + $classes = array(); + + if ( array_key_exists( 'iconColor', $context ) ) { + $classes[] = 'has-' . $context['iconColor'] . '-color'; + } + + if ( array_key_exists( 'iconBackgroundColor', $context ) ) { + $classes[] = 'has-' . $context['iconBackgroundColor'] . '-background-color'; + } + + return ' ' . implode( ' ', $classes ); +} diff --git a/packages/block-library/src/social-link/socials-with-bg.scss b/packages/block-library/src/social-link/socials-with-bg.scss index 65542dafd95413..042db464f6ee26 100644 --- a/packages/block-library/src/social-link/socials-with-bg.scss +++ b/packages/block-library/src/social-link/socials-with-bg.scss @@ -154,6 +154,11 @@ color: #fff; } +.wp-social-link-threads { + background-color: #000; + color: #fff; +} + .wp-social-link-tiktok { background-color: #000; color: #fff; diff --git a/packages/block-library/src/social-link/socials-without-bg.scss b/packages/block-library/src/social-link/socials-without-bg.scss index 82af8d42ef1b8a..ea8fca5d7ab835 100644 --- a/packages/block-library/src/social-link/socials-without-bg.scss +++ b/packages/block-library/src/social-link/socials-without-bg.scss @@ -119,6 +119,10 @@ color: #2aabee; } +.wp-social-link-threads { + color: #000; +} + .wp-social-link-tiktok { color: #000; } diff --git a/packages/block-library/src/social-link/test/index.native.js b/packages/block-library/src/social-link/test/index.native.js deleted file mode 100644 index 4ad7611e72045d..00000000000000 --- a/packages/block-library/src/social-link/test/index.native.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * External dependencies - */ -import { fireEvent, initializeEditor, waitFor, within } from 'test/helpers'; -/** - * WordPress dependencies - */ -import { registerCoreBlocks } from '@wordpress/block-library'; -import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; - -const unregisterBlocks = () => { - const blocks = getBlockTypes(); - - blocks.forEach( ( { name } ) => unregisterBlockType( name ) ); -}; - -describe( '<SocialLinkEdit/>', () => { - beforeAll( () => { - registerCoreBlocks(); - } ); - - afterAll( () => { - unregisterBlocks(); - } ); - - /** - * GIVEN an EDITOR is displayed; - * WHEN a SOCIAL ICONS BLOCK is selected from the BLOCK INSERTER; - */ - it( 'should display WORDPRESS, FACEBOOK, TWITTER, INSTAGRAM by default.', async () => { - // Arrange - const subject = await initializeEditor( {} ); - - // Act - fireEvent.press( - await waitFor( () => subject.getByLabelText( 'Add block' ) ) - ); - fireEvent.changeText( - await waitFor( () => - subject.getByPlaceholderText( 'Search blocks' ) - ), - 'social icons' - ); - fireEvent.press( - await subject.findByLabelText( 'Social Icons block' ) - ); - const [ socialIconsBlock ] = subject.getAllByLabelText( - /Social Icons Block. Row 1/ - ); - fireEvent( - within( socialIconsBlock ).getByTestId( 'block-list-wrapper' ), - 'layout', - { nativeEvent: { layout: { width: 100 } } } - ); - - // Assert - expect( - await waitFor( () => - subject.getByLabelText( /WordPress social icon/ ) - ) - ).toBeDefined(); - expect( - await waitFor( () => - subject.getByLabelText( /Facebook social icon/ ) - ) - ).toBeDefined(); - expect( - await waitFor( () => - subject.getByLabelText( /Twitter social icon/ ) - ) - ).toBeDefined(); - expect( - await waitFor( () => - subject.getByLabelText( /Instagram social icon/ ) - ) - ).toBeDefined(); - } ); - - /** - * GIVEN an EDITOR is displayed; - * WHEN a SOCIAL ICONS BLOCK is selected from the BLOCK INSERTER; - */ - it( `should display WORDPRESS with a URL set by default - AND should display FACEBOOK, TWITTER, INSTAGRAM with NO URL set by default.`, async () => { - // Arrange - const subject = await initializeEditor( {} ); - - // Act - fireEvent.press( - await waitFor( () => subject.getByLabelText( 'Add block' ) ) - ); - fireEvent.changeText( - await waitFor( () => - subject.getByPlaceholderText( 'Search blocks' ) - ), - 'social icons' - ); - fireEvent.press( - await subject.findByLabelText( 'Social Icons block' ) - ); - const [ socialIconsBlock ] = subject.getAllByLabelText( - /Social Icons Block. Row 1/ - ); - fireEvent( - within( socialIconsBlock ).getByTestId( 'block-list-wrapper' ), - 'layout', - { nativeEvent: { layout: { width: 100 } } } - ); - - // Assert - expect( - await waitFor( () => - subject.getByA11yHint( /WordPress has URL set/ ) - ) - ).toBeDefined(); - expect( - await waitFor( () => - subject.getByA11yHint( /Facebook has no URL set/ ) - ) - ).toBeDefined(); - expect( - await waitFor( () => - subject.getByA11yHint( /Twitter has no URL set/ ) - ) - ).toBeDefined(); - expect( - await waitFor( () => - subject.getByA11yHint( /Instagram has no URL set/ ) - ) - ).toBeDefined(); - } ); -} ); diff --git a/packages/block-library/src/social-link/variations.js b/packages/block-library/src/social-link/variations.js index 40aaf3cc9d0f1b..47307ca65c0882 100644 --- a/packages/block-library/src/social-link/variations.js +++ b/packages/block-library/src/social-link/variations.js @@ -35,6 +35,7 @@ import { SoundCloudIcon, SpotifyIcon, TelegramIcon, + ThreadsIcon, TiktokIcon, TumblrIcon, TwitchIcon, @@ -255,6 +256,12 @@ const variations = [ title: 'Telegram', icon: TelegramIcon, }, + { + name: 'threads', + attributes: { service: 'threads' }, + title: 'Threads', + icon: ThreadsIcon, + }, { name: 'tiktok', attributes: { service: 'tiktok' }, diff --git a/packages/block-library/src/social-links/block.json b/packages/block-library/src/social-links/block.json index a7707cf1951345..20206511a4c96c 100644 --- a/packages/block-library/src/social-links/block.json +++ b/packages/block-library/src/social-links/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/social-links", "title": "Social Icons", "category": "widgets", @@ -41,14 +41,16 @@ "providesContext": { "openInNewTab": "openInNewTab", "showLabels": "showLabels", + "iconColor": "iconColor", "iconColorValue": "iconColorValue", + "iconBackgroundColor": "iconBackgroundColor", "iconBackgroundColorValue": "iconBackgroundColorValue" }, "supports": { "align": [ "left", "center", "right" ], "anchor": true, "__experimentalExposeControlsToChildren": true, - "__experimentalLayout": { + "layout": { "allowSwitching": false, "allowInheriting": false, "allowVerticalAlignment": false, @@ -71,7 +73,9 @@ "padding": true, "units": [ "px", "em", "rem", "vh", "vw" ], "__experimentalDefaultControls": { - "blockGap": true + "blockGap": true, + "margin": true, + "padding": false } } }, diff --git a/packages/block-library/src/social-links/edit.native.js b/packages/block-library/src/social-links/edit.native.js index 9a3b1bdc8694ba..8236eef0814775 100644 --- a/packages/block-library/src/social-links/edit.native.js +++ b/packages/block-library/src/social-links/edit.native.js @@ -57,7 +57,7 @@ function SocialLinksEdit( { }, [ shouldRenderFooterAppender ] ); const renderFooterAppender = useRef( () => ( - <View> + <View style={ styles.footerAppenderContainer }> <InnerBlocks.ButtonBlockAppender isFloating={ true } /> </View> ) ); diff --git a/packages/block-library/src/social-links/editor.native.scss b/packages/block-library/src/social-links/editor.native.scss index cfba35769c7190..23030419f8891d 100644 --- a/packages/block-library/src/social-links/editor.native.scss +++ b/packages/block-library/src/social-links/editor.native.scss @@ -19,3 +19,7 @@ flex-direction: row; flex-wrap: wrap; } + +.footerAppenderContainer { + margin: $block-selected-margin 0; +} diff --git a/packages/block-library/src/social-links/editor.scss b/packages/block-library/src/social-links/editor.scss index c6e0d97a858b3c..4ba574c965d2d6 100644 --- a/packages/block-library/src/social-links/editor.scss +++ b/packages/block-library/src/social-links/editor.scss @@ -87,7 +87,8 @@ } // Center flex items. This has an equivalent in style.scss. -.wp-block[data-align="center"] > .wp-block-social-links { +.wp-block[data-align="center"] > .wp-block-social-links, +.wp-block.wp-block-social-links.aligncenter { justify-content: center; } diff --git a/packages/block-library/src/social-links/style.scss b/packages/block-library/src/social-links/style.scss index 582edd4f22bac6..23c741e9819f9a 100644 --- a/packages/block-library/src/social-links/style.scss +++ b/packages/block-library/src/social-links/style.scss @@ -142,3 +142,13 @@ padding-right: calc((2/3) * 1em); } } + +// Ensure the Snapchat label is visible when no custom +// icon color or background color is set. +.wp-block-social-links:not(.has-icon-color):not(.has-icon-background-color) { + .wp-social-link-snapchat { + .wp-block-social-link-label { + color: #000; + } + } +} diff --git a/packages/block-library/src/social-links/test/edit.native.js b/packages/block-library/src/social-links/test/edit.native.js index 48fefaadaec8c0..a4ac5246979aa3 100644 --- a/packages/block-library/src/social-links/test/edit.native.js +++ b/packages/block-library/src/social-links/test/edit.native.js @@ -3,12 +3,14 @@ */ import { addBlock, + dismissModal, fireEvent, getEditorHtml, initializeEditor, within, getBlock, waitFor, + waitForModalVisible, } from 'test/helpers'; /** @@ -197,4 +199,32 @@ describe( 'Social links block', () => { expect( getEditorHtml() ).toMatchSnapshot(); } ); + + it( "should set a icon's URL", async () => { + const screen = await initializeEditor(); + await addBlock( screen, 'Social Icons' ); + fireEvent.press( screen.getByLabelText( 'Facebook social icon' ) ); + fireEvent.press( screen.getByLabelText( 'Add link to Facebook' ) ); + + await waitForModalVisible( + screen.getByTestId( 'link-settings-navigation' ) + ); + fireEvent.changeText( + screen.getByPlaceholderText( 'Add URL' ), + 'https://facebook.com' + ); + dismissModal( screen.getByTestId( 'link-settings-navigation' ) ); + + expect( getEditorHtml() ).toMatchInlineSnapshot( ` + "<!-- wp:social-links --> + <ul class="wp-block-social-links"><!-- wp:social-link {"url":"https://wordpress.org","service":"wordpress"} /--> + + <!-- wp:social-link {"url":"https://facebook.com","service":"facebook","label":"","rel":""} /--> + + <!-- wp:social-link {"service":"twitter"} /--> + + <!-- wp:social-link {"service":"instagram"} /--></ul> + <!-- /wp:social-links -->" + ` ); + } ); } ); diff --git a/packages/block-library/src/spacer/block.json b/packages/block-library/src/spacer/block.json index 021ff9f4a2de33..a9da8d537f1b61 100644 --- a/packages/block-library/src/spacer/block.json +++ b/packages/block-library/src/spacer/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/spacer", "title": "Spacer", "category": "design", diff --git a/packages/block-library/src/spacer/controls.js b/packages/block-library/src/spacer/controls.js index ee702c42667819..d999550f16f331 100644 --- a/packages/block-library/src/spacer/controls.js +++ b/packages/block-library/src/spacer/controls.js @@ -81,6 +81,7 @@ function DimensionInput( { label, onChange, isResizing, value = '' } ) { units={ units } allowReset={ false } splitOnAxis={ false } + showSideInLabel={ false } /> </View> ) } diff --git a/packages/block-library/src/spacer/save.js b/packages/block-library/src/spacer/save.js index 96d72bca7ac335..dd3a595a808f27 100644 --- a/packages/block-library/src/spacer/save.js +++ b/packages/block-library/src/spacer/save.js @@ -3,12 +3,17 @@ */ import { useBlockProps, getSpacingPresetCssVar } from '@wordpress/block-editor'; -export default function save( { attributes: { height, width } } ) { +export default function save( { attributes } ) { + const { height, width, style } = attributes; + const { layout: { selfStretch } = {} } = style || {}; + // If selfStretch is set to 'fill' or 'fit', don't set default height. + const finalHeight = + selfStretch === 'fill' || selfStretch === 'fit' ? undefined : height; return ( <div { ...useBlockProps.save( { style: { - height: getSpacingPresetCssVar( height ), + height: getSpacingPresetCssVar( finalHeight ), width: getSpacingPresetCssVar( width ), }, 'aria-hidden': true, diff --git a/packages/block-library/src/spacer/test/index.native.js b/packages/block-library/src/spacer/test/index.native.js index ee052715b92c9a..235b8bab78ee03 100644 --- a/packages/block-library/src/spacer/test/index.native.js +++ b/packages/block-library/src/spacer/test/index.native.js @@ -5,7 +5,7 @@ import { fireEvent, getEditorHtml, initializeEditor, - waitFor, + waitForModalVisible, } from 'test/helpers'; /** @@ -65,13 +65,14 @@ describe( 'Spacer block', () => { // Open block settings fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); - await waitFor( - () => screen.getByTestId( 'block-settings-modal' ).props.isVisible - ); + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); // Update height attribute - fireEvent.press( screen.getByText( '100' ) ); - const heightTextInput = screen.getByDisplayValue( '100' ); + fireEvent.press( screen.getByText( '100', { hidden: true } ) ); + const heightTextInput = screen.getByDisplayValue( '100', { + hidden: true, + } ); fireEvent.changeText( heightTextInput, '50' ); expect( getEditorHtml() ).toMatchSnapshot(); @@ -92,17 +93,20 @@ describe( 'Spacer block', () => { // Open block settings fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); - await waitFor( - () => screen.getByTestId( 'block-settings-modal' ).props.isVisible - ); + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); // Set vh unit - fireEvent.press( screen.getByText( 'px' ) ); - fireEvent.press( screen.getByText( 'Viewport height (vh)' ) ); + fireEvent.press( screen.getByText( 'px', { hidden: true } ) ); + fireEvent.press( + screen.getByText( 'Viewport height (vh)', { hidden: true } ) + ); // Update height attribute - fireEvent.press( screen.getByText( '100' ) ); - const heightTextInput = screen.getByDisplayValue( '100' ); + fireEvent.press( screen.getByText( '100', { hidden: true } ) ); + const heightTextInput = screen.getByDisplayValue( '100', { + hidden: true, + } ); fireEvent.changeText( heightTextInput, '25' ); expect( getEditorHtml() ).toMatchSnapshot(); @@ -123,9 +127,8 @@ describe( 'Spacer block', () => { // Open block settings fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); - await waitFor( - () => screen.getByTestId( 'block-settings-modal' ).props.isVisible - ); + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); // Increment height fireEvent( @@ -154,9 +157,8 @@ describe( 'Spacer block', () => { // Open block settings fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); - await waitFor( - () => screen.getByTestId( 'block-settings-modal' ).props.isVisible - ); + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); // Increment height fireEvent( @@ -212,13 +214,14 @@ describe( 'Spacer block', () => { // Open block settings fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); - await waitFor( - () => screen.getByTestId( 'block-settings-modal' ).props.isVisible - ); + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); // Update height attribute - fireEvent.press( screen.getByText( '60' ) ); - const heightTextInput = screen.getByDisplayValue( '60' ); + fireEvent.press( screen.getByText( '60', { hidden: true } ) ); + const heightTextInput = screen.getByDisplayValue( '60', { + hidden: true, + } ); fireEvent.changeText( heightTextInput, '70' ); expect( getEditorHtml() ).toMatchSnapshot(); @@ -239,13 +242,14 @@ describe( 'Spacer block', () => { // Open block settings fireEvent.press( screen.getByLabelText( 'Open Settings' ) ); - await waitFor( - () => screen.getByTestId( 'block-settings-modal' ).props.isVisible - ); + const blockSettingsModal = screen.getByTestId( 'block-settings-modal' ); + await waitForModalVisible( blockSettingsModal ); // Update height attribute - fireEvent.press( screen.getByText( '100' ) ); - const heightTextInput = screen.getByDisplayValue( '100' ); + fireEvent.press( screen.getByText( '100', { hidden: true } ) ); + const heightTextInput = screen.getByDisplayValue( '100', { + hidden: true, + } ); fireEvent.changeText( heightTextInput, '120' ); expect( getEditorHtml() ).toMatchSnapshot(); diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index 8fe8b904c23f58..c4b0a37e6354d7 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -51,8 +51,10 @@ @import "./spacer/style.scss"; @import "./tag-cloud/style.scss"; @import "./table/style.scss"; +@import "./term-description/style.scss"; @import "./text-columns/style.scss"; @import "./verse/style.scss"; @import "./video/style.scss"; +@import "./footnotes/style.scss"; @import "common.scss"; diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json index f095a2e2e5cdbb..9623263166916b 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "__experimental": true, "name": "core/table-of-contents", "title": "Table of Contents", diff --git a/packages/block-library/src/table/block.json b/packages/block-library/src/table/block.json index adac1e9c2130e0..d1139d6c55addf 100644 --- a/packages/block-library/src/table/block.json +++ b/packages/block-library/src/table/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/table", "title": "Table", "category": "text", @@ -166,7 +166,11 @@ }, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "typography": { "fontSize": true, diff --git a/packages/block-library/src/tag-cloud/block.json b/packages/block-library/src/tag-cloud/block.json index ec1e3335127193..9481dc945666ad 100644 --- a/packages/block-library/src/tag-cloud/block.json +++ b/packages/block-library/src/tag-cloud/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/tag-cloud", "title": "Tag Cloud", "category": "widgets", @@ -36,7 +36,6 @@ ], "supports": { "html": false, - "anchor": true, "align": true, "spacing": { "margin": true, diff --git a/packages/block-library/src/tag-cloud/edit.js b/packages/block-library/src/tag-cloud/edit.js index 714de24a38a149..4dbec86fff520c 100644 --- a/packages/block-library/src/tag-cloud/edit.js +++ b/packages/block-library/src/tag-cloud/edit.js @@ -122,6 +122,7 @@ function TagCloudEdit( { attributes, setAttributes, taxonomies } ) { /> <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Number of tags' ) } value={ numberOfTags } onChange={ ( value ) => diff --git a/packages/block-library/src/template-part/block.json b/packages/block-library/src/template-part/block.json index 282ac2ca22127a..9fe431150ae392 100644 --- a/packages/block-library/src/template-part/block.json +++ b/packages/block-library/src/template-part/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/template-part", "title": "Template Part", "category": "theme", @@ -21,7 +21,6 @@ } }, "supports": { - "anchor": true, "align": true, "html": false, "reusable": false diff --git a/packages/block-library/src/template-part/edit/advanced-controls.js b/packages/block-library/src/template-part/edit/advanced-controls.js index 0b54e78e9aa8cf..b879b46638face 100644 --- a/packages/block-library/src/template-part/edit/advanced-controls.js +++ b/packages/block-library/src/template-part/edit/advanced-controls.js @@ -12,6 +12,27 @@ import { useSelect } from '@wordpress/data'; */ import { TemplatePartImportControls } from './import-controls'; +const htmlElementMessages = { + header: __( + 'The <header> element should represent introductory content, typically a group of introductory or navigational aids.' + ), + main: __( + 'The <main> element should be used for the primary content of your document only.' + ), + section: __( + "The <section> element should represent a standalone portion of the document that can't be better represented by another element." + ), + article: __( + 'The <article> element should represent a self-contained, syndicatable portion of the document.' + ), + aside: __( + "The <aside> element should represent a portion of a document whose content is only indirectly related to the document's main content." + ), + footer: __( + 'The <footer> element should represent a footer for its nearest sectioning element (e.g.: <section>, <article>, <main> etc.).' + ), +}; + export function TemplatePartAdvancedControls( { tagName, setAttributes, @@ -34,41 +55,19 @@ export function TemplatePartAdvancedControls( { templatePartId ); - const { areaOptions } = useSelect( ( select ) => { + const definedAreas = useSelect( ( select ) => { // FIXME: @wordpress/block-library should not depend on @wordpress/editor. // Blocks can be loaded into a *non-post* block editor. - /* eslint-disable @wordpress/data-no-store-string-literals */ - const definedAreas = - select( 'core/editor' ).__experimentalGetDefaultTemplatePartAreas(); - /* eslint-enable @wordpress/data-no-store-string-literals */ - return { - areaOptions: definedAreas.map( ( { label, area: _area } ) => ( { - label, - value: _area, - } ) ), - }; + /* eslint-disable-next-line @wordpress/data-no-store-string-literals */ + return select( + 'core/editor' + ).__experimentalGetDefaultTemplatePartAreas(); }, [] ); - const htmlElementMessages = { - header: __( - 'The <header> element should represent introductory content, typically a group of introductory or navigational aids.' - ), - main: __( - 'The <main> element should be used for the primary content of your document only. ' - ), - section: __( - "The <section> element should represent a standalone portion of the document that can't be better represented by another element." - ), - article: __( - 'The <article> element should represent a self-contained, syndicatable portion of the document.' - ), - aside: __( - "The <aside> element should represent a portion of a document whose content is only indirectly related to the document's main content." - ), - footer: __( - 'The <footer> element should represent a footer for its nearest sectioning element (e.g.: <section>, <article>, <main> etc.).' - ), - }; + const areaOptions = definedAreas.map( ( { label, area: _area } ) => ( { + label, + value: _area, + } ) ); return ( <InspectorControls group="advanced"> diff --git a/packages/block-library/src/template-part/edit/import-controls.js b/packages/block-library/src/template-part/edit/import-controls.js index f629691535a327..c8f4fbc2e647df 100644 --- a/packages/block-library/src/template-part/edit/import-controls.js +++ b/packages/block-library/src/template-part/edit/import-controls.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __, _x, sprintf } from '@wordpress/i18n'; import { useMemo, useState } from '@wordpress/element'; import { useDispatch, useSelect, useRegistry } from '@wordpress/data'; import { @@ -163,7 +163,7 @@ export function TemplatePartImportControls( { area, setAttributes } ) { isBusy={ isBusy } aria-disabled={ isBusy || ! selectedSidebar } > - { __( 'Import' ) } + { _x( 'Import', 'button label' ) } </Button> </FlexItem> </HStack> diff --git a/packages/block-library/src/template-part/edit/index.js b/packages/block-library/src/template-part/edit/index.js index b7ae4f6043afb1..64c8db5e3bc151 100644 --- a/packages/block-library/src/template-part/edit/index.js +++ b/packages/block-library/src/template-part/edit/index.js @@ -34,7 +34,6 @@ export default function TemplatePartEdit( { attributes, setAttributes, clientId, - isSelected, } ) { const { slug, theme, tagName, layout = {} } = attributes; const templatePartId = createTemplatePartId( theme, slug ); @@ -77,7 +76,7 @@ export default function TemplatePartEdit( { area: _area, }; }, - [ templatePartId, clientId ] + [ templatePartId, attributes.area, clientId ] ); const { templateParts } = useAlternativeTemplateParts( area, @@ -91,10 +90,7 @@ export default function TemplatePartEdit( { const isEntityAvailable = ! isPlaceholder && ! isMissing && isResolved; const TagName = tagName || areaObject.tagName; - // The `isSelected` check ensures the `BlockSettingsMenuControls` fill - // doesn't render multiple times. The block controls has similar internal check. const canReplace = - isSelected && isEntityAvailable && hasReplacements && ( area === 'header' || area === 'footer' ); @@ -156,25 +152,42 @@ export default function TemplatePartEdit( { ) } { canReplace && ( <BlockSettingsMenuControls> - { () => ( - <MenuItem - onClick={ () => { - setIsTemplatePartSelectionOpen( true ); - } } - > - { createInterpolateElement( - __( 'Replace <BlockTitle />' ), - { - BlockTitle: ( - <BlockTitle - clientId={ clientId } - maximumLength={ 25 } - /> - ), + { ( { selectedClientIds } ) => { + // Only enable for single selection that matches the current block. + // Ensures menu item doesn't render multiple times. + if ( + ! ( + selectedClientIds.length === 1 && + clientId === selectedClientIds[ 0 ] + ) + ) { + return null; + } + + return ( + <MenuItem + onClick={ () => { + setIsTemplatePartSelectionOpen( true ); + } } + aria-expanded={ + isTemplatePartSelectionOpen } - ) } - </MenuItem> - ) } + aria-haspopup="dialog" + > + { createInterpolateElement( + __( 'Replace <BlockTitle />' ), + { + BlockTitle: ( + <BlockTitle + clientId={ clientId } + maximumLength={ 25 } + /> + ), + } + ) } + </MenuItem> + ); + } } </BlockSettingsMenuControls> ) } { isEntityAvailable && ( diff --git a/packages/block-library/src/template-part/edit/inner-blocks.js b/packages/block-library/src/template-part/edit/inner-blocks.js index 19e79dca27e5ca..b17428bbdbb40e 100644 --- a/packages/block-library/src/template-part/edit/inner-blocks.js +++ b/packages/block-library/src/template-part/edit/inner-blocks.js @@ -37,7 +37,7 @@ export default function TemplatePartInnerBlocks( { renderAppender: hasInnerBlocks ? undefined : InnerBlocks.ButtonBlockAppender, - __experimentalLayout: themeSupportsLayout ? usedLayout : undefined, + layout: themeSupportsLayout ? usedLayout : undefined, } ); return <TagName { ...innerBlocksProps } />; diff --git a/packages/block-library/src/template-part/index.php b/packages/block-library/src/template-part/index.php index d3de7d0b3afbd5..a7bd4033affc34 100644 --- a/packages/block-library/src/template-part/index.php +++ b/packages/block-library/src/template-part/index.php @@ -63,18 +63,16 @@ function render_block_core_template_part( $attributes ) { */ do_action( 'render_block_core_template_part_post', $template_part_id, $attributes, $template_part_post, $content ); } else { + $template_part_file_path = ''; // Else, if the template part was provided by the active theme, // render the corresponding file content. - $parent_theme_folders = get_block_theme_folders( get_template() ); - $child_theme_folders = get_block_theme_folders( get_stylesheet() ); - $child_theme_part_file_path = get_theme_file_path( '/' . $child_theme_folders['wp_template_part'] . '/' . $attributes['slug'] . '.html' ); - $parent_theme_part_file_path = get_theme_file_path( '/' . $parent_theme_folders['wp_template_part'] . '/' . $attributes['slug'] . '.html' ); - $template_part_file_path = 0 === validate_file( $attributes['slug'] ) && file_exists( $child_theme_part_file_path ) ? $child_theme_part_file_path : $parent_theme_part_file_path; - if ( 0 === validate_file( $attributes['slug'] ) && file_exists( $template_part_file_path ) ) { - $content = file_get_contents( $template_part_file_path ); - $content = is_string( $content ) && '' !== $content - ? _inject_theme_attribute_in_block_template_content( $content ) - : ''; + if ( 0 === validate_file( $attributes['slug'] ) ) { + $block_template = get_block_file_template( $template_part_id, 'wp_template_part' ); + + $content = $block_template->content; + if ( isset( $block_template->area ) ) { + $area = $block_template->area; + } } if ( '' !== $content && null !== $content ) { @@ -249,7 +247,7 @@ function build_template_part_block_instance_variations() { 'area' => $template_part->area, ), 'scope' => array( 'inserter' ), - 'icon' => $icon_by_area[ $template_part->area ], + 'icon' => isset( $icon_by_area[ $template_part->area ] ) ? $icon_by_area[ $template_part->area ] : null, 'example' => array( 'attributes' => array( 'slug' => $template_part->slug, diff --git a/packages/block-library/src/term-description/block.json b/packages/block-library/src/term-description/block.json index bd96f3405f54f2..beee12eb674b03 100644 --- a/packages/block-library/src/term-description/block.json +++ b/packages/block-library/src/term-description/block.json @@ -1,6 +1,7 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, + "__experimental": "fse", "name": "core/term-description", "title": "Term Description", "category": "theme", @@ -12,7 +13,6 @@ } }, "supports": { - "anchor": true, "align": [ "wide", "full" ], "html": false, "color": { diff --git a/packages/block-library/src/term-description/style.scss b/packages/block-library/src/term-description/style.scss new file mode 100644 index 00000000000000..bd455f7a68d3dd --- /dev/null +++ b/packages/block-library/src/term-description/style.scss @@ -0,0 +1,11 @@ +// Lowest specificity on wrapper margins to avoid overriding layout styles. +:where(.wp-block-term-description) { + margin-top: var(--wp--style--block-gap); + margin-bottom: var(--wp--style--block-gap); +} + +// Zero out the margin on the description paragraph. +.wp-block-term-description p { + margin-top: 0; + margin-bottom: 0; +} diff --git a/packages/block-library/src/text-columns/block.json b/packages/block-library/src/text-columns/block.json index 84d5ccece038ab..3af169fadbb3bd 100644 --- a/packages/block-library/src/text-columns/block.json +++ b/packages/block-library/src/text-columns/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/text-columns", "title": "Text Columns (deprecated)", "icon": "columns", diff --git a/packages/block-library/src/text-columns/edit.js b/packages/block-library/src/text-columns/edit.js index 7e7946927b1d29..761d047e90b1c9 100644 --- a/packages/block-library/src/text-columns/edit.js +++ b/packages/block-library/src/text-columns/edit.js @@ -35,6 +35,7 @@ export default function TextColumnsEdit( { attributes, setAttributes } ) { <PanelBody> <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Columns' ) } value={ columns } onChange={ ( value ) => diff --git a/packages/block-library/src/utils/interactivity/constants.js b/packages/block-library/src/utils/interactivity/constants.js deleted file mode 100644 index f462753c9f8179..00000000000000 --- a/packages/block-library/src/utils/interactivity/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const directivePrefix = 'data-wp-'; diff --git a/packages/block-library/src/utils/interactivity/directives.js b/packages/block-library/src/utils/interactivity/directives.js deleted file mode 100644 index b2415293a2bf0e..00000000000000 --- a/packages/block-library/src/utils/interactivity/directives.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * External dependencies - */ -import { useContext, useMemo, useEffect } from 'preact/hooks'; -import { deepSignal, peek } from 'deepsignal'; -/** - * Internal dependencies - */ -import { createPortal } from './portals.js'; - -/** - * Internal dependencies - */ -import { useSignalEffect } from './utils'; -import { directive } from './hooks'; - -const isObject = ( item ) => - item && typeof item === 'object' && ! Array.isArray( item ); - -const mergeDeepSignals = ( target, source ) => { - for ( const k in source ) { - if ( typeof peek( target, k ) === 'undefined' ) { - target[ `$${ k }` ] = source[ `$${ k }` ]; - } else if ( - isObject( peek( target, k ) ) && - isObject( peek( source, k ) ) - ) { - mergeDeepSignals( - target[ `$${ k }` ].peek(), - source[ `$${ k }` ].peek() - ); - } - } -}; - -export default () => { - // data-wp-context - directive( - 'context', - ( { - directives: { - context: { default: context }, - }, - props: { children }, - context: inherited, - } ) => { - const { Provider } = inherited; - const inheritedValue = useContext( inherited ); - const value = useMemo( () => { - const localValue = deepSignal( context ); - mergeDeepSignals( localValue, inheritedValue ); - return localValue; - }, [ context, inheritedValue ] ); - - return <Provider value={ value }>{ children }</Provider>; - }, - { priority: 5 } - ); - - // data-wp-body - directive( 'body', ( { props: { children }, context: inherited } ) => { - const { Provider } = inherited; - const inheritedValue = useContext( inherited ); - return createPortal( - <Provider value={ inheritedValue }>{ children }</Provider>, - document.body - ); - } ); - - // data-wp-effect.[name] - directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.values( effect ).forEach( ( path ) => { - useSignalEffect( () => { - return evaluate( path, { context: contextValue } ); - } ); - } ); - } ); - - // data-wp-init.[name] - directive( 'init', ( { directives: { init }, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.values( init ).forEach( ( path ) => { - useEffect( () => { - return evaluate( path, { context: contextValue } ); - }, [] ); - } ); - } ); - - // data-wp-on.[event] - directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { - const contextValue = useContext( context ); - Object.entries( on ).forEach( ( [ name, path ] ) => { - element.props[ `on${ name }` ] = ( event ) => { - evaluate( path, { event, context: contextValue } ); - }; - } ); - } ); - - // data-wp-class.[classname] - directive( - 'class', - ( { - directives: { class: className }, - element, - evaluate, - context, - } ) => { - const contextValue = useContext( context ); - Object.keys( className ) - .filter( ( n ) => n !== 'default' ) - .forEach( ( name ) => { - const result = evaluate( className[ name ], { - className: name, - context: contextValue, - } ); - const currentClass = element.props.class || ''; - const classFinder = new RegExp( - `(^|\\s)${ name }(\\s|$)`, - 'g' - ); - if ( ! result ) - element.props.class = currentClass - .replace( classFinder, ' ' ) - .trim(); - else if ( ! classFinder.test( currentClass ) ) - element.props.class = currentClass - ? `${ currentClass } ${ name }` - : name; - - useEffect( () => { - // This seems necessary because Preact doesn't change the class - // names on the hydration, so we have to do it manually. It doesn't - // need deps because it only needs to do it the first time. - if ( ! result ) { - element.ref.current.classList.remove( name ); - } else { - element.ref.current.classList.add( name ); - } - }, [] ); - } ); - } - ); - - // data-wp-bind.[attribute] - directive( - 'bind', - ( { directives: { bind }, element, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.entries( bind ) - .filter( ( n ) => n !== 'default' ) - .forEach( ( [ attribute, path ] ) => { - const result = evaluate( path, { - context: contextValue, - } ); - element.props[ attribute ] = result; - - // This seems necessary because Preact doesn't change the attributes - // on the hydration, so we have to do it manually. It doesn't need - // deps because it only needs to do it the first time. - useEffect( () => { - // aria- and data- attributes have no boolean representation. - // A `false` value is different from the attribute not being - // present, so we can't remove it. - // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 - if ( result === false && attribute[ 4 ] !== '-' ) { - element.ref.current.removeAttribute( attribute ); - } else { - element.ref.current.setAttribute( - attribute, - result === true && attribute[ 4 ] !== '-' - ? '' - : result - ); - } - }, [] ); - } ); - } - ); - - // data-wp-ignore - directive( - 'ignore', - ( { - element: { - type: Type, - props: { innerHTML, ...rest }, - }, - } ) => { - // Preserve the initial inner HTML. - const cached = useMemo( () => innerHTML, [] ); - return ( - <Type - dangerouslySetInnerHTML={ { __html: cached } } - { ...rest } - /> - ); - } - ); -}; diff --git a/packages/block-library/src/utils/interactivity/hooks.js b/packages/block-library/src/utils/interactivity/hooks.js deleted file mode 100644 index e309990482ebc2..00000000000000 --- a/packages/block-library/src/utils/interactivity/hooks.js +++ /dev/null @@ -1,145 +0,0 @@ -/** - * External dependencies - */ -import { h, options, createContext, cloneElement } from 'preact'; -import { useRef, useMemo } from 'preact/hooks'; -/** - * Internal dependencies - */ -import { rawStore as store } from './store'; - -// Main context. -const context = createContext( {} ); - -// WordPress Directives. -const directiveMap = {}; -const directivePriorities = {}; -export const directive = ( name, cb, { priority = 10 } = {} ) => { - directiveMap[ name ] = cb; - directivePriorities[ name ] = priority; -}; - -// Resolve the path to some property of the store object. -const resolve = ( path, ctx ) => { - let current = { ...store, context: ctx }; - path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); - return current; -}; - -// Generate the evaluate function. -const getEvaluate = - ( { ref } = {} ) => - ( path, extraArgs = {} ) => { - // If path starts with !, remove it and save a flag. - const hasNegationOperator = - path[ 0 ] === '!' && !! ( path = path.slice( 1 ) ); - const value = resolve( path, extraArgs.context ); - const returnValue = - typeof value === 'function' - ? value( { - ref: ref.current, - ...store, - ...extraArgs, - } ) - : value; - return hasNegationOperator ? ! returnValue : returnValue; - }; - -// Separate directives by priority. The resulting array contains objects -// of directives grouped by same priority, and sorted in ascending order. -const usePriorityLevels = ( directives ) => - useMemo( () => { - const byPriority = Object.entries( directives ).reduce( - ( acc, [ name, values ] ) => { - const priority = directivePriorities[ name ]; - if ( ! acc[ priority ] ) acc[ priority ] = {}; - acc[ priority ][ name ] = values; - - return acc; - }, - {} - ); - - return Object.entries( byPriority ) - .sort( ( [ p1 ], [ p2 ] ) => p1 - p2 ) - .map( ( [ , obj ] ) => obj ); - }, [ directives ] ); - -// Directive wrapper. -const Directive = ( { type, directives, props: originalProps } ) => { - const ref = useRef( null ); - const element = h( type, { ...originalProps, ref } ); - const evaluate = useMemo( () => getEvaluate( { ref } ), [] ); - - // Add wrappers recursively for each priority level. - const byPriorityLevel = usePriorityLevels( directives ); - return ( - <RecursivePriorityLevel - directives={ byPriorityLevel } - element={ element } - evaluate={ evaluate } - originalProps={ originalProps } - /> - ); -}; - -// Priority level wrapper. -const RecursivePriorityLevel = ( { - directives: [ directives, ...rest ], - element, - evaluate, - originalProps, -} ) => { - // This element needs to be a fresh copy so we are not modifying an already - // rendered element with Preact's internal properties initialized. This - // prevents an error with changes in `element.props.children` not being - // reflected in `element.__k`. - element = cloneElement( element ); - - // Recursively render the wrapper for the next priority level. - // - // Note that, even though we're instantiating a vnode with a - // `RecursivePriorityLevel` here, its render function will not be executed - // just yet. Actually, it will be delayed until the current render function - // has finished. That ensures directives in the current priorty level have - // run (and thus modified the passed `element`) before the next level. - const children = - rest.length > 0 ? ( - <RecursivePriorityLevel - directives={ rest } - element={ element } - evaluate={ evaluate } - originalProps={ originalProps } - /> - ) : ( - element - ); - - const props = { ...originalProps, children }; - const directiveArgs = { directives, props, element, context, evaluate }; - - for ( const d in directives ) { - const wrapper = directiveMap[ d ]?.( directiveArgs ); - if ( wrapper !== undefined ) props.children = wrapper; - } - - return props.children; -}; - -// Preact Options Hook called each time a vnode is created. -const old = options.vnode; -options.vnode = ( vnode ) => { - if ( vnode.props.__directives ) { - const props = vnode.props; - const directives = props.__directives; - delete props.__directives; - vnode.props = { - type: vnode.type, - directives, - props, - }; - vnode.type = Directive; - } - - if ( old ) old( vnode ); -}; diff --git a/packages/block-library/src/utils/interactivity/hydration.js b/packages/block-library/src/utils/interactivity/hydration.js deleted file mode 100644 index 2fc34eeb64b9b5..00000000000000 --- a/packages/block-library/src/utils/interactivity/hydration.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * External dependencies - */ -import { hydrate } from 'preact'; -/** - * Internal dependencies - */ -import { toVdom, hydratedIslands } from './vdom'; -import { createRootFragment } from './utils'; -import { directivePrefix } from './constants'; - -export const init = async () => { - document - .querySelectorAll( `[${ directivePrefix }island]` ) - .forEach( ( node ) => { - if ( ! hydratedIslands.has( node ) ) { - const fragment = createRootFragment( node.parentNode, node ); - const vdom = toVdom( node ); - hydrate( vdom, fragment ); - } - } ); -}; diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js deleted file mode 100644 index 6dbac1a45e88ca..00000000000000 --- a/packages/block-library/src/utils/interactivity/index.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Internal dependencies - */ -import registerDirectives from './directives'; -import { init } from './hydration'; -export { store } from './store'; - -/** - * Initialize the Interactivity API. - */ -registerDirectives(); - -document.addEventListener( 'DOMContentLoaded', async () => { - await init(); - // eslint-disable-next-line no-console - console.log( 'Interactivity API started' ); -} ); diff --git a/packages/block-library/src/utils/interactivity/store.js b/packages/block-library/src/utils/interactivity/store.js deleted file mode 100644 index d11af901352017..00000000000000 --- a/packages/block-library/src/utils/interactivity/store.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * External dependencies - */ -import { deepSignal } from 'deepsignal'; - -const isObject = ( item ) => - item && typeof item === 'object' && ! Array.isArray( item ); - -const deepMerge = ( target, source ) => { - if ( isObject( target ) && isObject( source ) ) { - for ( const key in source ) { - if ( isObject( source[ key ] ) ) { - if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); - deepMerge( target[ key ], source[ key ] ); - } else { - Object.assign( target, { [ key ]: source[ key ] } ); - } - } - } -}; - -const getSerializedState = () => { - // TODO: change the store tag ID for a better one. - const storeTag = document.querySelector( - `script[type="application/json"]#store` - ); - if ( ! storeTag ) return {}; - try { - const { state } = JSON.parse( storeTag.textContent ); - if ( isObject( state ) ) return state; - throw Error( 'Parsed state is not an object' ); - } catch ( e ) { - // eslint-disable-next-line no-console - console.log( e ); - } - return {}; -}; - -const rawState = getSerializedState(); -export const rawStore = { state: deepSignal( rawState ) }; - -export const store = ( { state, ...block } ) => { - deepMerge( rawStore, block ); - deepMerge( rawState, state ); -}; diff --git a/packages/block-library/src/utils/interactivity/utils.js b/packages/block-library/src/utils/interactivity/utils.js deleted file mode 100644 index 21d15da2f94ff9..00000000000000 --- a/packages/block-library/src/utils/interactivity/utils.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * External dependencies - */ -import { useRef, useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; - -function afterNextFrame( callback ) { - const done = () => { - window.cancelAnimationFrame( raf ); - setTimeout( callback ); - }; - const raf = window.requestAnimationFrame( done ); -} - -// Using the mangled properties: -// this.c: this._callback -// this.x: this._compute -// https://github.com/preactjs/signals/blob/main/mangle.json -function createFlusher( compute, notify ) { - let flush; - const dispose = effect( function () { - flush = this.c.bind( this ); - this.x = compute; - this.c = notify; - return compute(); - } ); - return { flush, dispose }; -} - -// Version of `useSignalEffect` with a `useEffect`-like execution. This hook -// implementation comes from this PR: -// https://github.com/preactjs/signals/pull/290. -// -// We need to include it here in this repo until the mentioned PR is merged. -export function useSignalEffect( cb ) { - const callback = useRef( cb ); - callback.current = cb; - - useEffect( () => { - const execute = () => callback.current(); - const notify = () => afterNextFrame( eff.flush ); - const eff = createFlusher( execute, notify ); - return eff.dispose; - }, [] ); -} - -// For wrapperless hydration. -// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c -export const createRootFragment = ( parent, replaceNode ) => { - replaceNode = [].concat( replaceNode ); - const s = replaceNode[ replaceNode.length - 1 ].nextSibling; - function insert( c, r ) { - parent.insertBefore( c, r || s ); - } - return ( parent.__k = { - nodeType: 1, - parentNode: parent, - firstChild: replaceNode[ 0 ], - childNodes: replaceNode, - insertBefore: insert, - appendChild: insert, - removeChild( c ) { - parent.removeChild( c ); - }, - } ); -}; diff --git a/packages/block-library/src/utils/interactivity/vdom.js b/packages/block-library/src/utils/interactivity/vdom.js deleted file mode 100644 index 07640319b88a8a..00000000000000 --- a/packages/block-library/src/utils/interactivity/vdom.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * External dependencies - */ -import { h } from 'preact'; -/** - * Internal dependencies - */ -import { directivePrefix as p } from './constants'; - -const ignoreAttr = `${ p }ignore`; -const islandAttr = `${ p }island`; -const directiveParser = new RegExp( `${ p }([^.]+)\.?(.*)$` ); - -export const hydratedIslands = new WeakSet(); - -// Recursive function that transforms a DOM tree into vDOM. -export function toVdom( root ) { - const treeWalker = document.createTreeWalker( - root, - 205 // ELEMENT + TEXT + COMMENT + CDATA_SECTION + PROCESSING_INSTRUCTION - ); - - function walk( node ) { - const { attributes, nodeType } = node; - - if ( nodeType === 3 ) return [ node.data ]; - if ( nodeType === 4 ) { - const next = treeWalker.nextSibling(); - node.replaceWith( new window.Text( node.nodeValue ) ); - return [ node.nodeValue, next ]; - } - if ( nodeType === 8 || nodeType === 7 ) { - const next = treeWalker.nextSibling(); - node.remove(); - return [ null, next ]; - } - - const props = {}; - const children = []; - const directives = {}; - let hasDirectives = false; - let ignore = false; - let island = false; - - for ( let i = 0; i < attributes.length; i++ ) { - const n = attributes[ i ].name; - if ( n[ p.length ] && n.slice( 0, p.length ) === p ) { - if ( n === ignoreAttr ) { - ignore = true; - } else if ( n === islandAttr ) { - island = true; - } else { - hasDirectives = true; - let val = attributes[ i ].value; - try { - val = JSON.parse( val ); - } catch ( e ) {} - const [ , prefix, suffix ] = directiveParser.exec( n ); - directives[ prefix ] = directives[ prefix ] || {}; - directives[ prefix ][ suffix || 'default' ] = val; - } - } else if ( n === 'ref' ) { - continue; - } - props[ n ] = attributes[ i ].value; - } - - if ( ignore && ! island ) - return [ - h( node.localName, { - ...props, - innerHTML: node.innerHTML, - __directives: { ignore: true }, - } ), - ]; - if ( island ) hydratedIslands.add( node ); - - if ( hasDirectives ) props.__directives = directives; - - let child = treeWalker.firstChild(); - if ( child ) { - while ( child ) { - const [ vnode, nextChild ] = walk( child ); - if ( vnode ) children.push( vnode ); - child = nextChild || treeWalker.nextSibling(); - } - treeWalker.parentNode(); - } - - return [ h( node.localName, props, children ) ]; - } - - return walk( treeWalker.currentNode ); -} diff --git a/packages/block-library/src/utils/migrate-font-family.js b/packages/block-library/src/utils/migrate-font-family.js index 7edec513e94a95..8cf90eba81e580 100644 --- a/packages/block-library/src/utils/migrate-font-family.js +++ b/packages/block-library/src/utils/migrate-font-family.js @@ -6,7 +6,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; const { cleanEmptyObject } = unlock( blockEditorPrivateApis ); diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json index 4f023d716b5c51..d0fffc8ae50769 100644 --- a/packages/block-library/src/verse/block.json +++ b/packages/block-library/src/verse/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/verse", "title": "Verse", "category": "text", @@ -46,7 +46,11 @@ }, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } }, "__experimentalBorder": { "radius": true, diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json index 6f53246cc88010..debe6f20fe53f7 100644 --- a/packages/block-library/src/video/block.json +++ b/packages/block-library/src/video/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/video", "title": "Video", "category": "media", @@ -83,7 +83,11 @@ "align": true, "spacing": { "margin": true, - "padding": true + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } } }, "editorStyle": "wp-block-video-editor", diff --git a/packages/block-library/src/video/style.scss b/packages/block-library/src/video/style.scss index e6b3a1b3493d71..e8d99186f9f7ac 100644 --- a/packages/block-library/src/video/style.scss +++ b/packages/block-library/src/video/style.scss @@ -3,6 +3,7 @@ box-sizing: border-box; video { width: 100%; + vertical-align: middle; } @supports (position: sticky) { diff --git a/packages/block-serialization-default-parser/CHANGELOG.md b/packages/block-serialization-default-parser/CHANGELOG.md index 22ef0f31cdd71b..c99b60550849e4 100644 --- a/packages/block-serialization-default-parser/CHANGELOG.md +++ b/packages/block-serialization-default-parser/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.40.0 (2023-08-16) + +## 4.39.0 (2023-08-10) + +## 4.38.0 (2023-07-20) + +## 4.37.0 (2023-07-05) + +## 4.36.0 (2023-06-23) + +## 4.35.0 (2023-06-07) + ## 4.34.0 (2023-05-24) ## 4.33.0 (2023-05-10) diff --git a/packages/block-serialization-default-parser/README.md b/packages/block-serialization-default-parser/README.md index d8aae625f1c223..7fef607a29d8f6 100644 --- a/packages/block-serialization-default-parser/README.md +++ b/packages/block-serialization-default-parser/README.md @@ -1,6 +1,6 @@ # Block Serialization Default Parser -This library contains the default block serialization parser implementations for WordPress documents. It provides native PHP and JavaScript parsers that implement the [specification](https://github.com/WordPress/gutenberg/tree/HEAD/docs/contributors/code/grammar.md) from [`@wordpress/block-serialization-spec-parser`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-serialization-spec-parser/README.md) and which normally operates on the document stored in `post_content`. +This library contains the default block serialization parser implementations for WordPress documents. It provides native PHP and JavaScript parsers that implement the specification from [`@wordpress/block-serialization-spec-parser`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-serialization-spec-parser/README.md) and which normally operates on the document stored in `post_content`. ## Installation diff --git a/packages/block-serialization-default-parser/class-wp-block-parser-block.php b/packages/block-serialization-default-parser/class-wp-block-parser-block.php new file mode 100644 index 00000000000000..28377d3ede32b8 --- /dev/null +++ b/packages/block-serialization-default-parser/class-wp-block-parser-block.php @@ -0,0 +1,90 @@ +<?php +/** + * Block Serialization Parser + * + * @package WordPress + */ + +/** + * Class WP_Block_Parser_Block + * + * Holds the block structure in memory + * + * @since 5.0.0 + */ +class WP_Block_Parser_Block { + /** + * Name of block + * + * @example "core/paragraph" + * + * @since 5.0.0 + * @var string + */ + public $blockName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * Optional set of attributes from block comment delimiters + * + * @example null + * @example array( 'columns' => 3 ) + * + * @since 5.0.0 + * @var array|null + */ + public $attrs; + + /** + * List of inner blocks (of this same class) + * + * @since 5.0.0 + * @var WP_Block_Parser_Block[] + */ + public $innerBlocks; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * Resultant HTML from inside block comment delimiters + * after removing inner blocks + * + * @example "...Just <!-- wp:test /--> testing..." -> "Just testing..." + * + * @since 5.0.0 + * @var string + */ + public $innerHTML; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * List of string fragments and null markers where inner blocks were found + * + * @example array( + * 'innerHTML' => 'BeforeInnerAfter', + * 'innerBlocks' => array( block, block ), + * 'innerContent' => array( 'Before', null, 'Inner', null, 'After' ), + * ) + * + * @since 4.2.0 + * @var array + */ + public $innerContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * Constructor. + * + * Will populate object properties from the provided arguments. + * + * @since 5.0.0 + * + * @param string $name Name of block. + * @param array $attrs Optional set of attributes from block comment delimiters. + * @param array $inner_blocks List of inner blocks (of this same class). + * @param string $inner_html Resultant HTML from inside block comment delimiters after removing inner blocks. + * @param array $inner_content List of string fragments and null markers where inner blocks were found. + */ + public function __construct( $name, $attrs, $inner_blocks, $inner_html, $inner_content ) { + $this->blockName = $name; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->attrs = $attrs; + $this->innerBlocks = $inner_blocks; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->innerHTML = $inner_html; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->innerContent = $inner_content; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + } +} diff --git a/packages/block-serialization-default-parser/class-wp-block-parser-frame.php b/packages/block-serialization-default-parser/class-wp-block-parser-frame.php new file mode 100644 index 00000000000000..15938dea389aad --- /dev/null +++ b/packages/block-serialization-default-parser/class-wp-block-parser-frame.php @@ -0,0 +1,78 @@ +<?php +/** + * Block Serialization Parser + * + * @package WordPress + */ + +/** + * Class WP_Block_Parser_Frame + * + * Holds partial blocks in memory while parsing + * + * @internal + * @since 5.0.0 + */ +class WP_Block_Parser_Frame { + /** + * Full or partial block + * + * @since 5.0.0 + * @var WP_Block_Parser_Block + */ + public $block; + + /** + * Byte offset into document for start of parse token + * + * @since 5.0.0 + * @var int + */ + public $token_start; + + /** + * Byte length of entire parse token string + * + * @since 5.0.0 + * @var int + */ + public $token_length; + + /** + * Byte offset into document for after parse token ends + * (used during reconstruction of stack into parse production) + * + * @since 5.0.0 + * @var int + */ + public $prev_offset; + + /** + * Byte offset into document where leading HTML before token starts + * + * @since 5.0.0 + * @var int + */ + public $leading_html_start; + + /** + * Constructor + * + * Will populate object properties from the provided arguments. + * + * @since 5.0.0 + * + * @param WP_Block_Parser_Block $block Full or partial block. + * @param int $token_start Byte offset into document for start of parse token. + * @param int $token_length Byte length of entire parse token string. + * @param int $prev_offset Byte offset into document for after parse token ends. + * @param int $leading_html_start Byte offset into document where leading HTML before token starts. + */ + public function __construct( $block, $token_start, $token_length, $prev_offset = null, $leading_html_start = null ) { + $this->block = $block; + $this->token_start = $token_start; + $this->token_length = $token_length; + $this->prev_offset = isset( $prev_offset ) ? $prev_offset : $token_start + $token_length; + $this->leading_html_start = $leading_html_start; + } +} diff --git a/packages/block-serialization-default-parser/class-wp-block-parser.php b/packages/block-serialization-default-parser/class-wp-block-parser.php new file mode 100644 index 00000000000000..61f287b21fc4e8 --- /dev/null +++ b/packages/block-serialization-default-parser/class-wp-block-parser.php @@ -0,0 +1,413 @@ +<?php +/** + * Block Serialization Parser + * + * @package WordPress + */ + +/** + * Class WP_Block_Parser + * + * Parses a document and constructs a list of parsed block objects + * + * @since 5.0.0 + * @since 4.0.0 returns arrays not objects, all attributes are arrays + */ +class WP_Block_Parser { + /** + * Input document being parsed + * + * @example "Pre-text\n<!-- wp:paragraph -->This is inside a block!<!-- /wp:paragraph -->" + * + * @since 5.0.0 + * @var string + */ + public $document; + + /** + * Tracks parsing progress through document + * + * @since 5.0.0 + * @var int + */ + public $offset; + + /** + * List of parsed blocks + * + * @since 5.0.0 + * @var WP_Block_Parser_Block[] + */ + public $output; + + /** + * Stack of partially-parsed structures in memory during parse + * + * @since 5.0.0 + * @var WP_Block_Parser_Frame[] + */ + public $stack; + + /** + * Empty associative array, here due to PHP quirks + * + * @since 4.4.0 + * @var array empty associative array + */ + public $empty_attrs; + + /** + * Parses a document and returns a list of block structures + * + * When encountering an invalid parse will return a best-effort + * parse. In contrast to the specification parser this does not + * return an error on invalid inputs. + * + * @since 5.0.0 + * + * @param string $document Input document being parsed. + * @return array[] + */ + public function parse( $document ) { + $this->document = $document; + $this->offset = 0; + $this->output = array(); + $this->stack = array(); + $this->empty_attrs = json_decode( '{}', true ); + + while ( $this->proceed() ) { + continue; + } + + return $this->output; + } + + /** + * Processes the next token from the input document + * and returns whether to proceed eating more tokens + * + * This is the "next step" function that essentially + * takes a token as its input and decides what to do + * with that token before descending deeper into a + * nested block tree or continuing along the document + * or breaking out of a level of nesting. + * + * @internal + * @since 5.0.0 + * @return bool + */ + public function proceed() { + $next_token = $this->next_token(); + list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token; + $stack_depth = count( $this->stack ); + + // we may have some HTML soup before the next block. + $leading_html_start = $start_offset > $this->offset ? $this->offset : null; + + switch ( $token_type ) { + case 'no-more-tokens': + // if not in a block then flush output. + if ( 0 === $stack_depth ) { + $this->add_freeform(); + return false; + } + + /* + * Otherwise we have a problem + * This is an error + * + * we have options + * - treat it all as freeform text + * - assume an implicit closer (easiest when not nesting) + */ + + // for the easy case we'll assume an implicit closer. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack(); + return false; + } + + /* + * for the nested case where it's more difficult we'll + * have to assume that multiple closers are missing + * and so we'll collapse the whole stack piecewise + */ + while ( 0 < count( $this->stack ) ) { + $this->add_block_from_stack(); + } + return false; + + case 'void-block': + /* + * easy case is if we stumbled upon a void block + * in the top-level of the document + */ + if ( 0 === $stack_depth ) { + if ( isset( $leading_html_start ) ) { + $this->output[] = (array) $this->freeform( + substr( + $this->document, + $leading_html_start, + $start_offset - $leading_html_start + ) + ); + } + + $this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ); + $this->offset = $start_offset + $token_length; + return true; + } + + // otherwise we found an inner block. + $this->add_inner_block( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length + ); + $this->offset = $start_offset + $token_length; + return true; + + case 'block-opener': + // track all newly-opened blocks on the stack. + array_push( + $this->stack, + new WP_Block_Parser_Frame( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length, + $start_offset + $token_length, + $leading_html_start + ) + ); + $this->offset = $start_offset + $token_length; + return true; + + case 'block-closer': + /* + * if we're missing an opener we're in trouble + * This is an error + */ + if ( 0 === $stack_depth ) { + /* + * we have options + * - assume an implicit opener + * - assume _this_ is the opener + * - give up and close out the document + */ + $this->add_freeform(); + return false; + } + + // if we're not nesting then this is easy - close the block. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack( $start_offset ); + $this->offset = $start_offset + $token_length; + return true; + } + + /* + * otherwise we're nested and we have to close out the current + * block and add it as a new innerBlock to the parent + */ + $stack_top = array_pop( $this->stack ); + $html = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset ); + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + $stack_top->prev_offset = $start_offset + $token_length; + + $this->add_inner_block( + $stack_top->block, + $stack_top->token_start, + $stack_top->token_length, + $start_offset + $token_length + ); + $this->offset = $start_offset + $token_length; + return true; + + default: + // This is an error. + $this->add_freeform(); + return false; + } + } + + /** + * Scans the document from where we last left off + * and finds the next valid token to parse if it exists + * + * Returns the type of the find: kind of find, block information, attributes + * + * @internal + * @since 5.0.0 + * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments + * @return array + */ + public function next_token() { + $matches = null; + + /* + * aye the magic + * we're using a single RegExp to tokenize the block comment delimiters + * we're also using a trick here because the only difference between a + * block opener and a block closer is the leading `/` before `wp:` (and + * a closer has no attributes). we can trap them both and process the + * match back in PHP to see which one it was. + */ + $has_match = preg_match( + '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s', + $this->document, + $matches, + PREG_OFFSET_CAPTURE, + $this->offset + ); + + // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE. + if ( false === $has_match ) { + return array( 'no-more-tokens', null, null, null, null ); + } + + // we have no more tokens. + if ( 0 === $has_match ) { + return array( 'no-more-tokens', null, null, null, null ); + } + + list( $match, $started_at ) = $matches[0]; + + $length = strlen( $match ); + $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1]; + $is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1]; + $namespace = $matches['namespace']; + $namespace = ( isset( $namespace ) && -1 !== $namespace[1] ) ? $namespace[0] : 'core/'; + $name = $namespace . $matches['name'][0]; + $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1]; + + /* + * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays + * are associative arrays. If we use `array()` we get a JSON `[]` + */ + $attrs = $has_attrs + ? json_decode( $matches['attrs'][0], /* as-associative */ true ) + : $this->empty_attrs; + + /* + * This state isn't allowed + * This is an error + */ + if ( $is_closer && ( $is_void || $has_attrs ) ) { + // we can ignore them since they don't hurt anything. + } + + if ( $is_void ) { + return array( 'void-block', $name, $attrs, $started_at, $length ); + } + + if ( $is_closer ) { + return array( 'block-closer', $name, null, $started_at, $length ); + } + + return array( 'block-opener', $name, $attrs, $started_at, $length ); + } + + /** + * Returns a new block object for freeform HTML + * + * @internal + * @since 3.9.0 + * + * @param string $inner_html HTML content of block. + * @return WP_Block_Parser_Block freeform block object. + */ + public function freeform( $inner_html ) { + return new WP_Block_Parser_Block( null, $this->empty_attrs, array(), $inner_html, array( $inner_html ) ); + } + + /** + * Pushes a length of text from the input document + * to the output list as a freeform block. + * + * @internal + * @since 5.0.0 + * @param null $length how many bytes of document text to output. + */ + public function add_freeform( $length = null ) { + $length = $length ? $length : strlen( $this->document ) - $this->offset; + + if ( 0 === $length ) { + return; + } + + $this->output[] = (array) $this->freeform( substr( $this->document, $this->offset, $length ) ); + } + + /** + * Given a block structure from memory pushes + * a new block to the output list. + * + * @internal + * @since 5.0.0 + * @param WP_Block_Parser_Block $block The block to add to the output. + * @param int $token_start Byte offset into the document where the first token for the block starts. + * @param int $token_length Byte length of entire block from start of opening token to end of closing token. + * @param int|null $last_offset Last byte offset into document if continuing form earlier output. + */ + public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) { + $parent = $this->stack[ count( $this->stack ) - 1 ]; + $parent->block->innerBlocks[] = (array) $block; + $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset ); + + if ( ! empty( $html ) ) { + $parent->block->innerHTML .= $html; + $parent->block->innerContent[] = $html; + } + + $parent->block->innerContent[] = null; + $parent->prev_offset = $last_offset ? $last_offset : $token_start + $token_length; + } + + /** + * Pushes the top block from the parsing stack to the output list. + * + * @internal + * @since 5.0.0 + * @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML. + */ + public function add_block_from_stack( $end_offset = null ) { + $stack_top = array_pop( $this->stack ); + $prev_offset = $stack_top->prev_offset; + + $html = isset( $end_offset ) + ? substr( $this->document, $prev_offset, $end_offset - $prev_offset ) + : substr( $this->document, $prev_offset ); + + if ( ! empty( $html ) ) { + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + } + + if ( isset( $stack_top->leading_html_start ) ) { + $this->output[] = (array) $this->freeform( + substr( + $this->document, + $stack_top->leading_html_start, + $stack_top->token_start - $stack_top->leading_html_start + ) + ); + } + + $this->output[] = (array) $stack_top->block; + } +} + +/** + * WP_Block_Parser_Block class. + * + * Required for backward compatibility in WordPress Core. + */ +require_once __DIR__ . '/class-wp-block-parser-block.php'; + +/** + * WP_Block_Parser_Frame class. + * + * Required for backward compatibility in WordPress Core. + */ +require_once __DIR__ . '/class-wp-block-parser-frame.php'; diff --git a/packages/block-serialization-default-parser/package.json b/packages/block-serialization-default-parser/package.json index af6b8cfd8f73ad..0cda27b554e2e9 100644 --- a/packages/block-serialization-default-parser/package.json +++ b/packages/block-serialization-default-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-default-parser", - "version": "4.34.0", + "version": "4.40.0", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-default-parser/parser.php b/packages/block-serialization-default-parser/parser.php index 50337b588a37b9..5395650960a2d3 100644 --- a/packages/block-serialization-default-parser/parser.php +++ b/packages/block-serialization-default-parser/parser.php @@ -5,551 +5,7 @@ * @package WordPress */ -/** - * Class WP_Block_Parser_Block - * - * Holds the block structure in memory - * - * @since 5.0.0 - */ -class WP_Block_Parser_Block { - /** - * Name of block - * - * @example "core/paragraph" - * - * @since 5.0.0 - * @var string - */ - public $blockName; - - /** - * Optional set of attributes from block comment delimiters - * - * @example null - * @example array( 'columns' => 3 ) - * - * @since 5.0.0 - * @var array|null - */ - public $attrs; - - /** - * List of inner blocks (of this same class) - * - * @since 5.0.0 - * @var WP_Block_Parser_Block[] - */ - public $innerBlocks; - - /** - * Resultant HTML from inside block comment delimiters - * after removing inner blocks - * - * @example "...Just <!-- wp:test /--> testing..." -> "Just testing..." - * - * @since 5.0.0 - * @var string - */ - public $innerHTML; - - /** - * List of string fragments and null markers where inner blocks were found - * - * @example array( - * 'innerHTML' => 'BeforeInnerAfter', - * 'innerBlocks' => array( block, block ), - * 'innerContent' => array( 'Before', null, 'Inner', null, 'After' ), - * ) - * - * @since 4.2.0 - * @var array - */ - public $innerContent; - - /** - * Constructor. - * - * Will populate object properties from the provided arguments. - * - * @since 5.0.0 - * - * @param string $name Name of block. - * @param array $attrs Optional set of attributes from block comment delimiters. - * @param array $innerBlocks List of inner blocks (of this same class). - * @param string $innerHTML Resultant HTML from inside block comment delimiters after removing inner blocks. - * @param array $innerContent List of string fragments and null markers where inner blocks were found. - */ - public function __construct( $name, $attrs, $innerBlocks, $innerHTML, $innerContent ) { - $this->blockName = $name; - $this->attrs = $attrs; - $this->innerBlocks = $innerBlocks; - $this->innerHTML = $innerHTML; - $this->innerContent = $innerContent; - } -} - -/** - * Class WP_Block_Parser_Frame - * - * Holds partial blocks in memory while parsing - * - * @internal - * @since 5.0.0 - */ -class WP_Block_Parser_Frame { - /** - * Full or partial block - * - * @since 5.0.0 - * @var WP_Block_Parser_Block - */ - public $block; - - /** - * Byte offset into document for start of parse token - * - * @since 5.0.0 - * @var int - */ - public $token_start; - - /** - * Byte length of entire parse token string - * - * @since 5.0.0 - * @var int - */ - public $token_length; - - /** - * Byte offset into document for after parse token ends - * (used during reconstruction of stack into parse production) - * - * @since 5.0.0 - * @var int - */ - public $prev_offset; - - /** - * Byte offset into document where leading HTML before token starts - * - * @since 5.0.0 - * @var int - */ - public $leading_html_start; - - /** - * Constructor - * - * Will populate object properties from the provided arguments. - * - * @since 5.0.0 - * - * @param WP_Block_Parser_Block $block Full or partial block. - * @param int $token_start Byte offset into document for start of parse token. - * @param int $token_length Byte length of entire parse token string. - * @param int $prev_offset Byte offset into document for after parse token ends. - * @param int $leading_html_start Byte offset into document where leading HTML before token starts. - */ - public function __construct( $block, $token_start, $token_length, $prev_offset = null, $leading_html_start = null ) { - $this->block = $block; - $this->token_start = $token_start; - $this->token_length = $token_length; - $this->prev_offset = isset( $prev_offset ) ? $prev_offset : $token_start + $token_length; - $this->leading_html_start = $leading_html_start; - } -} - -/** - * Class WP_Block_Parser - * - * Parses a document and constructs a list of parsed block objects - * - * @since 5.0.0 - * @since 4.0.0 returns arrays not objects, all attributes are arrays - */ -class WP_Block_Parser { - /** - * Input document being parsed - * - * @example "Pre-text\n<!-- wp:paragraph -->This is inside a block!<!-- /wp:paragraph -->" - * - * @since 5.0.0 - * @var string - */ - public $document; - - /** - * Tracks parsing progress through document - * - * @since 5.0.0 - * @var int - */ - public $offset; - - /** - * List of parsed blocks - * - * @since 5.0.0 - * @var WP_Block_Parser_Block[] - */ - public $output; - - /** - * Stack of partially-parsed structures in memory during parse - * - * @since 5.0.0 - * @var WP_Block_Parser_Frame[] - */ - public $stack; - - /** - * Empty associative array, here due to PHP quirks - * - * @since 4.4.0 - * @var array empty associative array - */ - public $empty_attrs; - - /** - * Parses a document and returns a list of block structures - * - * When encountering an invalid parse will return a best-effort - * parse. In contrast to the specification parser this does not - * return an error on invalid inputs. - * - * @since 5.0.0 - * - * @param string $document Input document being parsed. - * @return array[] - */ - public function parse( $document ) { - $this->document = $document; - $this->offset = 0; - $this->output = array(); - $this->stack = array(); - $this->empty_attrs = json_decode( '{}', true ); - - while ( $this->proceed() ) { - continue; - } - - return $this->output; - } - - /** - * Processes the next token from the input document - * and returns whether to proceed eating more tokens - * - * This is the "next step" function that essentially - * takes a token as its input and decides what to do - * with that token before descending deeper into a - * nested block tree or continuing along the document - * or breaking out of a level of nesting. - * - * @internal - * @since 5.0.0 - * @return bool - */ - public function proceed() { - $next_token = $this->next_token(); - list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token; - $stack_depth = count( $this->stack ); - - // we may have some HTML soup before the next block. - $leading_html_start = $start_offset > $this->offset ? $this->offset : null; - - switch ( $token_type ) { - case 'no-more-tokens': - // if not in a block then flush output. - if ( 0 === $stack_depth ) { - $this->add_freeform(); - return false; - } - - /* - * Otherwise we have a problem - * This is an error - * - * we have options - * - treat it all as freeform text - * - assume an implicit closer (easiest when not nesting) - */ - - // for the easy case we'll assume an implicit closer. - if ( 1 === $stack_depth ) { - $this->add_block_from_stack(); - return false; - } - - /* - * for the nested case where it's more difficult we'll - * have to assume that multiple closers are missing - * and so we'll collapse the whole stack piecewise - */ - while ( 0 < count( $this->stack ) ) { - $this->add_block_from_stack(); - } - return false; - - case 'void-block': - /* - * easy case is if we stumbled upon a void block - * in the top-level of the document - */ - if ( 0 === $stack_depth ) { - if ( isset( $leading_html_start ) ) { - $this->output[] = (array) $this->freeform( - substr( - $this->document, - $leading_html_start, - $start_offset - $leading_html_start - ) - ); - } - - $this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ); - $this->offset = $start_offset + $token_length; - return true; - } - - // otherwise we found an inner block. - $this->add_inner_block( - new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), - $start_offset, - $token_length - ); - $this->offset = $start_offset + $token_length; - return true; - - case 'block-opener': - // track all newly-opened blocks on the stack. - array_push( - $this->stack, - new WP_Block_Parser_Frame( - new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), - $start_offset, - $token_length, - $start_offset + $token_length, - $leading_html_start - ) - ); - $this->offset = $start_offset + $token_length; - return true; - - case 'block-closer': - /* - * if we're missing an opener we're in trouble - * This is an error - */ - if ( 0 === $stack_depth ) { - /* - * we have options - * - assume an implicit opener - * - assume _this_ is the opener - * - give up and close out the document - */ - $this->add_freeform(); - return false; - } - - // if we're not nesting then this is easy - close the block. - if ( 1 === $stack_depth ) { - $this->add_block_from_stack( $start_offset ); - $this->offset = $start_offset + $token_length; - return true; - } - - /* - * otherwise we're nested and we have to close out the current - * block and add it as a new innerBlock to the parent - */ - $stack_top = array_pop( $this->stack ); - $html = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset ); - $stack_top->block->innerHTML .= $html; - $stack_top->block->innerContent[] = $html; - $stack_top->prev_offset = $start_offset + $token_length; - - $this->add_inner_block( - $stack_top->block, - $stack_top->token_start, - $stack_top->token_length, - $start_offset + $token_length - ); - $this->offset = $start_offset + $token_length; - return true; - - default: - // This is an error. - $this->add_freeform(); - return false; - } - } - - /** - * Scans the document from where we last left off - * and finds the next valid token to parse if it exists - * - * Returns the type of the find: kind of find, block information, attributes - * - * @internal - * @since 5.0.0 - * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments - * @return array - */ - public function next_token() { - $matches = null; - - /* - * aye the magic - * we're using a single RegExp to tokenize the block comment delimiters - * we're also using a trick here because the only difference between a - * block opener and a block closer is the leading `/` before `wp:` (and - * a closer has no attributes). we can trap them both and process the - * match back in PHP to see which one it was. - */ - $has_match = preg_match( - '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s', - $this->document, - $matches, - PREG_OFFSET_CAPTURE, - $this->offset - ); - - // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE. - if ( false === $has_match ) { - return array( 'no-more-tokens', null, null, null, null ); - } - - // we have no more tokens. - if ( 0 === $has_match ) { - return array( 'no-more-tokens', null, null, null, null ); - } - - list( $match, $started_at ) = $matches[0]; - - $length = strlen( $match ); - $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1]; - $is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1]; - $namespace = $matches['namespace']; - $namespace = ( isset( $namespace ) && -1 !== $namespace[1] ) ? $namespace[0] : 'core/'; - $name = $namespace . $matches['name'][0]; - $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1]; - - /* - * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays - * are associative arrays. If we use `array()` we get a JSON `[]` - */ - $attrs = $has_attrs - ? json_decode( $matches['attrs'][0], /* as-associative */ true ) - : $this->empty_attrs; - - /* - * This state isn't allowed - * This is an error - */ - if ( $is_closer && ( $is_void || $has_attrs ) ) { - // we can ignore them since they don't hurt anything. - } - - if ( $is_void ) { - return array( 'void-block', $name, $attrs, $started_at, $length ); - } - - if ( $is_closer ) { - return array( 'block-closer', $name, null, $started_at, $length ); - } - - return array( 'block-opener', $name, $attrs, $started_at, $length ); - } - - /** - * Returns a new block object for freeform HTML - * - * @internal - * @since 3.9.0 - * - * @param string $innerHTML HTML content of block. - * @return WP_Block_Parser_Block freeform block object. - */ - public function freeform( $innerHTML ) { - return new WP_Block_Parser_Block( null, $this->empty_attrs, array(), $innerHTML, array( $innerHTML ) ); - } - - /** - * Pushes a length of text from the input document - * to the output list as a freeform block. - * - * @internal - * @since 5.0.0 - * @param null $length how many bytes of document text to output. - */ - public function add_freeform( $length = null ) { - $length = $length ? $length : strlen( $this->document ) - $this->offset; - - if ( 0 === $length ) { - return; - } - - $this->output[] = (array) $this->freeform( substr( $this->document, $this->offset, $length ) ); - } - - /** - * Given a block structure from memory pushes - * a new block to the output list. - * - * @internal - * @since 5.0.0 - * @param WP_Block_Parser_Block $block The block to add to the output. - * @param int $token_start Byte offset into the document where the first token for the block starts. - * @param int $token_length Byte length of entire block from start of opening token to end of closing token. - * @param int|null $last_offset Last byte offset into document if continuing form earlier output. - */ - public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) { - $parent = $this->stack[ count( $this->stack ) - 1 ]; - $parent->block->innerBlocks[] = (array) $block; - $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset ); - - if ( ! empty( $html ) ) { - $parent->block->innerHTML .= $html; - $parent->block->innerContent[] = $html; - } - - $parent->block->innerContent[] = null; - $parent->prev_offset = $last_offset ? $last_offset : $token_start + $token_length; - } - - /** - * Pushes the top block from the parsing stack to the output list. - * - * @internal - * @since 5.0.0 - * @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML. - */ - public function add_block_from_stack( $end_offset = null ) { - $stack_top = array_pop( $this->stack ); - $prev_offset = $stack_top->prev_offset; - - $html = isset( $end_offset ) - ? substr( $this->document, $prev_offset, $end_offset - $prev_offset ) - : substr( $this->document, $prev_offset ); - - if ( ! empty( $html ) ) { - $stack_top->block->innerHTML .= $html; - $stack_top->block->innerContent[] = $html; - } - - if ( isset( $stack_top->leading_html_start ) ) { - $this->output[] = (array) $this->freeform( - substr( - $this->document, - $stack_top->leading_html_start, - $stack_top->token_start - $stack_top->leading_html_start - ) - ); - } - - $this->output[] = (array) $stack_top->block; - } -} +// Require files. +require_once __DIR__ . '/class-wp-block-parser-block.php'; +require_once __DIR__ . '/class-wp-block-parser-frame.php'; +require_once __DIR__ . '/class-wp-block-parser.php'; diff --git a/packages/block-serialization-spec-parser/CHANGELOG.md b/packages/block-serialization-spec-parser/CHANGELOG.md index ff7405164f7f84..799f24ec663660 100644 --- a/packages/block-serialization-spec-parser/CHANGELOG.md +++ b/packages/block-serialization-spec-parser/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.40.0 (2023-08-16) + +## 4.39.0 (2023-08-10) + +## 4.38.0 (2023-07-20) + +## 4.37.0 (2023-07-05) + +## 4.36.0 (2023-06-23) + +## 4.35.0 (2023-06-07) + ## 4.34.0 (2023-05-24) ## 4.33.0 (2023-05-10) diff --git a/packages/block-serialization-spec-parser/README.md b/packages/block-serialization-spec-parser/README.md index 5c4dc865821219..50b0aae46f0874 100644 --- a/packages/block-serialization-spec-parser/README.md +++ b/packages/block-serialization-spec-parser/README.md @@ -1,6 +1,6 @@ # Block Serialization Spec Parser -This library contains the grammar file (`grammar.pegjs`) for WordPress posts which is a block serialization [_specification_](https://github.com/WordPress/gutenberg/tree/HEAD/docs/contributors/code/grammar.md) which is used to generate the actual _parser_ which is also bundled in this package. +This library contains the grammar file (`grammar.pegjs`) for WordPress posts which is a block serialization _specification_ which is used to generate the actual _parser_ which is also bundled in this package. PEG parser generators are available in many languages, though different libraries may require some translation of this grammar into their syntax. For more information see: diff --git a/packages/block-serialization-spec-parser/package.json b/packages/block-serialization-spec-parser/package.json index fc4bb21348fd33..7e39e035848be6 100644 --- a/packages/block-serialization-spec-parser/package.json +++ b/packages/block-serialization-spec-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-spec-parser", - "version": "4.34.0", + "version": "4.40.0", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md index a8f4839207f020..34c7f2c7ceb508 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 12.17.0 (2023-08-16) + +## 12.16.0 (2023-08-10) + +## 12.15.0 (2023-07-20) + +## 12.14.0 (2023-07-05) + +## 12.13.0 (2023-06-23) + +## 12.12.0 (2023-06-07) + ## 12.11.0 (2023-05-24) ## 12.10.0 (2023-05-10) diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 91cfec30c6a726..01547ca24ef683 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -458,6 +458,7 @@ _Parameters_ - _options.mode_ `[string]`: Handle content as blocks or inline content. _ 'AUTO': Decide based on the content passed. _ 'INLINE': Always handle as inline content, and return string. \* 'BLOCKS': Always handle as blocks, and return array of blocks. - _options.tagName_ `[Array]`: The tag into which content will be inserted. - _options.preserveWhiteSpace_ `[boolean]`: Whether or not to preserve consequent white space. +- _options.disableFilters_ `[boolean]`: Whether or not to filter non semantic content. _Returns_ diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 8a1c7ff15768ba..1dee1f1d4ae004 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blocks", - "version": "12.11.0", + "version": "12.17.0", "description": "Block API for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -45,13 +45,13 @@ "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", + "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "hpq": "^1.3.0", "is-plain-object": "^5.0.0", - "lodash": "^4.17.21", "memize": "^2.1.0", "rememo": "^4.0.2", - "remove-accents": "^0.4.2", + "remove-accents": "^0.5.0", "showdown": "^1.9.1", "simple-html-tokenizer": "^0.5.7", "uuid": "^8.3.0" diff --git a/packages/blocks/src/api/constants.js b/packages/blocks/src/api/constants.js index 6fe04c07de1bfb..2afdee93278ce7 100644 --- a/packages/blocks/src/api/constants.js +++ b/packages/blocks/src/api/constants.js @@ -148,6 +148,14 @@ export const __EXPERIMENTAL_STYLE_PROPERTY = { value: [ 'elements', 'button', 'color', 'background' ], support: [ 'color', 'button' ], }, + headingColor: { + value: [ 'elements', 'heading', 'color', 'text' ], + support: [ 'color', 'heading' ], + }, + headingBackgroundColor: { + value: [ 'elements', 'heading', 'color', 'background' ], + support: [ 'color', 'heading' ], + }, fontFamily: { value: [ 'typography', 'fontFamily' ], support: [ 'typography', '__experimentalFontFamily' ], @@ -215,6 +223,11 @@ export const __EXPERIMENTAL_STYLE_PROPERTY = { support: [ 'typography', '__experimentalLetterSpacing' ], useEngine: true, }, + writingMode: { + value: [ 'typography', 'writingMode' ], + support: [ 'typography', '__experimentalWritingMode' ], + useEngine: true, + }, '--wp--style--root--padding': { value: [ 'spacing', 'padding' ], support: [ 'spacing', 'padding' ], diff --git a/packages/blocks/src/api/parser/index.js b/packages/blocks/src/api/parser/index.js index 52facfb212c8c4..f8ff0c68964dc3 100644 --- a/packages/blocks/src/api/parser/index.js +++ b/packages/blocks/src/api/parser/index.js @@ -101,6 +101,7 @@ export function normalizeRawBlock( rawBlock, options ) { // meaning there are no negative consequences to repeated autop calls. if ( rawBlockName === fallbackBlockName && + rawBlockName === 'core/freeform' && ! options?.__unstableSkipAutop ) { rawInnerHTML = autop( rawInnerHTML ).trim(); diff --git a/packages/blocks/src/api/parser/test/index.js b/packages/blocks/src/api/parser/test/index.js index 589d6b81be5536..85e51e9d0af025 100644 --- a/packages/blocks/src/api/parser/test/index.js +++ b/packages/blocks/src/api/parser/test/index.js @@ -90,19 +90,30 @@ describe( 'block parser', () => { } ); it( 'should fall back to the freeform content handler if block type not specified', () => { - registerBlockType( 'core/freeform-block', unknownBlockSettings ); - setFreeformContentHandlerName( 'core/freeform-block' ); + registerBlockType( 'core/freeform', unknownBlockSettings ); + setFreeformContentHandlerName( 'core/freeform' ); const block = parseRawBlock( { innerHTML: 'content', } ); - expect( block.name ).toEqual( 'core/freeform-block' ); + expect( block.name ).toEqual( 'core/freeform' ); expect( block.attributes ).toEqual( { content: '<p>content</p>' } ); } ); + it( 'skips adding paragraph tags if freeform block is set to core/html', () => { + registerBlockType( 'core/html', unknownBlockSettings ); + setFreeformContentHandlerName( 'core/html' ); + + const block = parseRawBlock( { + innerHTML: 'content', + } ); + expect( block.name ).toEqual( 'core/html' ); + expect( block.attributes ).toEqual( { content: 'content' } ); + } ); + it( 'skips adding paragraph tags if __unstableSkipAutop is passed as an option', () => { - registerBlockType( 'core/freeform-block', unknownBlockSettings ); - setFreeformContentHandlerName( 'core/freeform-block' ); + registerBlockType( 'core/freeform', unknownBlockSettings ); + setFreeformContentHandlerName( 'core/freeform' ); const block = parseRawBlock( { @@ -112,7 +123,7 @@ describe( 'block parser', () => { __unstableSkipAutop: true, } ); - expect( block.name ).toEqual( 'core/freeform-block' ); + expect( block.name ).toEqual( 'core/freeform' ); expect( block.attributes ).toEqual( { content: 'content' } ); } ); diff --git a/packages/blocks/src/api/raw-handling/paste-handler.js b/packages/blocks/src/api/raw-handling/paste-handler.js index c4ad40e0b1f509..f0acfac9fa7614 100644 --- a/packages/blocks/src/api/raw-handling/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/paste-handler.js @@ -8,7 +8,6 @@ import { getPhrasingContentSchema, removeInvalidHTML } from '@wordpress/dom'; */ import { htmlToBlocks } from './html-to-blocks'; import { hasBlockSupport } from '../registration'; -import { getBlockInnerHTML } from '../serializer'; import parse from '../parser'; import normaliseBlocks from './normalise-blocks'; import specialCommentConverter from './special-comment-converter'; @@ -66,6 +65,40 @@ function filterInlineHTML( HTML, preserveWhiteSpace ) { return HTML; } +/** + * If we're allowed to return inline content, and there is only one inlineable + * block, and the original plain text content does not have any line breaks, + * then treat it as inline paste. + * + * @param {Object} options + * @param {Array} options.blocks + * @param {string} options.plainText + * @param {string} options.mode + */ +function maybeConvertToInline( { blocks, plainText, mode } ) { + if ( + mode === 'AUTO' && + blocks.length === 1 && + hasBlockSupport( blocks[ 0 ].name, '__unstablePasteTextInline', false ) + ) { + const trimRegex = /^[\n]+|[\n]+$/g; + // Don't catch line breaks at the start or end. + const trimmedPlainText = plainText.replace( trimRegex, '' ); + + if ( + trimmedPlainText !== '' && + trimmedPlainText.indexOf( '\n' ) === -1 + ) { + const target = blocks[ 0 ].innerBlocks.length + ? blocks[ 0 ].innerBlocks[ 0 ] + : blocks[ 0 ]; + return target.attributes.content; + } + } + + return blocks; +} + /** * Converts an HTML string to known blocks. Strips everything else. * @@ -79,6 +112,7 @@ function filterInlineHTML( HTML, preserveWhiteSpace ) { * @param {Array} [options.tagName] The tag into which content will be inserted. * @param {boolean} [options.preserveWhiteSpace] Whether or not to preserve consequent white space. * + * @param {boolean} [options.disableFilters] Whether or not to filter non semantic content. * @return {Array|string} A list of blocks or a string, depending on `handlerMode`. */ export function pasteHandler( { @@ -87,6 +121,7 @@ export function pasteHandler( { mode = 'AUTO', tagName, preserveWhiteSpace, + disableFilters, } ) { // First of all, strip any meta tags. HTML = HTML.replace( /<meta[^>]+>/g, '' ); @@ -121,6 +156,14 @@ export function pasteHandler( { HTML = HTML.normalize(); } + if ( disableFilters ) { + return maybeConvertToInline( { + blocks: htmlToBlocks( normaliseBlocks( HTML ), pasteHandler ), + plainText, + mode, + } ); + } + // Parse Markdown (and encoded HTML) if: // * There is a plain text version. // * There is no HTML version, or it has no formatting. @@ -219,28 +262,5 @@ export function pasteHandler( { .flat() .filter( Boolean ); - // If we're allowed to return inline content, and there is only one - // inlineable block, and the original plain text content does not have any - // line breaks, then treat it as inline paste. - if ( - mode === 'AUTO' && - blocks.length === 1 && - hasBlockSupport( blocks[ 0 ].name, '__unstablePasteTextInline', false ) - ) { - const trimRegex = /^[\n]+|[\n]+$/g; - // Don't catch line breaks at the start or end. - const trimmedPlainText = plainText.replace( trimRegex, '' ); - - if ( - trimmedPlainText !== '' && - trimmedPlainText.indexOf( '\n' ) === -1 - ) { - return removeInvalidHTML( - getBlockInnerHTML( blocks[ 0 ] ), - phrasingContentSchema - ).replace( trimRegex, '' ); - } - } - - return blocks; + return maybeConvertToInline( { blocks, plainText, mode } ); } diff --git a/packages/blocks/src/api/raw-handling/utils.js b/packages/blocks/src/api/raw-handling/utils.js index 8d1b4a5ef3684e..76818f2663627d 100644 --- a/packages/blocks/src/api/raw-handling/utils.js +++ b/packages/blocks/src/api/raw-handling/utils.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { mergeWith } from 'lodash'; +import deepmerge from 'deepmerge'; /** * WordPress dependencies @@ -14,6 +14,41 @@ import { isPhrasingContent, getPhrasingContentSchema } from '@wordpress/dom'; import { hasBlockSupport } from '..'; import { getRawTransforms } from './get-raw-transforms'; +const customMerge = ( key ) => { + return ( srcValue, objValue ) => { + switch ( key ) { + case 'children': { + if ( objValue === '*' || srcValue === '*' ) { + return '*'; + } + + return { ...objValue, ...srcValue }; + } + case 'attributes': + case 'require': { + return [ ...( objValue || [] ), ...( srcValue || [] ) ]; + } + case 'isMatch': { + // If one of the values being merge is undefined (matches everything), + // the result of the merge will be undefined. + if ( ! objValue || ! srcValue ) { + return undefined; + } + // When merging two isMatch functions, the result is a new function + // that returns if one of the source functions returns true. + return ( ...args ) => { + return objValue( ...args ) || srcValue( ...args ); + }; + } + } + + return deepmerge( objValue, srcValue, { + customMerge, + clone: false, + } ); + }; +}; + export function getBlockContentSchemaFromTransforms( transforms, context ) { const phrasingContentSchema = getPhrasingContentSchema( context ); const schemaArgs = { phrasingContentSchema, isPaste: context === 'paste' }; @@ -51,32 +86,9 @@ export function getBlockContentSchemaFromTransforms( transforms, context ) { ); } ); - return mergeWith( {}, ...schemas, ( objValue, srcValue, key ) => { - switch ( key ) { - case 'children': { - if ( objValue === '*' || srcValue === '*' ) { - return '*'; - } - - return { ...objValue, ...srcValue }; - } - case 'attributes': - case 'require': { - return [ ...( objValue || [] ), ...( srcValue || [] ) ]; - } - case 'isMatch': { - // If one of the values being merge is undefined (matches everything), - // the result of the merge will be undefined. - if ( ! objValue || ! srcValue ) { - return undefined; - } - // When merging two isMatch functions, the result is a new function - // that returns if one of the source functions returns true. - return ( ...args ) => { - return objValue( ...args ) || srcValue( ...args ); - }; - } - } + return deepmerge.all( schemas, { + customMerge, + clone: false, } ); } diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index f601a3f59314fe..72c0a30db02059 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -1,10 +1,5 @@ /* eslint no-console: [ 'error', { allow: [ 'error', 'warn' ] } ] */ -/** - * External dependencies - */ -import { camelCase } from 'change-case'; - /** * WordPress dependencies */ @@ -15,8 +10,8 @@ import { _x } from '@wordpress/i18n'; * Internal dependencies */ import i18nBlockSchema from './i18n-block.json'; -import { BLOCK_ICON_DEFAULT } from './constants'; import { store as blocksStore } from '../store'; +import { unlock } from '../lock-unlock'; /** * An icon type definition. One of a Dashicon slug, an element, @@ -129,8 +124,6 @@ import { store as blocksStore } from '../store'; * then no preview is shown. */ -export const serverSideBlockDefinitions = {}; - function isObject( object ) { return object !== null && typeof object === 'object'; } @@ -142,54 +135,9 @@ function isObject( object ) { */ // eslint-disable-next-line camelcase export function unstable__bootstrapServerSideBlockDefinitions( definitions ) { - for ( const blockName of Object.keys( definitions ) ) { - // Don't overwrite if already set. It covers the case when metadata - // was initialized from the server. - if ( serverSideBlockDefinitions[ blockName ] ) { - // We still need to polyfill `apiVersion` for WordPress version - // lower than 5.7. If it isn't present in the definition shared - // from the server, we try to fallback to the definition passed. - // @see https://github.com/WordPress/gutenberg/pull/29279 - if ( - serverSideBlockDefinitions[ blockName ].apiVersion === - undefined && - definitions[ blockName ].apiVersion - ) { - serverSideBlockDefinitions[ blockName ].apiVersion = - definitions[ blockName ].apiVersion; - } - // The `ancestor` prop is not included in the definitions shared - // from the server yet, so it needs to be polyfilled as well. - // @see https://github.com/WordPress/gutenberg/pull/39894 - if ( - serverSideBlockDefinitions[ blockName ].ancestor === - undefined && - definitions[ blockName ].ancestor - ) { - serverSideBlockDefinitions[ blockName ].ancestor = - definitions[ blockName ].ancestor; - } - // The `selectors` prop is not yet included in the server provided - // definitions. Polyfill it as well. This can be removed when the - // minimum supported WordPress is >= 6.3. - if ( - serverSideBlockDefinitions[ blockName ].selectors === - undefined && - definitions[ blockName ].selectors - ) { - serverSideBlockDefinitions[ blockName ].selectors = - definitions[ blockName ].selectors; - } - continue; - } - - serverSideBlockDefinitions[ blockName ] = Object.fromEntries( - Object.entries( definitions[ blockName ] ) - .filter( - ( [ , value ] ) => value !== null && value !== undefined - ) - .map( ( [ key, value ] ) => [ camelCase( key ), value ] ) - ); + const { addBootstrappedBlockType } = unlock( dispatch( blocksStore ) ); + for ( const [ name, blockType ] of Object.entries( definitions ) ) { + addBootstrappedBlockType( name, blockType ); } } @@ -219,6 +167,7 @@ function getBlockSettingsFromMetadata( { textdomain, ...metadata } ) { 'styles', 'example', 'variations', + '__experimentalAutoInsert', ]; const settings = Object.fromEntries( @@ -290,29 +239,16 @@ export function registerBlockType( blockNameOrMetadata, settings ) { return; } + const { addBootstrappedBlockType, addUnprocessedBlockType } = unlock( + dispatch( blocksStore ) + ); + if ( isObject( blockNameOrMetadata ) ) { - unstable__bootstrapServerSideBlockDefinitions( { - [ name ]: getBlockSettingsFromMetadata( blockNameOrMetadata ), - } ); + const metadata = getBlockSettingsFromMetadata( blockNameOrMetadata ); + addBootstrappedBlockType( name, metadata ); } - const blockType = { - name, - icon: BLOCK_ICON_DEFAULT, - keywords: [], - attributes: {}, - providesContext: {}, - usesContext: [], - selectors: {}, - supports: {}, - styles: [], - variations: [], - save: () => null, - ...serverSideBlockDefinitions?.[ name ], - ...settings, - }; - - dispatch( blocksStore ).__experimentalRegisterBlockType( blockType ); + addUnprocessedBlockType( name, settings ); return select( blocksStore ).getBlockType( name ); } diff --git a/packages/blocks/src/api/serializer.js b/packages/blocks/src/api/serializer.js index 3300e0893d2459..29ac9f9a2228f3 100644 --- a/packages/blocks/src/api/serializer.js +++ b/packages/blocks/src/api/serializer.js @@ -81,12 +81,14 @@ const innerBlocksPropsProvider = {}; */ export function getBlockProps( props = {} ) { const { blockType, attributes } = blockPropsProvider; - return applyFilters( - 'blocks.getSaveContent.extraProps', - { ...props }, - blockType, - attributes - ); + return getBlockProps.skipFilters + ? props + : applyFilters( + 'blocks.getSaveContent.extraProps', + { ...props }, + blockType, + attributes + ); } /** @@ -96,6 +98,11 @@ export function getBlockProps( props = {} ) { */ export function getInnerBlocksProps( props = {} ) { const { innerBlocks } = innerBlocksPropsProvider; + // Allow a different component to be passed to getSaveElement to handle + // inner blocks, bypassing the default serialisation. + if ( ! Array.isArray( innerBlocks ) ) { + return { ...props, children: innerBlocks }; + } // Value is an array of blocks, so defer to block serializer. const html = serialize( innerBlocks, { isInnerBlocks: true } ); // Use special-cased raw HTML tag to avoid default escaping. @@ -120,6 +127,9 @@ export function getSaveElement( innerBlocks = [] ) { const blockType = normalizeBlockType( blockTypeOrName ); + + if ( ! blockType?.save ) return null; + let { save } = blockType; // Component classes are unsupported for save since serialization must @@ -228,7 +238,8 @@ export function getCommentAttributes( blockType, attributes ) { // Ignore default value. if ( 'default' in attributeSchema && - attributeSchema.default === value + JSON.stringify( attributeSchema.default ) === + JSON.stringify( value ) ) { return accumulator; } @@ -379,7 +390,8 @@ export function __unstableSerializeAndClean( blocks ) { // pre-block-editor removep'd content formatting. if ( blocks.length === 1 && - blocks[ 0 ].name === getFreeformContentHandlerName() + blocks[ 0 ].name === getFreeformContentHandlerName() && + blocks[ 0 ].name === 'core/freeform' ) { content = removep( content ); } diff --git a/packages/blocks/src/api/test/registration.js b/packages/blocks/src/api/test/registration.js index dbb11e4d23001a..877c9fdc4a038a 100644 --- a/packages/blocks/src/api/test/registration.js +++ b/packages/blocks/src/api/test/registration.js @@ -5,7 +5,7 @@ */ import { addFilter, removeAllFilters, removeFilter } from '@wordpress/hooks'; import { logged } from '@wordpress/deprecated'; -import { select } from '@wordpress/data'; +import { select, dispatch } from '@wordpress/data'; /** * Internal dependencies @@ -28,12 +28,12 @@ import { getBlockSupport, hasBlockSupport, isReusableBlock, - serverSideBlockDefinitions, unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase } from '../registration'; import { BLOCK_ICON_DEFAULT, DEPRECATED_ENTRY_KEYS } from '../constants'; import { omit } from '../utils'; import { store as blocksStore } from '../../store'; +import { unlock } from '../../lock-unlock'; const noop = () => {}; @@ -49,19 +49,14 @@ describe( 'blocks', () => { title: 'block title', }; - beforeAll( () => { - // Initialize the block store. - require( '../../store' ); - } ); - afterEach( () => { - getBlockTypes().forEach( ( block ) => { - unregisterBlockType( block.name ); - } ); + const registeredNames = Object.keys( + unlock( select( blocksStore ) ).getUnprocessedBlockTypes() + ); + dispatch( blocksStore ).removeBlockTypes( registeredNames ); setFreeformContentHandlerName( undefined ); setUnregisteredTypeHandlerName( undefined ); setDefaultBlockName( undefined ); - unstable__bootstrapServerSideBlockDefinitions( {} ); // Reset deprecation logging to ensure we properly track warnings. for ( const key in logged ) { @@ -362,7 +357,7 @@ describe( 'blocks', () => { const blockName = 'core/test-block-with-incompatible-keys'; unstable__bootstrapServerSideBlockDefinitions( { [ blockName ]: { - api_version: 2, + api_version: 3, provides_context: { fontSize: 'fontSize', }, @@ -375,7 +370,7 @@ describe( 'blocks', () => { }; registerBlockType( blockName, blockType ); expect( getBlockType( blockName ) ).toEqual( { - apiVersion: 2, + apiVersion: 3, name: blockName, save: expect.any( Function ), title: 'block title', @@ -393,80 +388,6 @@ describe( 'blocks', () => { } ); } ); - // This test can be removed once the polyfill for apiVersion gets removed. - it( 'should apply apiVersion on the client when not set on the server', () => { - const blockName = 'core/test-block-back-compat'; - unstable__bootstrapServerSideBlockDefinitions( { - [ blockName ]: { - category: 'widgets', - }, - } ); - unstable__bootstrapServerSideBlockDefinitions( { - [ blockName ]: { - apiVersion: 2, - category: 'ignored', - }, - } ); - - const blockType = { - title: 'block title', - }; - registerBlockType( blockName, blockType ); - expect( getBlockType( blockName ) ).toEqual( { - apiVersion: 2, - name: blockName, - save: expect.any( Function ), - title: 'block title', - category: 'widgets', - icon: { src: BLOCK_ICON_DEFAULT }, - attributes: {}, - providesContext: {}, - usesContext: [], - keywords: [], - selectors: {}, - supports: {}, - styles: [], - variations: [], - } ); - } ); - - // This test can be removed once the polyfill for ancestor gets removed. - it( 'should apply ancestor on the client when not set on the server', () => { - const blockName = 'core/test-block-with-ancestor'; - unstable__bootstrapServerSideBlockDefinitions( { - [ blockName ]: { - category: 'widgets', - }, - } ); - unstable__bootstrapServerSideBlockDefinitions( { - [ blockName ]: { - ancestor: 'core/test-block-ancestor', - category: 'ignored', - }, - } ); - - const blockType = { - title: 'block title', - }; - registerBlockType( blockName, blockType ); - expect( getBlockType( blockName ) ).toEqual( { - ancestor: 'core/test-block-ancestor', - name: blockName, - save: expect.any( Function ), - title: 'block title', - category: 'widgets', - icon: { src: BLOCK_ICON_DEFAULT }, - attributes: {}, - providesContext: {}, - usesContext: [], - keywords: [], - selectors: {}, - supports: {}, - styles: [], - variations: [], - } ); - } ); - // This can be removed once polyfill adding selectors has been removed. it( 'should apply selectors on the client when not set on the server', () => { const blockName = 'core/test-block-with-selectors'; @@ -822,7 +743,6 @@ describe( 'blocks', () => { styles: [], variations: [], save: () => null, - ...serverSideBlockDefinitions[ name ], ...blockSettingsWithDeprecations, }, DEPRECATED_ENTRY_KEYS @@ -922,6 +842,34 @@ describe( 'blocks', () => { 'Declaring non-string block descriptions is deprecated since version 6.2.' ); } ); + + it( 're-applies block filters', () => { + // register block + registerBlockType( 'test/block', defaultBlockSettings ); + + // register a filter after registering a block + addFilter( + 'blocks.registerBlockType', + 'core/blocks/reapply', + ( settings ) => ( { + ...settings, + title: settings.title + ' filtered', + } ) + ); + + // check that block type has unfiltered values + expect( getBlockType( 'test/block' ).title ).toBe( + 'block title' + ); + + // reapply the block filters + dispatch( blocksStore ).reapplyBlockTypeFilters(); + + // check that block type has filtered values + expect( getBlockType( 'test/block' ).title ).toBe( + 'block title filtered' + ); + } ); } ); test( 'registers block from metadata', () => { diff --git a/packages/blocks/src/api/test/serializer.js b/packages/blocks/src/api/test/serializer.js index 22fa5bc7b6dc6a..6cb21470f9e1fa 100644 --- a/packages/blocks/src/api/test/serializer.js +++ b/packages/blocks/src/api/test/serializer.js @@ -244,7 +244,7 @@ describe( 'block serializer', () => { describe( 'serializeBlock()', () => { it( 'serializes the freeform content fallback block without comment delimiters', () => { - registerBlockType( 'core/freeform-block', { + registerBlockType( 'core/freeform', { category: 'text', title: 'freeform block', attributes: { @@ -254,8 +254,8 @@ describe( 'block serializer', () => { }, save: ( { attributes } ) => attributes.fruit, } ); - setFreeformContentHandlerName( 'core/freeform-block' ); - const block = createBlock( 'core/freeform-block', { + setFreeformContentHandlerName( 'core/freeform' ); + const block = createBlock( 'core/freeform', { fruit: 'Bananas', } ); @@ -264,7 +264,7 @@ describe( 'block serializer', () => { expect( content ).toBe( 'Bananas' ); } ); it( 'serializes the freeform content fallback block with comment delimiters in nested context', () => { - registerBlockType( 'core/freeform-block', { + registerBlockType( 'core/freeform', { category: 'text', title: 'freeform block', attributes: { @@ -274,17 +274,17 @@ describe( 'block serializer', () => { }, save: ( { attributes } ) => attributes.fruit, } ); - setFreeformContentHandlerName( 'core/freeform-block' ); - const block = createBlock( 'core/freeform-block', { + setFreeformContentHandlerName( 'core/freeform' ); + const block = createBlock( 'core/freeform', { fruit: 'Bananas', } ); const content = serializeBlock( block, { isInnerBlocks: true } ); expect( content ).toBe( - '<!-- wp:freeform-block {"fruit":"Bananas"} -->\n' + + '<!-- wp:freeform {"fruit":"Bananas"} -->\n' + 'Bananas\n' + - '<!-- /wp:freeform-block -->' + '<!-- /wp:freeform -->' ); } ); it( 'serializes the unregistered fallback block without comment delimiters', () => { diff --git a/packages/blocks/src/private-apis.js b/packages/blocks/src/lock-unlock.js similarity index 100% rename from packages/blocks/src/private-apis.js rename to packages/blocks/src/lock-unlock.js diff --git a/packages/blocks/src/store/actions.js b/packages/blocks/src/store/actions.js index 2c02fb73b03527..d3bd71c067ebe3 100644 --- a/packages/blocks/src/store/actions.js +++ b/packages/blocks/src/store/actions.js @@ -1,154 +1,17 @@ -/** - * External dependencies - */ -import { isPlainObject } from 'is-plain-object'; - /** * WordPress dependencies */ import deprecated from '@wordpress/deprecated'; -import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies */ -import { isValidIcon, normalizeIconObject, omit } from '../api/utils'; -import { DEPRECATED_ENTRY_KEYS } from '../api/constants'; +import { processBlockType } from './process-block-type'; /** @typedef {import('../api/registration').WPBlockVariation} WPBlockVariation */ /** @typedef {import('../api/registration').WPBlockType} WPBlockType */ /** @typedef {import('./reducer').WPBlockCategory} WPBlockCategory */ -const { error, warn } = window.console; - -/** - * Mapping of legacy category slugs to their latest normal values, used to - * accommodate updates of the default set of block categories. - * - * @type {Record<string,string>} - */ -const LEGACY_CATEGORY_MAPPING = { - common: 'text', - formatting: 'text', - layout: 'design', -}; - -/** - * Whether the argument is a function. - * - * @param {*} maybeFunc The argument to check. - * @return {boolean} True if the argument is a function, false otherwise. - */ -function isFunction( maybeFunc ) { - return typeof maybeFunc === 'function'; -} - -/** - * Takes the unprocessed block type data and applies all the existing filters for the registered block type. - * Next, it validates all the settings and performs additional processing to the block type definition. - * - * @param {WPBlockType} blockType Unprocessed block type settings. - * @param {Object} thunkArgs Argument object for the thunk middleware. - * @param {Function} thunkArgs.select Function to select from the store. - * - * @return {WPBlockType | undefined} The block, if it has been successfully registered; otherwise `undefined`. - */ -const processBlockType = ( blockType, { select } ) => { - const { name } = blockType; - - const settings = applyFilters( - 'blocks.registerBlockType', - { ...blockType }, - name, - null - ); - - if ( settings.description && typeof settings.description !== 'string' ) { - deprecated( 'Declaring non-string block descriptions', { - since: '6.2', - } ); - } - - if ( settings.deprecated ) { - settings.deprecated = settings.deprecated.map( ( deprecation ) => - Object.fromEntries( - Object.entries( - // Only keep valid deprecation keys. - applyFilters( - 'blocks.registerBlockType', - // Merge deprecation keys with pre-filter settings - // so that filters that depend on specific keys being - // present don't fail. - { - // Omit deprecation keys here so that deprecations - // can opt out of specific keys like "supports". - ...omit( blockType, DEPRECATED_ENTRY_KEYS ), - ...deprecation, - }, - name, - deprecation - ) - ).filter( ( [ key ] ) => DEPRECATED_ENTRY_KEYS.includes( key ) ) - ) - ); - } - - if ( ! isPlainObject( settings ) ) { - error( 'Block settings must be a valid object.' ); - return; - } - - if ( ! isFunction( settings.save ) ) { - error( 'The "save" property must be a valid function.' ); - return; - } - if ( 'edit' in settings && ! isFunction( settings.edit ) ) { - error( 'The "edit" property must be a valid function.' ); - return; - } - - // Canonicalize legacy categories to equivalent fallback. - if ( LEGACY_CATEGORY_MAPPING.hasOwnProperty( settings.category ) ) { - settings.category = LEGACY_CATEGORY_MAPPING[ settings.category ]; - } - - if ( - 'category' in settings && - ! select - .getCategories() - .some( ( { slug } ) => slug === settings.category ) - ) { - warn( - 'The block "' + - name + - '" is registered with an invalid category "' + - settings.category + - '".' - ); - delete settings.category; - } - - if ( ! ( 'title' in settings ) || settings.title === '' ) { - error( 'The block "' + name + '" must have a title.' ); - return; - } - if ( typeof settings.title !== 'string' ) { - error( 'Block titles must be strings.' ); - return; - } - - settings.icon = normalizeIconObject( settings.icon ); - if ( ! isValidIcon( settings.icon.src ) ) { - error( - 'The icon passed is invalid. ' + - 'The icon should be a string, an element, a function, or an object following the specifications documented in https://developer.wordpress.org/block-editor/developers/block-api/block-registration/#icon-optional' - ); - return; - } - - return settings; -}; - /** * Returns an action object used in signalling that block types have been added. * Ignored from documentation as the recommended usage for this action through registerBlockType from @wordpress/blocks. @@ -167,26 +30,6 @@ export function addBlockTypes( blockTypes ) { }; } -/** - * Signals that the passed block type's settings should be stored in the state. - * - * @param {WPBlockType} blockType Unprocessed block type settings. - */ -export const __experimentalRegisterBlockType = - ( blockType ) => - ( { dispatch, select } ) => { - dispatch( { - type: 'ADD_UNPROCESSED_BLOCK_TYPE', - blockType, - } ); - - const processedBlockType = processBlockType( blockType, { select } ); - if ( ! processedBlockType ) { - return; - } - dispatch.addBlockTypes( processedBlockType ); - }; - /** * Signals that all block types should be computed again. * It uses stored unprocessed block types and all the most recent list of registered filters. @@ -201,25 +44,17 @@ export const __experimentalRegisterBlockType = * 7. Filter G. * In this scenario some filters would not get applied for all blocks because they are registered too late. */ -export const __experimentalReapplyBlockTypeFilters = - () => - ( { dispatch, select } ) => { - const unprocessedBlockTypes = - select.__experimentalGetUnprocessedBlockTypes(); - - const processedBlockTypes = Object.keys( unprocessedBlockTypes ).reduce( - ( accumulator, blockName ) => { - const result = processBlockType( - unprocessedBlockTypes[ blockName ], - { select } - ); - if ( result ) { - accumulator.push( result ); - } - return accumulator; - }, - [] - ); +export function reapplyBlockTypeFilters() { + return ( { dispatch, select } ) => { + const processedBlockTypes = []; + for ( const [ name, settings ] of Object.entries( + select.getUnprocessedBlockTypes() + ) ) { + const result = dispatch( processBlockType( name, settings ) ); + if ( result ) { + processedBlockTypes.push( result ); + } + } if ( ! processedBlockTypes.length ) { return; @@ -227,6 +62,19 @@ export const __experimentalReapplyBlockTypeFilters = dispatch.addBlockTypes( processedBlockTypes ); }; +} + +export function __experimentalReapplyBlockFilters() { + deprecated( + 'wp.data.dispatch( "core/blocks" ).__experimentalReapplyBlockFilters', + { + since: '6.4', + alternative: 'reapplyBlockFilters', + } + ); + + return reapplyBlockTypeFilters(); +} /** * Returns an action object used to remove a registered block type. diff --git a/packages/blocks/src/store/index.js b/packages/blocks/src/store/index.js index ad249fd4554709..ffda3ffe000261 100644 --- a/packages/blocks/src/store/index.js +++ b/packages/blocks/src/store/index.js @@ -10,8 +10,9 @@ import reducer from './reducer'; import * as selectors from './selectors'; import * as privateSelectors from './private-selectors'; import * as actions from './actions'; +import * as privateActions from './private-actions'; import { STORE_NAME } from './constants'; -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; /** * Store definition for the blocks namespace. @@ -28,3 +29,4 @@ export const store = createReduxStore( STORE_NAME, { register( store ); unlock( store ).registerPrivateSelectors( privateSelectors ); +unlock( store ).registerPrivateActions( privateActions ); diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js new file mode 100644 index 00000000000000..bc06e231b17222 --- /dev/null +++ b/packages/blocks/src/store/private-actions.js @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { processBlockType } from './process-block-type'; + +/** @typedef {import('../api/registration').WPBlockType} WPBlockType */ + +/** + * Add bootstrapped block type metadata to the store. These metadata usually come from + * the `block.json` file and are either statically boostrapped from the server, or + * passed as the `metadata` parameter to the `registerBlockType` function. + * + * @param {string} name Block name. + * @param {WPBlockType} blockType Block type metadata. + */ +export function addBootstrappedBlockType( name, blockType ) { + return { + type: 'ADD_BOOTSTRAPPED_BLOCK_TYPE', + name, + blockType, + }; +} + +/** + * Add unprocessed block type settings to the store. These data are passed as the + * `settings` parameter to the client-side `registerBlockType` function. + * + * @param {string} name Block name. + * @param {WPBlockType} blockType Unprocessed block type settings. + */ +export function addUnprocessedBlockType( name, blockType ) { + return ( { dispatch } ) => { + dispatch( { type: 'ADD_UNPROCESSED_BLOCK_TYPE', name, blockType } ); + const processedBlockType = dispatch( + processBlockType( name, blockType ) + ); + if ( ! processedBlockType ) { + return; + } + dispatch.addBlockTypes( processedBlockType ); + }; +} diff --git a/packages/blocks/src/store/private-selectors.js b/packages/blocks/src/store/private-selectors.js index 08739e5f9ef1e5..7e4311658c8694 100644 --- a/packages/blocks/src/store/private-selectors.js +++ b/packages/blocks/src/store/private-selectors.js @@ -2,12 +2,12 @@ * External dependencies */ import createSelector from 'rememo'; -import { get } from 'lodash'; /** * Internal dependencies */ import { getBlockType } from './selectors'; +import { getValueFromObjectPath } from './utils'; import { __EXPERIMENTAL_STYLE_PROPERTY as STYLE_PROPERTY } from '../api/constants'; const ROOT_BLOCK_SUPPORTS = [ @@ -106,15 +106,7 @@ export const getSupportedStyles = createSelector( // Check for blockGap support. // Block spacing support doesn't map directly to a single style property, so needs to be handled separately. - // Also, only allow `blockGap` support if serialization has not been skipped, to be sure global spacing can be applied. - if ( - blockType?.supports?.spacing?.blockGap && - blockType?.supports?.spacing?.__experimentalSkipSerialization !== - true && - ! blockType?.supports?.spacing?.__experimentalSkipSerialization?.some?.( - ( spacingType ) => spacingType === 'blockGap' - ) - ) { + if ( blockType?.supports?.spacing?.blockGap ) { supportKeys.push( 'blockGap' ); } @@ -135,7 +127,7 @@ export const getSupportedStyles = createSelector( if ( STYLE_PROPERTY[ styleName ].support[ 0 ] in blockType.supports && - get( + getValueFromObjectPath( blockType.supports, STYLE_PROPERTY[ styleName ].support ) !== false @@ -146,7 +138,7 @@ export const getSupportedStyles = createSelector( } if ( - get( + getValueFromObjectPath( blockType.supports, STYLE_PROPERTY[ styleName ].support, false @@ -160,3 +152,27 @@ export const getSupportedStyles = createSelector( }, ( state, name ) => [ state.blockTypes[ name ] ] ); + +/** + * Returns the bootstrapped block type metadata for a give block name. + * + * @param {Object} state Data state. + * @param {string} name Block name. + * + * @return {Object} Bootstrapped block type metadata for a block. + */ +export function getBootstrappedBlockType( state, name ) { + return state.bootstrappedBlockTypes[ name ]; +} + +/** + * Returns all the unprocessed (before applying the `registerBlockType` filter) + * block type settings as passed during block registration. + * + * @param {Object} state Data state. + * + * @return {Array} Unprocessed block type settings for all blocks. + */ +export function getUnprocessedBlockTypes( state ) { + return state.unprocessedBlockTypes; +} diff --git a/packages/blocks/src/store/process-block-type.js b/packages/blocks/src/store/process-block-type.js new file mode 100644 index 00000000000000..aab198af6c66fb --- /dev/null +++ b/packages/blocks/src/store/process-block-type.js @@ -0,0 +1,159 @@ +/** + * External dependencies + */ +import { isPlainObject } from 'is-plain-object'; + +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { isValidIcon, normalizeIconObject, omit } from '../api/utils'; +import { BLOCK_ICON_DEFAULT, DEPRECATED_ENTRY_KEYS } from '../api/constants'; + +/** @typedef {import('../api/registration').WPBlockType} WPBlockType */ + +const { error, warn } = window.console; + +/** + * Mapping of legacy category slugs to their latest normal values, used to + * accommodate updates of the default set of block categories. + * + * @type {Record<string,string>} + */ +const LEGACY_CATEGORY_MAPPING = { + common: 'text', + formatting: 'text', + layout: 'design', +}; + +/** + * Takes the unprocessed block type settings, merges them with block type metadata + * and applies all the existing filters for the registered block type. + * Next, it validates all the settings and performs additional processing to the block type definition. + * + * @param {string} name Block name. + * @param {WPBlockType} blockSettings Unprocessed block type settings. + * + * @return {WPBlockType | undefined} The block, if it has been processed and can be registered; otherwise `undefined`. + */ +export const processBlockType = + ( name, blockSettings ) => + ( { select } ) => { + const blockType = { + name, + icon: BLOCK_ICON_DEFAULT, + keywords: [], + attributes: {}, + providesContext: {}, + usesContext: [], + selectors: {}, + supports: {}, + styles: [], + variations: [], + save: () => null, + ...select.getBootstrappedBlockType( name ), + ...blockSettings, + }; + + const settings = applyFilters( + 'blocks.registerBlockType', + blockType, + name, + null + ); + + if ( + settings.description && + typeof settings.description !== 'string' + ) { + deprecated( 'Declaring non-string block descriptions', { + since: '6.2', + } ); + } + + if ( settings.deprecated ) { + settings.deprecated = settings.deprecated.map( ( deprecation ) => + Object.fromEntries( + Object.entries( + // Only keep valid deprecation keys. + applyFilters( + 'blocks.registerBlockType', + // Merge deprecation keys with pre-filter settings + // so that filters that depend on specific keys being + // present don't fail. + { + // Omit deprecation keys here so that deprecations + // can opt out of specific keys like "supports". + ...omit( blockType, DEPRECATED_ENTRY_KEYS ), + ...deprecation, + }, + blockType.name, + deprecation + ) + ).filter( ( [ key ] ) => + DEPRECATED_ENTRY_KEYS.includes( key ) + ) + ) + ); + } + + if ( ! isPlainObject( settings ) ) { + error( 'Block settings must be a valid object.' ); + return; + } + + if ( typeof settings.save !== 'function' ) { + error( 'The "save" property must be a valid function.' ); + return; + } + if ( 'edit' in settings && typeof settings.edit !== 'function' ) { + error( 'The "edit" property must be a valid function.' ); + return; + } + + // Canonicalize legacy categories to equivalent fallback. + if ( LEGACY_CATEGORY_MAPPING.hasOwnProperty( settings.category ) ) { + settings.category = LEGACY_CATEGORY_MAPPING[ settings.category ]; + } + + if ( + 'category' in settings && + ! select + .getCategories() + .some( ( { slug } ) => slug === settings.category ) + ) { + warn( + 'The block "' + + name + + '" is registered with an invalid category "' + + settings.category + + '".' + ); + delete settings.category; + } + + if ( ! ( 'title' in settings ) || settings.title === '' ) { + error( 'The block "' + name + '" must have a title.' ); + return; + } + if ( typeof settings.title !== 'string' ) { + error( 'Block titles must be strings.' ); + return; + } + + settings.icon = normalizeIconObject( settings.icon ); + if ( ! isValidIcon( settings.icon.src ) ) { + error( + 'The icon passed is invalid. ' + + 'The icon should be a string, an element, a function, or an object following the specifications documented in https://developer.wordpress.org/block-editor/developers/block-api/block-registration/#icon-optional' + ); + return; + } + + return settings; + }; diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index d8f76e00fc71d5..a8f114fea79c70 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { camelCase } from 'change-case'; + /** * WordPress dependencies */ @@ -52,6 +57,72 @@ function getUniqueItemsByName( items ) { }, [] ); } +function bootstrappedBlockTypes( state = {}, action ) { + switch ( action.type ) { + case 'ADD_BOOTSTRAPPED_BLOCK_TYPE': + const { name, blockType } = action; + const serverDefinition = state[ name ]; + let newDefinition; + // Don't overwrite if already set. It covers the case when metadata + // was initialized from the server. + if ( serverDefinition ) { + // The `selectors` prop is not yet included in the server provided + // definitions and needs to be polyfilled. This can be removed when the + // minimum supported WordPress is >= 6.3. + if ( + serverDefinition.selectors === undefined && + blockType.selectors + ) { + newDefinition = { + ...serverDefinition, + selectors: blockType.selectors, + }; + } + + // The `autoInsert` prop is not yet included in the server provided + // definitions and needs to be polyfilled. This can be removed when the + // minimum supported WordPress is >= 6.4. + if ( + serverDefinition.__experimentalAutoInsert === undefined && + blockType.__experimentalAutoInsert + ) { + newDefinition = { + ...serverDefinition, + ...newDefinition, + __experimentalAutoInsert: + blockType.__experimentalAutoInsert, + }; + } + } else { + newDefinition = Object.fromEntries( + Object.entries( blockType ) + .filter( + ( [ , value ] ) => + value !== null && value !== undefined + ) + .map( ( [ key, value ] ) => [ + camelCase( key ), + value, + ] ) + ); + newDefinition.name = name; + } + + if ( newDefinition ) { + return { + ...state, + [ name ]: newDefinition, + }; + } + + return state; + case 'REMOVE_BLOCK_TYPES': + return omit( state, action.names ); + } + + return state; +} + /** * Reducer managing the unprocessed block types in a form passed when registering the by block. * It's for internal use only. It allows recomputing the processed block types on-demand after block type filters @@ -67,7 +138,7 @@ export function unprocessedBlockTypes( state = {}, action ) { case 'ADD_UNPROCESSED_BLOCK_TYPE': return { ...state, - [ action.blockType.name ]: action.blockType, + [ action.name ]: action.blockType, }; case 'REMOVE_BLOCK_TYPES': return omit( state, action.names ); @@ -300,6 +371,7 @@ export function collections( state = {}, action ) { } export default combineReducers( { + bootstrappedBlockTypes, unprocessedBlockTypes, blockTypes, blockStyles, diff --git a/packages/blocks/src/store/selectors.js b/packages/blocks/src/store/selectors.js index bf70dbc0c261c5..b2b8ab8106f097 100644 --- a/packages/blocks/src/store/selectors.js +++ b/packages/blocks/src/store/selectors.js @@ -3,13 +3,17 @@ */ import createSelector from 'rememo'; import removeAccents from 'remove-accents'; -import { get } from 'lodash'; /** * WordPress dependencies */ import { pipe } from '@wordpress/compose'; +/** + * Internal dependencies + */ +import { getValueFromObjectPath } from './utils'; + /** @typedef {import('../api/registration').WPBlockVariation} WPBlockVariation */ /** @typedef {import('../api/registration').WPBlockVariationScope} WPBlockVariationScope */ /** @typedef {import('./reducer').WPBlockCategory} WPBlockCategory */ @@ -28,17 +32,6 @@ const getNormalizedBlockType = ( state, nameOrType ) => ? getBlockType( state, nameOrType ) : nameOrType; -/** - * Returns all the unprocessed block types as passed during the registration. - * - * @param {Object} state Data state. - * - * @return {Array} Unprocessed block types. - */ -export function __experimentalGetUnprocessedBlockTypes( state ) { - return state.unprocessedBlockTypes; -} - /** * Returns all the available block types. * @@ -607,7 +600,11 @@ export const getBlockSupport = ( return defaultSupports; } - return get( blockType.supports, feature, defaultSupports ); + return getValueFromObjectPath( + blockType.supports, + feature, + defaultSupports + ); }; /** diff --git a/packages/blocks/src/store/test/reducer.js b/packages/blocks/src/store/test/reducer.js index b4312d0fd7df22..5664f9d876cb6e 100644 --- a/packages/blocks/src/store/test/reducer.js +++ b/packages/blocks/src/store/test/reducer.js @@ -31,24 +31,25 @@ describe( 'unprocessedBlockTypes', () => { it( 'should add a new block type', () => { const original = deepFreeze( { - 'core/paragraph': { name: 'core/paragraph' }, + 'core/paragraph': { title: 'Paragraph' }, } ); const state = unprocessedBlockTypes( original, { type: 'ADD_UNPROCESSED_BLOCK_TYPE', - blockType: { name: 'core/code' }, + name: 'core/code', + blockType: { title: 'Code' }, } ); expect( state ).toEqual( { - 'core/paragraph': { name: 'core/paragraph' }, - 'core/code': { name: 'core/code' }, + 'core/paragraph': { title: 'Paragraph' }, + 'core/code': { title: 'Code' }, } ); } ); it( 'should remove unprocessed block types', () => { const original = deepFreeze( { - 'core/paragraph': { name: 'core/paragraph' }, - 'core/code': { name: 'core/code' }, + 'core/paragraph': { title: 'Paragraph' }, + 'core/code': { title: 'Code' }, } ); const state = blockTypes( original, { @@ -57,7 +58,7 @@ describe( 'unprocessedBlockTypes', () => { } ); expect( state ).toEqual( { - 'core/paragraph': { name: 'core/paragraph' }, + 'core/paragraph': { title: 'Paragraph' }, } ); } ); } ); diff --git a/packages/blocks/src/store/utils.js b/packages/blocks/src/store/utils.js new file mode 100644 index 00000000000000..64974bd8000b27 --- /dev/null +++ b/packages/blocks/src/store/utils.js @@ -0,0 +1,20 @@ +/** + * Helper util to return a value from a certain path of the object. + * Path is specified as either: + * - a string of properties, separated by dots, for example: "x.y". + * - an array of properties, for example `[ 'x', 'y' ]`. + * You can also specify a default value in case the result is nullish. + * + * @param {Object} object Input object. + * @param {string|Array} path Path to the object property. + * @param {*} defaultValue Default value if the value at the specified path is nullish. + * @return {*} Value of the object property at the specified path. + */ +export const getValueFromObjectPath = ( object, path, defaultValue ) => { + const normalizedPath = Array.isArray( path ) ? path : path.split( '.' ); + let value = object; + normalizedPath.forEach( ( fieldName ) => { + value = value?.[ fieldName ]; + } ); + return value ?? defaultValue; +}; diff --git a/packages/browserslist-config/CHANGELOG.md b/packages/browserslist-config/CHANGELOG.md index 896521c2d028c8..cd3e675cb577dc 100644 --- a/packages/browserslist-config/CHANGELOG.md +++ b/packages/browserslist-config/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 5.23.0 (2023-08-16) + +## 5.22.0 (2023-08-10) + +## 5.21.0 (2023-07-20) + +## 5.20.0 (2023-07-05) + +## 5.19.0 (2023-06-23) + +## 5.18.0 (2023-06-07) + ## 5.17.0 (2023-05-24) ## 5.16.0 (2023-05-10) diff --git a/packages/browserslist-config/package.json b/packages/browserslist-config/package.json index 25c1305b2023ec..9136613a01dc2b 100644 --- a/packages/browserslist-config/package.json +++ b/packages/browserslist-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/browserslist-config", - "version": "5.17.0", + "version": "5.23.0", "description": "WordPress Browserslist shared configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/commands/CHANGELOG.md b/packages/commands/CHANGELOG.md index 57830c57becf79..7eaf493e7ce98e 100644 --- a/packages/commands/CHANGELOG.md +++ b/packages/commands/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.11.0 (2023-08-16) + +## 0.10.0 (2023-08-10) + +## 0.9.0 (2023-07-20) + +## 0.8.0 (2023-07-05) + +## 0.7.0 (2023-06-23) + +## 0.6.0 (2023-06-07) + ## 0.5.0 (2023-05-24) ## 0.4.0 (2023-05-10) diff --git a/packages/commands/README.md b/packages/commands/README.md index 0e0afdce394c99..c6504ff638cdea 100644 --- a/packages/commands/README.md +++ b/packages/commands/README.md @@ -24,9 +24,21 @@ Undocumented declaration. Undocumented declaration. +### store + +Store definition for the commands namespace. + +_Related_ + +- <https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore> + +_Type_ + +- `Object` + ### useCommand -Attach a command to the Global command menu. +Attach a command to the command palette. _Parameters_ @@ -34,7 +46,7 @@ _Parameters_ ### useCommandLoader -Attach a command loader to the Global command menu. +Attach a command loader to the command palette. _Parameters_ diff --git a/packages/commands/package.json b/packages/commands/package.json index 4ae19c17d87a56..ea89a529dc4c47 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/commands", - "version": "0.5.0", + "version": "0.11.0", "description": "Handles the commands menu.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -34,11 +34,13 @@ "@wordpress/icons": "file:../icons", "@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts", "@wordpress/private-apis": "file:../private-apis", + "classnames": "^2.3.1", "cmdk": "^0.2.0", "rememo": "^4.0.2" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/commands/src/components/command-menu.js b/packages/commands/src/components/command-menu.js index 9f59db3f6f53cb..cd0e5e1d5e760c 100644 --- a/packages/commands/src/components/command-menu.js +++ b/packages/commands/src/components/command-menu.js @@ -1,13 +1,20 @@ /** * External dependencies */ -import { Command } from 'cmdk'; +import { Command, useCommandState } from 'cmdk'; +import classnames from 'classnames'; /** * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { useState, useEffect, useRef, useCallback } from '@wordpress/element'; +import { + useState, + useEffect, + useRef, + useCallback, + useMemo, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Modal, @@ -18,7 +25,7 @@ import { store as keyboardShortcutsStore, useShortcut, } from '@wordpress/keyboard-shortcuts'; -import { Icon } from '@wordpress/icons'; +import { Icon, search as inputIcon } from '@wordpress/icons'; /** * Internal dependencies @@ -43,12 +50,18 @@ function CommandMenuLoader( { name, search, hook, setLoader, close } ) { key={ command.name } value={ command.searchLabel ?? command.label } onSelect={ () => command.callback( { close } ) } + id={ command.name } > <HStack alignment="left" - className="commands-command-menu__item" + className={ classnames( + 'commands-command-menu__item', + { + 'has-icon': command.icon, + } + ) } > - <Icon icon={ command.icon } /> + { command.icon && <Icon icon={ command.icon } /> } <span> <TextHighlight text={ command.label } @@ -112,12 +125,15 @@ export function CommandMenuGroup( { isContextual, search, setLoader, close } ) { key={ command.name } value={ command.searchLabel ?? command.label } onSelect={ () => command.callback( { close } ) } + id={ command.name } > <HStack alignment="left" - className="commands-command-menu__item" + className={ classnames( 'commands-command-menu__item', { + 'has-icon': command.icon, + } ) } > - <Icon icon={ command.icon } /> + { command.icon && <Icon icon={ command.icon } /> } <span> <TextHighlight text={ command.label } @@ -140,6 +156,33 @@ export function CommandMenuGroup( { isContextual, search, setLoader, close } ) { ); } +function CommandInput( { isOpen, search, setSearch } ) { + const commandMenuInput = useRef(); + const _value = useCommandState( ( state ) => state.value ); + const selectedItemId = useMemo( () => { + const item = document.querySelector( + `[cmdk-item=""][data-value="${ _value }"]` + ); + return item?.getAttribute( 'id' ); + }, [ _value ] ); + useEffect( () => { + // Focus the command palette input when mounting the modal. + if ( isOpen ) { + commandMenuInput.current.focus(); + } + }, [ isOpen ] ); + return ( + <Command.Input + ref={ commandMenuInput } + value={ search } + onValueChange={ setSearch } + placeholder={ __( 'Search for commands' ) } + aria-activedescendant={ selectedItemId } + icon={ search } + /> + ); +} + export function CommandMenu() { const { registerShortcut } = useDispatch( keyboardShortcutsStore ); const [ search, setSearch ] = useState( '' ); @@ -149,13 +192,12 @@ export function CommandMenu() { ); const { open, close } = useDispatch( commandsStore ); const [ loaders, setLoaders ] = useState( {} ); - const commandMenuInput = useRef(); useEffect( () => { registerShortcut( { name: 'core/commands', category: 'global', - description: __( 'Open the global command menu' ), + description: __( 'Open the command palette.' ), keyCombination: { modifier: 'primary', character: 'k', @@ -165,7 +207,11 @@ export function CommandMenu() { useShortcut( 'core/commands', + /** @type {import('react').KeyboardEventHandler} */ ( event ) => { + // Bails to avoid obscuring the effect of the preceding handler(s). + if ( event.defaultPrevented ) return; + event.preventDefault(); if ( isOpen ) { close(); @@ -191,16 +237,23 @@ export function CommandMenu() { close(); }; - useEffect( () => { - // Focus the command menu input when mounting the modal. - if ( isOpen ) { - commandMenuInput.current.focus(); - } - }, [ isOpen ] ); - if ( ! isOpen ) { return false; } + + const onKeyDown = ( event ) => { + if ( + // Ignore keydowns from IMEs + event.nativeEvent.isComposing || + // Workaround for Mac Safari where the final Enter/Backspace of an IME composition + // is `isComposing=false`, even though it's technically still part of the composition. + // These can only be detected by keyCode. + event.keyCode === 229 + ) { + event.preventDefault(); + } + }; + const isLoading = Object.values( loaders ).some( Boolean ); return ( @@ -211,13 +264,16 @@ export function CommandMenu() { __experimentalHideHeader > <div className="commands-command-menu__container"> - <Command label={ __( 'Global Command Menu' ) }> + <Command + label={ __( 'Command palette' ) } + onKeyDown={ onKeyDown } + > <div className="commands-command-menu__header"> - <Command.Input - ref={ commandMenuInput } - value={ search } - onValueChange={ setSearch } - placeholder={ __( 'Type a command or search' ) } + <Icon icon={ inputIcon } /> + <CommandInput + search={ search } + setSearch={ setSearch } + isOpen={ isOpen } /> </div> <Command.List> diff --git a/packages/commands/src/components/style.scss b/packages/commands/src/components/style.scss index 11114cce856ba5..8466fabe9dd799 100644 --- a/packages/commands/src/components/style.scss +++ b/packages/commands/src/components/style.scss @@ -1,9 +1,15 @@ // dirty hack to clean up modal .commands-command-menu { - width: 100%; - max-width: 480px; + border-radius: $grid-unit-05; + width: calc(100% - #{$grid-unit-40}); + margin: auto; + max-width: 420px; position: relative; - top: 15%; + top: calc(15% + #{$header-height}); + + @include break-small() { + top: 15%; + } .components-modal__content { margin: 0; @@ -19,6 +25,7 @@ .commands-command-menu__header { display: flex; align-items: center; + padding-left: $grid-unit-20; .components-button { height: $grid-unit-70; @@ -42,33 +49,32 @@ [cmdk-input] { border: none; width: 100%; - padding: $grid-unit-20; + padding: $grid-unit-20 $grid-unit-20 $grid-unit-20 $grid-unit-10; outline: none; color: $gray-900; margin: 0; - font-size: 20px; + font-size: 16px; line-height: 28px; - border-bottom: 1px solid $gray-200; border-radius: 0; &::placeholder { - color: $gray-600; + color: $gray-700; } &:focus { box-shadow: none; outline: none; - border-bottom: 1px solid $gray-200; } } [cmdk-item] { - border-radius: $grid-unit-05; + border-radius: $radius-block-ui; cursor: pointer; display: flex; align-items: center; - padding: $grid-unit; color: $gray-900; + font-size: $default-font-size; + min-height: $button-size-next-default-40px; &[aria-selected="true"], &:active { @@ -86,16 +92,25 @@ } svg { - fill: $gray-600; + fill: $gray-900; + } + + > div { + padding: $grid-unit; + padding-left: $grid-unit-50; // Account for commands without icons. + } + + > .has-icon { + padding-left: $grid-unit; } } [cmdk-root] > [cmdk-list] { - max-height: 400px; + max-height: 368px; // Specific to not have commands overflow oddly. overflow: auto; - & > [cmdk-list-sizer] :has([cmdk-group-items]:not(:empty)) { - padding: $grid-unit; + & [cmdk-list-sizer] > [cmdk-group] > [cmdk-group-items]:not(:empty) { + padding: 0 $grid-unit $grid-unit; } } @@ -103,9 +118,9 @@ display: flex; align-items: center; justify-content: center; - height: $grid-unit-80; white-space: pre-wrap; - color: $gray-800; + color: $gray-900; + padding: $grid-unit-10 0 $grid-unit-40; } [cmdk-loading] { @@ -115,10 +130,14 @@ [cmdk-list-sizer] { position: relative; } +} - [cmdk-group]:has([cmdk-group-items]:not(:empty)) + [cmdk-group]:has([cmdk-group-items]:not(:empty)) { - border-top: 1px solid $gray-200; - } +.commands-command-menu__item span { + // Ensure commands do not run off the edge (great for post titles). + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .commands-command-menu__item mark { diff --git a/packages/commands/src/hooks/use-command-context.js b/packages/commands/src/hooks/use-command-context.js index c53e4131890c7e..ac41118241fccd 100644 --- a/packages/commands/src/hooks/use-command-context.js +++ b/packages/commands/src/hooks/use-command-context.js @@ -8,16 +8,17 @@ import { useDispatch, useSelect } from '@wordpress/data'; * Internal dependencies */ import { store as commandsStore } from '../store'; +import { unlock } from '../lock-unlock'; /** - * Sets the active context of the command center + * Sets the active context of the command palette * * @param {string} context Context to set. */ export default function useCommandContext( context ) { const { getContext } = useSelect( commandsStore ); const initialContext = useRef( getContext() ); - const { setContext } = useDispatch( commandsStore ); + const { setContext } = unlock( useDispatch( commandsStore ) ); useEffect( () => { setContext( context ); diff --git a/packages/commands/src/hooks/use-command-loader.js b/packages/commands/src/hooks/use-command-loader.js index 084b8fb0fba24f..8aa61adc4feb54 100644 --- a/packages/commands/src/hooks/use-command-loader.js +++ b/packages/commands/src/hooks/use-command-loader.js @@ -10,7 +10,7 @@ import { useDispatch } from '@wordpress/data'; import { store as commandsStore } from '../store'; /** - * Attach a command loader to the Global command menu. + * Attach a command loader to the command palette. * * @param {import('../store/actions').WPCommandLoaderConfig} loader command loader config. */ diff --git a/packages/commands/src/hooks/use-command.js b/packages/commands/src/hooks/use-command.js index e3f56662b91f29..10581ad2421988 100644 --- a/packages/commands/src/hooks/use-command.js +++ b/packages/commands/src/hooks/use-command.js @@ -10,7 +10,7 @@ import { useDispatch } from '@wordpress/data'; import { store as commandsStore } from '../store'; /** - * Attach a command to the Global command menu. + * Attach a command to the command palette. * * @param {import('../store/actions').WPCommandConfig} command command config. */ @@ -28,7 +28,7 @@ export default function useCommand( command ) { label: command.label, searchLabel: command.searchLabel, icon: command.icon, - callback: currentCallback.current, + callback: ( ...args ) => currentCallback.current( ...args ), } ); return () => { unregisterCommand( command.name ); diff --git a/packages/commands/src/index.js b/packages/commands/src/index.js index afc7ac27b7d5f4..b62166f6afc44c 100644 --- a/packages/commands/src/index.js +++ b/packages/commands/src/index.js @@ -2,3 +2,4 @@ export { CommandMenu } from './components/command-menu'; export { privateApis } from './private-apis'; export { default as useCommand } from './hooks/use-command'; export { default as useCommandLoader } from './hooks/use-command-loader'; +export { store } from './store'; diff --git a/packages/commands/src/lock-unlock.js b/packages/commands/src/lock-unlock.js new file mode 100644 index 00000000000000..0665114d842c34 --- /dev/null +++ b/packages/commands/src/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/commands' + ); diff --git a/packages/commands/src/private-apis.js b/packages/commands/src/private-apis.js index 7348711efd3517..cf37423a8da361 100644 --- a/packages/commands/src/private-apis.js +++ b/packages/commands/src/private-apis.js @@ -1,22 +1,10 @@ -/** - * WordPress dependencies - */ -import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; - /** * Internal dependencies */ import { default as useCommandContext } from './hooks/use-command-context'; -import { store } from './store'; - -export const { lock, unlock } = - __dangerousOptInToUnstableAPIsOnlyForCoreModules( - 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', - '@wordpress/commands' - ); +import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { useCommandContext, - store, } ); diff --git a/packages/commands/src/store/actions.js b/packages/commands/src/store/actions.js index 6162f1497cf0ad..f6d9105dd2b906 100644 --- a/packages/commands/src/store/actions.js +++ b/packages/commands/src/store/actions.js @@ -84,7 +84,7 @@ export function unregisterCommandLoader( name ) { } /** - * Opens the command center. + * Opens the command palette. * * @return {Object} action. */ @@ -95,7 +95,7 @@ export function open() { } /** - * Closes the command center. + * Closes the command palette. * * @return {Object} action. */ @@ -104,17 +104,3 @@ export function close() { type: 'CLOSE', }; } - -/** - * Sets the active context. - * - * @param {string} context Context. - * - * @return {Object} action. - */ -export function setContext( context ) { - return { - type: 'SET_CONTEXT', - context, - }; -} diff --git a/packages/commands/src/store/index.js b/packages/commands/src/store/index.js index 38db89576ab871..b8260017fe5a64 100644 --- a/packages/commands/src/store/index.js +++ b/packages/commands/src/store/index.js @@ -9,6 +9,8 @@ import { createReduxStore, register } from '@wordpress/data'; import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; +import * as privateActions from './private-actions'; +import { unlock } from '../lock-unlock'; const STORE_NAME = 'core/commands'; @@ -26,3 +28,4 @@ export const store = createReduxStore( STORE_NAME, { } ); register( store ); +unlock( store ).registerPrivateActions( privateActions ); diff --git a/packages/commands/src/store/private-actions.js b/packages/commands/src/store/private-actions.js new file mode 100644 index 00000000000000..57dba53451e658 --- /dev/null +++ b/packages/commands/src/store/private-actions.js @@ -0,0 +1,13 @@ +/** + * Sets the active context. + * + * @param {string} context Context. + * + * @return {Object} action. + */ +export function setContext( context ) { + return { + type: 'SET_CONTEXT', + context, + }; +} diff --git a/packages/commands/src/store/reducer.js b/packages/commands/src/store/reducer.js index c2bb73b189f16b..eba6346f3f2c4c 100644 --- a/packages/commands/src/store/reducer.js +++ b/packages/commands/src/store/reducer.js @@ -63,7 +63,7 @@ function commandLoaders( state = {}, action ) { } /** - * Reducer returning the command center open state. + * Reducer returning the command palette open state. * * @param {Object} state Current state. * @param {Object} action Dispatched action. @@ -82,7 +82,7 @@ function isOpen( state = false, action ) { } /** - * Reducer returning the command center's active context. + * Reducer returning the command palette's active context. * * @param {Object} state Current state. * @param {Object} action Dispatched action. diff --git a/packages/commands/src/store/selectors.js b/packages/commands/src/store/selectors.js index 9795a87dc86313..426151464131a0 100644 --- a/packages/commands/src/store/selectors.js +++ b/packages/commands/src/store/selectors.js @@ -3,6 +3,14 @@ */ import createSelector from 'rememo'; +/** + * Returns the registered static commands. + * + * @param {Object} state State tree. + * @param {boolean} contextual Whether to return only contextual commands. + * + * @return {import('./actions').WPCommandConfig[]} The list of registered commands. + */ export const getCommands = createSelector( ( state, contextual = false ) => Object.values( state.commands ).filter( ( command ) => { @@ -13,6 +21,14 @@ export const getCommands = createSelector( ( state ) => [ state.commands, state.context ] ); +/** + * Returns the registered command loaders. + * + * @param {Object} state State tree. + * @param {boolean} contextual Whether to return only contextual command loaders. + * + * @return {import('./actions').WPCommandLoaderConfig[]} The list of registered command loaders. + */ export const getCommandLoaders = createSelector( ( state, contextual = false ) => Object.values( state.commandLoaders ).filter( ( loader ) => { @@ -23,10 +39,24 @@ export const getCommandLoaders = createSelector( ( state ) => [ state.commandLoaders, state.context ] ); +/** + * Returns whether the command palette is open. + * + * @param {Object} state State tree. + * + * @return {boolean} Returns whether the command palette is open. + */ export function isOpen( state ) { return state.isOpen; } +/** + * Returns whether the active context. + * + * @param {Object} state State tree. + * + * @return {string} Context. + */ export function getContext( state ) { return state.context; } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index d9670d86cb91c7..7dce9d2a315f4e 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,146 @@ ## Unreleased +### Enhancements + +- Make the `Popover.Slot` optional and render popovers at the bottom of the document's body by default. ([#53889](https://github.com/WordPress/gutenberg/pull/53889)). +- `ProgressBar`: Add transition to determinate indicator ([#53877](https://github.com/WordPress/gutenberg/pull/53877)). +- Prevent nested `SlotFillProvider` from rendering ([#53940](https://github.com/WordPress/gutenberg/pull/53940)). + +### Bug Fix + +- `SandBox`: Fix the cleanup method in useEffect ([#53796](https://github.com/WordPress/gutenberg/pull/53796)). +- `PaletteEdit`: Fix the height of the `PaletteItems`. Don't rely on styles only present in the block editor ([#54000](https://github.com/WordPress/gutenberg/pull/54000)). + +### Internal + +- `Shortcut`: Add Storybook stories ([#53627](https://github.com/WordPress/gutenberg/pull/53627)). +- `SlotFill`: Do not render children when using `<Slot bubblesVirtually />`. ([#53272](https://github.com/WordPress/gutenberg/pull/53272)) +- Update `@floating-ui/react-dom` to the latest version ([#46845](https://github.com/WordPress/gutenberg/pull/46845)). + +## 25.6.0 (2023-08-16) + +### Enhancements + +- `Theme`: Expose via private APIs ([#53262](https://github.com/WordPress/gutenberg/pull/53262)). +- `ProgressBar`: Use the theme system accent for indicator color ([#53347](https://github.com/WordPress/gutenberg/pull/53347)). +- `ProgressBar`: Use gray 300 for track color ([#53349](https://github.com/WordPress/gutenberg/pull/53349)). +- `Modal`: add `headerActions` prop to render buttons in the header. ([#53328](https://github.com/WordPress/gutenberg/pull/53328)). +- `Snackbar`: Snackbar design and motion improvements ([#53248](https://github.com/WordPress/gutenberg/pull/53248)) +- `NumberControl`: Add `spinFactor` prop for adjusting the amount by which the spin controls change the value ([#52902](https://github.com/WordPress/gutenberg/pull/52902)). +- `Modal:`: Nuance outside interactions ([#52994](https://github.com/WordPress/gutenberg/pull/52994)). +- `Button`: Remove default border from the destructive button ([#53607](https://github.com/WordPress/gutenberg/pull/53607)). +- Components: Move accent colors to theme context ([#53631](https://github.com/WordPress/gutenberg/pull/53631)). +- `ProgressBar`: Use the new theme system accent for indicator color ([#53632](https://github.com/WordPress/gutenberg/pull/53632)). + +### Bug Fix + +- `Button`: add `:disabled` selector to reset hover color for disabled buttons ([#53411](https://github.com/WordPress/gutenberg/pull/53411)). + +### Internal + +- `ControlGroup`, `FormGroup`, `ControlLabel`, `Spinner`: Remove unused `ui/` components from the codebase ([#52953](https://github.com/WordPress/gutenberg/pull/52953)). +- `MenuItem`: Convert to TypeScript ([#53132](https://github.com/WordPress/gutenberg/pull/53132)). +- `MenuItem`: Add Storybook stories ([#53613](https://github.com/WordPress/gutenberg/pull/53613)). +- `MenuGroup`: Add Storybook stories ([#53090](https://github.com/WordPress/gutenberg/pull/53090)). +- Components: Remove unnecessary utils ([#53679](https://github.com/WordPress/gutenberg/pull/53679)). + +## 25.5.0 (2023-08-10) + +### New Feature + +- Add a new `ProgressBar` component. ([#53030](https://github.com/WordPress/gutenberg/pull/53030)). + +### Enhancements + +- `ColorPalette`, `BorderControl`: Don't hyphenate hex value in `aria-label` ([#52932](https://github.com/WordPress/gutenberg/pull/52932)). +- `MenuItemsChoice`, `MenuItem`: Support a `disabled` prop on a menu item ([#52737](https://github.com/WordPress/gutenberg/pull/52737)). + +### Bug Fix + +- `Modal`: Fix loss of focus when clicking outside ([#52653](https://github.com/WordPress/gutenberg/pull/52653)). + +## 25.4.0 (2023-07-20) + +### Enhancements + +- `TextControl`: Add `id` prop to allow for custom IDs in `TextControl`s ([#52028](https://github.com/WordPress/gutenberg/pull/52028)). +- `Navigator`: Add `replace` option to `navigator.goTo()` and `navigator.goToParent()` ([#52456](https://github.com/WordPress/gutenberg/pull/52456)). + +### Bug Fix + +- `Popover`: Pin `react-dropdown-menu` version to avoid breaking changes in dependency updates. ([#52356](https://github.com/WordPress/gutenberg/pull/52356)). +- `Item`: Unify focus style and add default font styles. ([#52495](https://github.com/WordPress/gutenberg/pull/52495)). +- `Toolbar`: Fix toolbar items not being tabbable on the first render. ([#52613](https://github.com/WordPress/gutenberg/pull/52613)) +- `FormTokenField`: Fix token overflow when moving cursor left or right. ([#52662](https://github.com/WordPress/gutenberg/pull/52662)) + +## 25.3.0 (2023-07-05) + +### Enhancements + +- `SelectControl`: Added option to set hidden options. ([#51545](https://github.com/WordPress/gutenberg/pull/51545)) +- `RangeControl`: Add `__next40pxDefaultSize` prop to opt into the new 40px default size ([#49105](https://github.com/WordPress/gutenberg/pull/49105)). +- `Button`: Introduce `size` prop with `default`, `compact`, and `small` variants ([#51842](https://github.com/WordPress/gutenberg/pull/51842)). +- `ItemGroup`: Update button focus state styles to target `:focus-visible` rather than `:focus`. ([#51787](https://github.com/WordPress/gutenberg/pull/51787)). +- `Guide`: Don't show Close button when there is only one page, and use default button and accent/theme styling ([#52014](https://github.com/WordPress/gutenberg/pull/52014)). + +### Bug Fix + +- `ConfirmDialog`: Ensure onConfirm isn't called an extra time when submitting one of the buttons using the keyboard ([#51730](https://github.com/WordPress/gutenberg/pull/51730)). +- `ZStack`: ZStack: fix component bounding box to match children ([#51836](https://github.com/WordPress/gutenberg/pull/51836)). +- `Modal`: Add small top padding to the content so that avoid cutting off the visible outline when hovering items ([#51829](https://github.com/WordPress/gutenberg/pull/51829)). +- `DropdownMenu`: fix icon style when dashicon is used ([#43574](https://github.com/WordPress/gutenberg/pull/43574)). +- `UnitControl`: Fix crash when certain units are used ([#52211](https://github.com/WordPress/gutenberg/pull/52211)). +- `Guide`: Place focus on the guide's container instead of its first tabbable ([#52300](https://github.com/WordPress/gutenberg/pull/52300)). + +## 25.2.0 (2023-06-23) + +### Enhancements + +- `UnitControl`: Revamp support for changing unit by typing ([#39303](https://github.com/WordPress/gutenberg/pull/39303)). +- `Modal`: Update corner radius to be between buttons and the site view frame, in a 2-4-8 system. ([#51254](https://github.com/WordPress/gutenberg/pull/51254)). +- `ItemGroup`: Update button focus state styles to be inline with other button focus states in the editor. ([#51576](https://github.com/WordPress/gutenberg/pull/51576)). +- `ItemGroup`: Update button focus state styles to target `:focus-visible` rather than `:focus`. ([#51787](https://github.com/WordPress/gutenberg/pull/51787)). + +### Bug Fix + +- `Popover`: Allow legitimate 0 positions to update popover position ([#51320](https://github.com/WordPress/gutenberg/pull/51320)). +- `Button`: Remove unnecessary margin from dashicon ([#51395](https://github.com/WordPress/gutenberg/pull/51395)). +- `Autocomplete`: Announce how many results are available to screen readers when suggestions list first renders ([#51018](https://github.com/WordPress/gutenberg/pull/51018)). + +### Internal + +- `ClipboardButton`: Convert to TypeScript ([#51334](https://github.com/WordPress/gutenberg/pull/51334)). +- `Toolbar`: Replace `reakit` dependency with `@ariakit/react` ([#51623](https://github.com/WordPress/gutenberg/pull/51623)). + +### Documentation + +- `SearchControl`: Improve documentation around usage of `label` prop ([#51781](https://github.com/WordPress/gutenberg/pull/51781)). + +## 25.1.0 (2023-06-07) + +### Enhancements + +- `BorderControl`: Improve color code readability in aria-label ([#51197](https://github.com/WordPress/gutenberg/pull/51197)). +- `Dropdown` and `DropdownMenu`: use internal context system to automatically pick the toolbar popover variant when rendered inside the `Toolbar` component ([#51154](https://github.com/WordPress/gutenberg/pull/51154)). + +### Bug Fix + +- `FocalPointUnitControl`: Add aria-labels ([#50993](https://github.com/WordPress/gutenberg/pull/50993)). + +### Enhancements + +- Wrapped `TabPanel` in a `forwardRef` call ([#50199](https://github.com/WordPress/gutenberg/pull/50199)). +- `ColorPalette`: Improve readability of color name and value, and improve rendering of partially transparent colors ([#50450](https://github.com/WordPress/gutenberg/pull/50450)). +- `Button`: Add `__next32pxSmallSize` prop to opt into the new 32px size when the `isSmall` prop is enabled ([#51012](https://github.com/WordPress/gutenberg/pull/51012)). +- `ItemGroup`: Update styles so all SVGs inherit color from their parent element ([#50819](https://github.com/WordPress/gutenberg/pull/50819)). + +### Experimental + +- `DropdownMenu` v2: Tweak styles ([#50967](https://github.com/WordPress/gutenberg/pull/50967), [#51097](https://github.com/WordPress/gutenberg/pull/51097)). +- `DropdownMenu` v2: change default placement to match the legacy `DropdownMenu` component ([#51133](https://github.com/WordPress/gutenberg/pull/51133)). +- `DropdownMenu` v2: Render in the default `Popover.Slot` ([#51046](https://github.com/WordPress/gutenberg/pull/51046)). + ## 25.0.0 (2023-05-24) ### Breaking Changes @@ -25,6 +165,7 @@ ### Enhancements - `Tooltip`: Update background color so tooltip boundaries are more visible in the site editor ([#50792](https://github.com/WordPress/gutenberg/pull/50792)). +- `FontSizePicker`: Tweak the header spacing to be more consistent with other design tools ([#50855](https://github.com/WordPress/gutenberg/pull/50855)). ## 24.0.0 (2023-05-10) diff --git a/packages/components/CONTRIBUTING.md b/packages/components/CONTRIBUTING.md index bf7569a19ddba0..5a94194fa63585 100644 --- a/packages/components/CONTRIBUTING.md +++ b/packages/components/CONTRIBUTING.md @@ -619,10 +619,10 @@ Given a component folder (e.g. `packages/components/src/unit-control`): 3. Rewrite the `meta` story object, and export it as default. In particular, make sure you add the following settings under the `parameters` key: ```tsx - const meta: ComponentMeta< typeof MyComponent > = { + const meta: Meta< typeof MyComponent > = { parameters: { controls: { expanded: true }, - docs: { source: { state: 'open' } }, + docs: { canvas: { sourceState: 'shown' } }, }, }; ``` diff --git a/packages/components/README.md b/packages/components/README.md index b0ad75ac0d08f5..f324cb48c66d7e 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -33,14 +33,9 @@ In non-WordPress projects, link to the `build-style/style.css` file directly, it _If you're using [`Popover`](/packages/components/src/popover/README.md) or [`Tooltip`](/packages/components/src/tooltip/README.md) components outside of the editor, make sure they are rendered within a `SlotFillProvider` and with a `Popover.Slot` somewhere up the element tree._ -By default, the `Popover` component will render inline i.e. within its -parent to which it should anchor. Depending upon the context in which the -`Popover` is being consumed, this might lead to incorrect positioning. For -example, when being nested within another popover. +By default, the `Popover` component will render within an extra element appended to the body of the document. -This issue can be solved by rendering popovers to a specific location in the DOM via the -`Popover.Slot`. For this to work, you will need your use of the `Popover` -component and its `Slot` to be wrapped in a [`SlotFill`](/packages/components/src/slot-fill/README.md) provider. +If you want to precisely contol where the popovers render, you will need to use the `Popover.Slot` component. A `Popover` is also used as the underlying mechanism to display `Tooltip` components. So the same considerations should be applied to them. @@ -62,7 +57,7 @@ import { MyComponentWithPopover } from './my-component'; const Example = () => { <SlotFillProvider> <MyComponentWithPopover /> - <Popover.Slot> + <Popover.Slot /> </SlotFillProvider> }; ``` diff --git a/packages/components/package.json b/packages/components/package.json index 12238d26522dc4..2a4f7c380ee979 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/components", - "version": "25.0.0", + "version": "25.6.0", "description": "UI components for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -30,6 +30,7 @@ ], "types": "build-types", "dependencies": { + "@ariakit/react": "^0.2.12", "@babel/runtime": "^7.16.0", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", @@ -37,8 +38,8 @@ "@emotion/serialize": "^1.0.2", "@emotion/styled": "^11.6.0", "@emotion/utils": "^1.0.0", - "@floating-ui/react-dom": "1.0.0", - "@radix-ui/react-dropdown-menu": "^2.0.4", + "@floating-ui/react-dom": "^2.0.1", + "@radix-ui/react-dropdown-menu": "2.0.4", "@use-gesture/react": "^10.2.24", "@wordpress/a11y": "file:../a11y", "@wordpress/compose": "file:../compose", @@ -65,7 +66,7 @@ "dom-scroll-into-view": "^1.2.1", "downshift": "^6.0.15", "fast-deep-equal": "^3.1.3", - "framer-motion": "^10.11.6", + "framer-motion": "^10.13.0", "gradient-parser": "^0.1.5", "highlight-words-core": "^1.2.2", "is-plain-object": "^5.0.0", @@ -74,7 +75,7 @@ "re-resizable": "^6.4.0", "react-colorful": "^5.3.1", "reakit": "^1.3.11", - "remove-accents": "^0.4.2", + "remove-accents": "^0.5.0", "use-lilius": "^2.0.1", "uuid": "^8.3.0", "valtio": "1.7.0" diff --git a/packages/components/src/alignment-matrix-control/stories/index.story.tsx b/packages/components/src/alignment-matrix-control/stories/index.story.tsx new file mode 100644 index 00000000000000..24b496d1f2432b --- /dev/null +++ b/packages/components/src/alignment-matrix-control/stories/index.story.tsx @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { Icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import AlignmentMatrixControl from '..'; +import { HStack } from '../../h-stack'; +import type { AlignmentMatrixControlProps } from '../types'; + +const meta: Meta< typeof AlignmentMatrixControl > = { + title: 'Components (Experimental)/AlignmentMatrixControl', + component: AlignmentMatrixControl, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'AlignmentMatrixControl.Icon': AlignmentMatrixControl.Icon, + }, + argTypes: { + onChange: { action: 'onChange', control: { type: null } }, + value: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof AlignmentMatrixControl > = ( { + defaultValue, + onChange, + ...props +} ) => { + const [ value, setValue ] = + useState< AlignmentMatrixControlProps[ 'value' ] >(); + + // Convenience handler for Canvas view so changes are reflected + useEffect( () => { + setValue( defaultValue ); + }, [ defaultValue ] ); + + return ( + <AlignmentMatrixControl + { ...props } + onChange={ ( ...changeArgs ) => { + setValue( ...changeArgs ); + onChange?.( ...changeArgs ); + } } + value={ value } + /> + ); +}; +export const Default = Template.bind( {} ); + +export const IconSubcomponent = () => { + return ( + <HStack justify="flex-start"> + <Icon + icon={ + <AlignmentMatrixControl.Icon size={ 24 } value="top left" /> + } + /> + <Icon + icon={ + <AlignmentMatrixControl.Icon + size={ 24 } + value="center center" + /> + } + /> + </HStack> + ); +}; diff --git a/packages/components/src/alignment-matrix-control/stories/index.tsx b/packages/components/src/alignment-matrix-control/stories/index.tsx deleted file mode 100644 index 6401907f921693..00000000000000 --- a/packages/components/src/alignment-matrix-control/stories/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useEffect, useState } from '@wordpress/element'; -import { Icon } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import AlignmentMatrixControl from '..'; -import { HStack } from '../../h-stack'; -import type { AlignmentMatrixControlProps } from '../types'; - -const meta: ComponentMeta< typeof AlignmentMatrixControl > = { - title: 'Components (Experimental)/AlignmentMatrixControl', - component: AlignmentMatrixControl, - subcomponents: { - 'AlignmentMatrixControl.Icon': AlignmentMatrixControl.Icon, - }, - argTypes: { - onChange: { action: 'onChange', control: { type: null } }, - value: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof AlignmentMatrixControl > = ( { - defaultValue, - onChange, - ...props -} ) => { - const [ value, setValue ] = - useState< AlignmentMatrixControlProps[ 'value' ] >(); - - // Convenience handler for Canvas view so changes are reflected - useEffect( () => { - setValue( defaultValue ); - }, [ defaultValue ] ); - - return ( - <AlignmentMatrixControl - { ...props } - onChange={ ( ...changeArgs ) => { - setValue( ...changeArgs ); - onChange?.( ...changeArgs ); - } } - value={ value } - /> - ); -}; -export const Default = Template.bind( {} ); - -export const IconSubcomponent = () => { - return ( - <HStack justify="flex-start"> - <Icon - icon={ - <AlignmentMatrixControl.Icon size={ 24 } value="top left" /> - } - /> - <Icon - icon={ - <AlignmentMatrixControl.Icon - size={ 24 } - value="center center" - /> - } - /> - </HStack> - ); -}; diff --git a/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts b/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts index 5adde6002ba3b4..0df630181b388d 100644 --- a/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts +++ b/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts @@ -54,7 +54,7 @@ const pointActive = ( { }: Pick< AlignmentMatrixControlCellProps, 'isActive' > ) => { const boxShadow = isActive ? `0 0 0 2px ${ COLORS.gray[ 900 ] }` : null; const pointColor = isActive ? COLORS.gray[ 900 ] : COLORS.gray[ 400 ]; - const pointColorHover = isActive ? COLORS.gray[ 900 ] : COLORS.ui.theme; + const pointColorHover = isActive ? COLORS.gray[ 900 ] : COLORS.theme.accent; return css` box-shadow: ${ boxShadow }; diff --git a/packages/components/src/alignment-matrix-control/test/index.tsx b/packages/components/src/alignment-matrix-control/test/index.tsx index 0e9661fe595b09..a99f6d70135c5f 100644 --- a/packages/components/src/alignment-matrix-control/test/index.tsx +++ b/packages/components/src/alignment-matrix-control/test/index.tsx @@ -1,7 +1,8 @@ /** * External dependencies */ -import { act, render, screen, within } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; /** * Internal dependencies @@ -27,6 +28,7 @@ describe( 'AlignmentMatrixControl', () => { describe( 'Change value', () => { const alignments = [ 'center left', 'center center', 'bottom center' ]; + const user = userEvent.setup(); it.each( alignments )( 'should change value on %s cell click', @@ -37,7 +39,9 @@ describe( 'AlignmentMatrixControl', () => { <AlignmentMatrixControl value="center" onChange={ spy } /> ); - await act( () => getCell( alignment ).focus() ); + await user.click( getCell( alignment ) ); + + expect( getCell( alignment ) ).toHaveFocus(); expect( spy ).toHaveBeenCalledWith( alignment ); } diff --git a/packages/components/src/angle-picker-control/stories/index.story.tsx b/packages/components/src/angle-picker-control/stories/index.story.tsx new file mode 100644 index 00000000000000..d10403a436bfc2 --- /dev/null +++ b/packages/components/src/angle-picker-control/stories/index.story.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { AnglePickerControl } from '..'; + +const meta: Meta< typeof AnglePickerControl > = { + title: 'Components/AnglePickerControl', + component: AnglePickerControl, + argTypes: { + as: { control: { type: null } }, + value: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +const AnglePickerWithState: StoryFn< typeof AnglePickerControl > = ( { + onChange, + ...args +} ) => { + const [ angle, setAngle ] = useState< number >( 0 ); + + const handleChange = ( newValue: number ) => { + setAngle( newValue ); + onChange( newValue ); + }; + + return ( + <AnglePickerControl + { ...args } + value={ angle } + onChange={ handleChange } + /> + ); +}; + +export const Default = AnglePickerWithState.bind( {} ); +Default.args = { + __nextHasNoMarginBottom: true, +}; diff --git a/packages/components/src/angle-picker-control/stories/index.tsx b/packages/components/src/angle-picker-control/stories/index.tsx deleted file mode 100644 index bfa2952b697b05..00000000000000 --- a/packages/components/src/angle-picker-control/stories/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { AnglePickerControl } from '..'; - -const meta: ComponentMeta< typeof AnglePickerControl > = { - title: 'Components/AnglePickerControl', - component: AnglePickerControl, - argTypes: { - as: { control: { type: null } }, - value: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -const AnglePickerWithState: ComponentStory< typeof AnglePickerControl > = ( { - onChange, - ...args -} ) => { - const [ angle, setAngle ] = useState< number >( 0 ); - - const handleChange = ( newValue: number ) => { - setAngle( newValue ); - onChange( newValue ); - }; - - return ( - <AnglePickerControl - { ...args } - value={ angle } - onChange={ handleChange } - /> - ); -}; - -export const Default = AnglePickerWithState.bind( {} ); -Default.args = { - __nextHasNoMarginBottom: true, -}; diff --git a/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx b/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx index f08756f33bccf8..65ac5b77d0da6c 100644 --- a/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx +++ b/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx @@ -58,7 +58,7 @@ export const CircleIndicatorWrapper = styled.div` `; export const CircleIndicator = styled.div` - background: ${ COLORS.ui.theme }; + background: ${ COLORS.theme.accent }; border-radius: 50%; box-sizing: border-box; display: block; @@ -71,6 +71,6 @@ export const CircleIndicator = styled.div` `; export const UnitText = styled( Text )` - color: ${ COLORS.ui.theme }; + color: ${ COLORS.theme.accent }; margin-right: ${ space( 3 ) }; `; diff --git a/packages/components/src/animate/stories/index.story.tsx b/packages/components/src/animate/stories/index.story.tsx new file mode 100644 index 00000000000000..be076b4e6976c6 --- /dev/null +++ b/packages/components/src/animate/stories/index.story.tsx @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Animate } from '..'; +import Notice from '../../notice'; + +const meta: Meta< typeof Animate > = { + title: 'Components/Animate', + component: Animate, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Animate > = ( props ) => ( + <Animate { ...props } /> +); + +export const Default = Template.bind( {} ); +Default.args = { + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>{ `No default animation. Use one of type = "appear", "slide-in", or "loading".` }</p> + </Notice> + ), +}; + +export const AppearTopLeft = Template.bind( {} ); +AppearTopLeft.args = { + type: 'appear', + options: { origin: 'top left' }, + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Appear animation. Origin: top left.</p> + </Notice> + ), +}; +export const AppearTopRight = Template.bind( {} ); +AppearTopRight.args = { + type: 'appear', + options: { origin: 'top right' }, + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Appear animation. Origin: top right.</p> + </Notice> + ), +}; +export const AppearBottomLeft = Template.bind( {} ); +AppearBottomLeft.args = { + type: 'appear', + options: { origin: 'bottom left' }, + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Appear animation. Origin: bottom left.</p> + </Notice> + ), +}; +export const AppearBottomRight = Template.bind( {} ); +AppearBottomRight.args = { + type: 'appear', + options: { origin: 'bottom right' }, + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Appear animation. Origin: bottom right.</p> + </Notice> + ), +}; + +export const Loading = Template.bind( {} ); +Loading.args = { + type: 'loading', + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Loading animation.</p> + </Notice> + ), +}; + +export const SlideIn = Template.bind( {} ); +SlideIn.args = { + type: 'slide-in', + options: { origin: 'left' }, + children: ( { className } ) => ( + <Notice className={ className } status="success"> + <p>Slide-in animation.</p> + </Notice> + ), +}; diff --git a/packages/components/src/animate/stories/index.tsx b/packages/components/src/animate/stories/index.tsx deleted file mode 100644 index 11537cea450e4d..00000000000000 --- a/packages/components/src/animate/stories/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Animate } from '..'; -import Notice from '../../notice'; - -const meta: ComponentMeta< typeof Animate > = { - title: 'Components/Animate', - component: Animate, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Animate > = ( props ) => ( - <Animate { ...props } /> -); - -export const Default: ComponentStory< typeof Animate > = Template.bind( {} ); -Default.args = { - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>{ `No default animation. Use one of type = "appear", "slide-in", or "loading".` }</p> - </Notice> - ), -}; - -export const AppearTopLeft: ComponentStory< typeof Animate > = Template.bind( - {} -); -AppearTopLeft.args = { - type: 'appear', - options: { origin: 'top left' }, - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>Appear animation. Origin: top left.</p> - </Notice> - ), -}; -export const AppearTopRight: ComponentStory< typeof Animate > = Template.bind( - {} -); -AppearTopRight.args = { - type: 'appear', - options: { origin: 'top right' }, - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>Appear animation. Origin: top right.</p> - </Notice> - ), -}; -export const AppearBottomLeft: ComponentStory< typeof Animate > = Template.bind( - {} -); -AppearBottomLeft.args = { - type: 'appear', - options: { origin: 'bottom left' }, - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>Appear animation. Origin: bottom left.</p> - </Notice> - ), -}; -export const AppearBottomRight: ComponentStory< typeof Animate > = - Template.bind( {} ); -AppearBottomRight.args = { - type: 'appear', - options: { origin: 'bottom right' }, - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>Appear animation. Origin: bottom right.</p> - </Notice> - ), -}; - -export const Loading: ComponentStory< typeof Animate > = Template.bind( {} ); -Loading.args = { - type: 'loading', - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>Loading animation.</p> - </Notice> - ), -}; - -export const SlideIn: ComponentStory< typeof Animate > = Template.bind( {} ); -SlideIn.args = { - type: 'slide-in', - options: { origin: 'left' }, - children: ( { className } ) => ( - <Notice className={ className } status="success"> - <p>Slide-in animation.</p> - </Notice> - ), -}; diff --git a/packages/components/src/autocomplete/autocompleter-ui.tsx b/packages/components/src/autocomplete/autocompleter-ui.tsx index e5bfe61d265bb9..663316c39b32ea 100644 --- a/packages/components/src/autocomplete/autocompleter-ui.tsx +++ b/packages/components/src/autocomplete/autocompleter-ui.tsx @@ -13,7 +13,9 @@ import { useState, } from '@wordpress/element'; import { useAnchor } from '@wordpress/rich-text'; -import { useMergeRefs, useRefEffect } from '@wordpress/compose'; +import { useDebounce, useMergeRefs, useRefEffect } from '@wordpress/compose'; +import { speak } from '@wordpress/a11y'; +import { __, _n, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -23,7 +25,7 @@ import Button from '../button'; import Popover from '../popover'; import { VisuallyHidden } from '../visually-hidden'; import { createPortal } from 'react-dom'; -import type { AutocompleterUIProps, WPCompleter } from './types'; +import type { AutocompleterUIProps, KeyedOption, WPCompleter } from './types'; export function getAutoCompleterUI( autocompleter: WPCompleter ) { const useItems = autocompleter.useItems @@ -69,8 +71,48 @@ export function getAutoCompleterUI( autocompleter: WPCompleter ) { useOnClickOutside( popoverRef, reset ); + const debouncedSpeak = useDebounce( speak, 500 ); + + function announce( options: Array< KeyedOption > ) { + if ( ! debouncedSpeak ) { + return; + } + if ( !! options.length ) { + if ( filterValue ) { + debouncedSpeak( + sprintf( + /* translators: %d: number of results. */ + _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + options.length + ), + options.length + ), + 'assertive' + ); + } else { + debouncedSpeak( + sprintf( + /* translators: %d: number of results. */ + _n( + 'Initial %d result loaded. Type to filter all available results. Use up and down arrow keys to navigate.', + 'Initial %d results loaded. Type to filter all available results. Use up and down arrow keys to navigate.', + options.length + ), + options.length + ), + 'assertive' + ); + } + } else { + debouncedSpeak( __( 'No results.' ), 'assertive' ); + } + } + useLayoutEffect( () => { onChangeOptions( items ); + announce( items ); // Temporarily disabling exhaustive-deps to avoid introducing unexpected side effecst. // See https://github.com/WordPress/gutenberg/pull/41820 // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/components/src/autocomplete/index.tsx b/packages/components/src/autocomplete/index.tsx index af9625d6459157..7825526fe34a5a 100644 --- a/packages/components/src/autocomplete/index.tsx +++ b/packages/components/src/autocomplete/index.tsx @@ -13,13 +13,8 @@ import { useRef, useMemo, } from '@wordpress/element'; -import { __, _n, sprintf } from '@wordpress/i18n'; -import { - useInstanceId, - useDebounce, - useMergeRefs, - useRefEffect, -} from '@wordpress/compose'; +import { __, _n } from '@wordpress/i18n'; +import { useInstanceId, useMergeRefs, useRefEffect } from '@wordpress/compose'; import { create, slice, @@ -27,7 +22,6 @@ import { isCollapsed, getTextContent, } from '@wordpress/rich-text'; -import { speak } from '@wordpress/a11y'; /** * Internal dependencies @@ -54,7 +48,6 @@ export function useAutocomplete( { completers, contentRef, }: UseAutocompleteProps ) { - const debouncedSpeak = useDebounce( speak, 500 ); const instanceId = useInstanceId( useAutocomplete ); const [ selectedIndex, setSelectedIndex ] = useState( 0 ); @@ -137,28 +130,6 @@ export function useAutocomplete( { setAutocompleterUI( null ); } - function announce( options: Array< KeyedOption > ) { - if ( ! debouncedSpeak ) { - return; - } - if ( !! options.length ) { - debouncedSpeak( - sprintf( - /* translators: %d: number of results. */ - _n( - '%d result found, use up and down arrow keys to navigate.', - '%d results found, use up and down arrow keys to navigate.', - options.length - ), - options.length - ), - 'assertive' - ); - } else { - debouncedSpeak( __( 'No results.' ), 'assertive' ); - } - } - /** * Load options for an autocompleter. * @@ -169,7 +140,6 @@ export function useAutocomplete( { options.length === filteredOptions.length ? selectedIndex : 0 ); setFilteredOptions( options ); - announce( options ); } function handleKeyDown( event: KeyboardEvent ) { diff --git a/packages/components/src/base-control/stories/index.story.tsx b/packages/components/src/base-control/stories/index.story.tsx new file mode 100644 index 00000000000000..3b6e228bd4fc81 --- /dev/null +++ b/packages/components/src/base-control/stories/index.story.tsx @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import BaseControl, { useBaseControlProps } from '..'; +import Button from '../../button'; + +const meta: Meta< typeof BaseControl > = { + title: 'Components/BaseControl', + component: BaseControl, + argTypes: { + children: { control: { type: null } }, + help: { control: { type: 'text' } }, + label: { control: { type: 'text' } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const BaseControlWithTextarea: StoryFn< typeof BaseControl > = ( props ) => { + const { baseControlProps, controlProps } = useBaseControlProps( props ); + + return ( + <BaseControl { ...baseControlProps }> + <textarea style={ { display: 'block' } } { ...controlProps } /> + </BaseControl> + ); +}; + +export const Default: StoryFn< typeof BaseControl > = + BaseControlWithTextarea.bind( {} ); +Default.args = { + __nextHasNoMarginBottom: true, + label: 'Label text', +}; + +export const WithHelpText = BaseControlWithTextarea.bind( {} ); +WithHelpText.args = { + ...Default.args, + help: 'Help text adds more explanation.', +}; + +/** + * `BaseControl.VisualLabel` is used to render a purely visual label inside a `BaseControl` component. + * + * It should only be used in cases where the children being rendered inside `BaseControl` are already accessibly labeled, + * e.g., a button, but we want an additional visual label for that section equivalent to the labels `BaseControl` would + * otherwise use if the `label` prop was passed. + */ +export const WithVisualLabel: StoryFn< typeof BaseControl > = ( props ) => { + // @ts-expect-error - Unclear how to fix, see also https://github.com/WordPress/gutenberg/pull/39468#discussion_r827150516 + BaseControl.VisualLabel.displayName = 'BaseControl.VisualLabel'; + + return ( + <BaseControl { ...props }> + <BaseControl.VisualLabel>Visual label</BaseControl.VisualLabel> + <div> + <Button variant="secondary">Select an author</Button> + </div> + </BaseControl> + ); +}; +WithVisualLabel.args = { + ...Default.args, + help: 'This button is already accessibly labeled.', + label: undefined, +}; diff --git a/packages/components/src/base-control/stories/index.tsx b/packages/components/src/base-control/stories/index.tsx deleted file mode 100644 index b1b64caec0c3b0..00000000000000 --- a/packages/components/src/base-control/stories/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import BaseControl, { useBaseControlProps } from '..'; -import Button from '../../button'; - -const meta: ComponentMeta< typeof BaseControl > = { - title: 'Components/BaseControl', - component: BaseControl, - argTypes: { - children: { control: { type: null } }, - help: { control: { type: 'text' } }, - label: { control: { type: 'text' } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const BaseControlWithTextarea: ComponentStory< typeof BaseControl > = ( - props -) => { - const { baseControlProps, controlProps } = useBaseControlProps( props ); - - return ( - <BaseControl { ...baseControlProps }> - <textarea style={ { display: 'block' } } { ...controlProps } /> - </BaseControl> - ); -}; - -export const Default: ComponentStory< typeof BaseControl > = - BaseControlWithTextarea.bind( {} ); -Default.args = { - __nextHasNoMarginBottom: true, - label: 'Label text', -}; - -export const WithHelpText = BaseControlWithTextarea.bind( {} ); -WithHelpText.args = { - ...Default.args, - help: 'Help text adds more explanation.', -}; - -/** - * `BaseControl.VisualLabel` is used to render a purely visual label inside a `BaseControl` component. - * - * It should only be used in cases where the children being rendered inside `BaseControl` are already accessibly labeled, - * e.g., a button, but we want an additional visual label for that section equivalent to the labels `BaseControl` would - * otherwise use if the `label` prop was passed. - */ -export const WithVisualLabel: ComponentStory< typeof BaseControl > = ( - props -) => { - // @ts-expect-error - Unclear how to fix, see also https://github.com/WordPress/gutenberg/pull/39468#discussion_r827150516 - BaseControl.VisualLabel.displayName = 'BaseControl.VisualLabel'; - - return ( - <BaseControl { ...props }> - <BaseControl.VisualLabel>Visual label</BaseControl.VisualLabel> - <div> - <Button variant="secondary">Select an author</Button> - </div> - </BaseControl> - ); -}; -WithVisualLabel.args = { - ...Default.args, - help: 'This button is already accessibly labeled.', - label: undefined, -}; diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx index d5fe6a9eb1f761..f782fb89340200 100644 --- a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx @@ -10,7 +10,8 @@ import { __ } from '@wordpress/i18n'; import Button from '../../button'; import Tooltip from '../../tooltip'; import { View } from '../../view'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderBoxControlLinkedButton } from './hook'; import type { LinkedButtonProps } from '../types'; diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts b/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts index 27ed54351aaf81..cd65758416ca13 100644 --- a/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts +++ b/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts @@ -7,7 +7,8 @@ import { useMemo } from '@wordpress/element'; * Internal dependencies */ import * as styles from '../styles'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { LinkedButtonProps } from '../types'; diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx index d8da0112bb7cea..922f0b39c09c97 100644 --- a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx @@ -11,7 +11,8 @@ import { useMergeRefs } from '@wordpress/compose'; import BorderBoxControlVisualizer from '../border-box-control-visualizer'; import { BorderControl } from '../../border-control'; import { Grid } from '../../grid'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderBoxControlSplitControls } from './hook'; import type { BorderControlProps } from '../../border-control/types'; diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts b/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts index a32b116843dcdb..ff152c9bc674ad 100644 --- a/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts +++ b/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts @@ -7,7 +7,8 @@ import { useMemo } from '@wordpress/element'; * Internal dependencies */ import * as styles from '../styles'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/'; import type { SplitControlsProps } from '../types'; diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx index c0abb92f3803ba..d815b0b8c088e8 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx @@ -7,7 +7,8 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { View } from '../../view'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderBoxControlVisualizer } from './hook'; import type { VisualizerProps } from '../types'; diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts index 4e6e69e9d8452b..d7ae390dcd146d 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts +++ b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts @@ -7,7 +7,8 @@ import { useMemo } from '@wordpress/element'; * Internal dependencies */ import * as styles from '../styles'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils'; import type { VisualizerProps } from '../types'; 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 c66855aa2f5e17..ad3162851c2670 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 @@ -14,7 +14,8 @@ import { BorderControl } from '../../border-control'; import { StyledLabel } from '../../base-control/styles/base-control-styles'; import { View } from '../../view'; import { VisuallyHidden } from '../../visually-hidden'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderBoxControl } from './hook'; import type { BorderBoxControlProps } from '../types'; diff --git a/packages/components/src/border-box-control/border-box-control/hook.ts b/packages/components/src/border-box-control/border-box-control/hook.ts index 1c2f8291de50d8..cf5aae4e09f725 100644 --- a/packages/components/src/border-box-control/border-box-control/hook.ts +++ b/packages/components/src/border-box-control/border-box-control/hook.ts @@ -16,7 +16,8 @@ import { isCompleteBorder, isEmptyBorder, } from '../utils'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { Border } from '../../border-control/types'; diff --git a/packages/components/src/border-box-control/stories/index.story.tsx b/packages/components/src/border-box-control/stories/index.story.tsx new file mode 100644 index 00000000000000..5b5d7f311208c0 --- /dev/null +++ b/packages/components/src/border-box-control/stories/index.story.tsx @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { ComponentProps } from 'react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import { BorderBoxControl } from '../'; + +const meta: Meta< typeof BorderBoxControl > = { + title: 'Components (Experimental)/BorderBoxControl', + component: BorderBoxControl, + argTypes: { + onChange: { action: 'onChange' }, + value: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +// Available border colors. +const colors = [ + { name: 'Blue 20', color: '#72aee6' }, + { name: 'Blue 40', color: '#3582c4' }, + { name: 'Red 40', color: '#e65054' }, + { name: 'Red 70', color: '#8a2424' }, + { name: 'Yellow 10', color: '#f2d675' }, + { name: 'Yellow 40', color: '#bd8600' }, +]; + +const Template: StoryFn< typeof BorderBoxControl > = ( props ) => { + const { onChange, ...otherProps } = props; + const [ borders, setBorders ] = useState< ( typeof props )[ 'value' ] >(); + + const onChangeMerged: ComponentProps< + typeof BorderBoxControl + >[ 'onChange' ] = ( newBorders ) => { + setBorders( newBorders ); + onChange( newBorders ); + }; + + return ( + <> + <BorderBoxControl + { ...otherProps } + onChange={ onChangeMerged } + value={ borders } + /> + <hr + style={ { + marginTop: '100px', + borderColor: '#ddd', + borderStyle: 'solid', + borderBottom: 'none', + } } + /> + <p style={ { color: '#aaa', fontSize: '0.9em' } }> + The BorderBoxControl is intended to be used within a component + that will provide reset controls. The button below is only for + convenience. + </p> + <Button + variant="primary" + onClick={ () => onChangeMerged( undefined ) } + > + Reset + </Button> + </> + ); +}; +export const Default = Template.bind( {} ); +Default.args = { + colors, + label: 'Borders', +}; diff --git a/packages/components/src/border-box-control/stories/index.tsx b/packages/components/src/border-box-control/stories/index.tsx deleted file mode 100644 index 01d8f39c615c2a..00000000000000 --- a/packages/components/src/border-box-control/stories/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { ComponentProps } from 'react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import Popover from '../../popover'; -import { BorderBoxControl } from '../'; -import { Provider as SlotFillProvider } from '../../slot-fill'; - -const meta: ComponentMeta< typeof BorderBoxControl > = { - title: 'Components (Experimental)/BorderBoxControl', - component: BorderBoxControl, - argTypes: { - onChange: { action: 'onChange' }, - value: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -// Available border colors. -const colors = [ - { name: 'Blue 20', color: '#72aee6' }, - { name: 'Blue 40', color: '#3582c4' }, - { name: 'Red 40', color: '#e65054' }, - { name: 'Red 70', color: '#8a2424' }, - { name: 'Yellow 10', color: '#f2d675' }, - { name: 'Yellow 40', color: '#bd8600' }, -]; - -const Template: ComponentStory< typeof BorderBoxControl > = ( props ) => { - const { onChange, ...otherProps } = props; - const [ borders, setBorders ] = useState< ( typeof props )[ 'value' ] >(); - - const onChangeMerged: ComponentProps< - typeof BorderBoxControl - >[ 'onChange' ] = ( newBorders ) => { - setBorders( newBorders ); - onChange( newBorders ); - }; - - return ( - <SlotFillProvider> - <BorderBoxControl - { ...otherProps } - onChange={ onChangeMerged } - value={ borders } - /> - <hr - style={ { - marginTop: '100px', - borderColor: '#ddd', - borderStyle: 'solid', - borderBottom: 'none', - } } - /> - <p style={ { color: '#aaa', fontSize: '0.9em' } }> - The BorderBoxControl is intended to be used within a component - that will provide reset controls. The button below is only for - convenience. - </p> - <Button - variant="primary" - onClick={ () => onChangeMerged( undefined ) } - > - Reset - </Button> - { /* @ts-expect-error Ignore until Popover.Slot is converted to TS */ } - <Popover.Slot /> - </SlotFillProvider> - ); -}; -export const Default = Template.bind( {} ); -Default.args = { - colors, - label: 'Borders', -}; 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 2b44b3d813a972..64e92ca79e32f4 100644 --- a/packages/components/src/border-control/border-control-dropdown/component.tsx +++ b/packages/components/src/border-control/border-control-dropdown/component.tsx @@ -19,7 +19,8 @@ import ColorPalette from '../../color-palette'; import Dropdown from '../../dropdown'; import { HStack } from '../../h-stack'; import { VStack } from '../../v-stack'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderControlDropdown } from './hook'; import { StyledLabel } from '../../base-control/styles/base-control-styles'; import DropdownContentWrapper from '../../dropdown/dropdown-content-wrapper'; @@ -29,6 +30,11 @@ import { isMultiplePaletteArray } from '../../color-palette/utils'; import type { DropdownProps as DropdownComponentProps } from '../../dropdown/types'; import type { ColorProps, DropdownProps } from '../types'; +const getAriaLabelColorValue = ( colorValue: string ) => { + // Leave hex values as-is. Remove the `var()` wrapper from CSS vars. + return colorValue.replace( /^var\((.+)\)$/, '$1' ); +}; + const getColorObject = ( colorValue: CSSProperties[ 'borderColor' ], colors: ColorProps[ 'colors' ] | undefined @@ -67,34 +73,36 @@ const getToggleAriaLabel = ( ) => { if ( isStyleEnabled ) { if ( colorObject ) { + const ariaLabelValue = getAriaLabelColorValue( colorObject.color ); return style ? sprintf( // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:". %3$s: The current border style selection e.g. "solid". 'Border color and style picker. The currently selected color is called "%1$s" and has a value of "%2$s". The currently selected style is "%3$s".', colorObject.name, - colorObject.color, + ariaLabelValue, style ) : sprintf( // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:". 'Border color and style picker. The currently selected color is called "%1$s" and has a value of "%2$s".', colorObject.name, - colorObject.color + ariaLabelValue ); } if ( colorValue ) { + const ariaLabelValue = getAriaLabelColorValue( colorValue ); return style ? sprintf( // translators: %1$s: The color's hex code e.g.: "#f00:". %2$s: The current border style selection e.g. "solid". 'Border color and style picker. The currently selected color has a value of "%1$s". The currently selected style is "%2$s".', - colorValue, + ariaLabelValue, style ) : sprintf( - // translators: %1$s: The color's hex code e.g.: "#f00:". + // translators: %1$s: The color's hex code e.g: "#f00". 'Border color and style picker. The currently selected color has a value of "%1$s".', - colorValue + ariaLabelValue ); } @@ -103,18 +111,18 @@ const getToggleAriaLabel = ( if ( colorObject ) { return sprintf( - // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:". + // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g: "#f00". 'Border color picker. The currently selected color is called "%1$s" and has a value of "%2$s".', colorObject.name, - colorObject.color + getAriaLabelColorValue( colorObject.color ) ); } if ( colorValue ) { return sprintf( - // translators: %1$s: The color's hex code e.g.: "#f00:". + // translators: %1$s: The color's hex code e.g: "#f00". 'Border color picker. The currently selected color has a value of "%1$s".', - colorValue + getAriaLabelColorValue( colorValue ) ); } diff --git a/packages/components/src/border-control/border-control-dropdown/hook.ts b/packages/components/src/border-control/border-control-dropdown/hook.ts index a3a789b5d55ee3..99309bb3374c58 100644 --- a/packages/components/src/border-control/border-control-dropdown/hook.ts +++ b/packages/components/src/border-control/border-control-dropdown/hook.ts @@ -8,7 +8,8 @@ import { useMemo } from '@wordpress/element'; */ import * as styles from '../styles'; import { parseQuantityAndUnitFromRawValue } from '../../unit-control/utils'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { DropdownProps } from '../types'; diff --git a/packages/components/src/border-control/border-control-style-picker/component.tsx b/packages/components/src/border-control/border-control-style-picker/component.tsx index c61cec058be1af..66858531f62cab 100644 --- a/packages/components/src/border-control/border-control-style-picker/component.tsx +++ b/packages/components/src/border-control/border-control-style-picker/component.tsx @@ -12,7 +12,8 @@ import { StyledLabel } from '../../base-control/styles/base-control-styles'; import { View } from '../../view'; import { Flex } from '../../flex'; import { VisuallyHidden } from '../../visually-hidden'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderControlStylePicker } from './hook'; import type { LabelProps, StylePickerProps } from '../types'; diff --git a/packages/components/src/border-control/border-control-style-picker/hook.ts b/packages/components/src/border-control/border-control-style-picker/hook.ts index b8d1c27b9a7276..7a77c735b9c2c9 100644 --- a/packages/components/src/border-control/border-control-style-picker/hook.ts +++ b/packages/components/src/border-control/border-control-style-picker/hook.ts @@ -7,7 +7,8 @@ import { useMemo } from '@wordpress/element'; * Internal dependencies */ import * as styles from '../styles'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { StylePickerProps } from '../types'; diff --git a/packages/components/src/border-control/border-control/component.tsx b/packages/components/src/border-control/border-control/component.tsx index 4f291bda44d24e..617ff5dd5997c2 100644 --- a/packages/components/src/border-control/border-control/component.tsx +++ b/packages/components/src/border-control/border-control/component.tsx @@ -13,7 +13,8 @@ import { HStack } from '../../h-stack'; import { StyledLabel } from '../../base-control/styles/base-control-styles'; import { View } from '../../view'; import { VisuallyHidden } from '../../visually-hidden'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useBorderControl } from './hook'; import type { BorderControlProps, LabelProps } from '../types'; diff --git a/packages/components/src/border-control/border-control/hook.ts b/packages/components/src/border-control/border-control/hook.ts index 77e2ede942406a..39917793de72de 100644 --- a/packages/components/src/border-control/border-control/hook.ts +++ b/packages/components/src/border-control/border-control/hook.ts @@ -8,7 +8,8 @@ import { useCallback, useMemo, useState } from '@wordpress/element'; */ import * as styles from '../styles'; import { parseQuantityAndUnitFromRawValue } from '../../unit-control/utils'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { Border, BorderControlProps } from '../types'; diff --git a/packages/components/src/border-control/stories/index.story.tsx b/packages/components/src/border-control/stories/index.story.tsx new file mode 100644 index 00000000000000..9a5349d302c276 --- /dev/null +++ b/packages/components/src/border-control/stories/index.story.tsx @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { ComponentProps } from 'react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { BorderControl } from '..'; +import type { Border } from '../types'; + +const meta: Meta< typeof BorderControl > = { + title: 'Components (Experimental)/BorderControl', + component: BorderControl, + argTypes: { + onChange: { + action: 'onChange', + }, + width: { control: { type: 'text' } }, + value: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +// Available border colors. +const colors = [ + { name: 'Blue 20', color: '#72aee6' }, + { name: 'Blue 40', color: '#3582c4' }, + { name: 'Red 40', color: '#e65054' }, + { name: 'Red 70', color: '#8a2424' }, + { name: 'Yellow 10', color: '#f2d675' }, + { name: 'Yellow 40', color: '#bd8600' }, +]; + +// Multiple origin colors. +const multipleOriginColors = [ + { + name: 'Default', + colors: [ + { name: 'Gray 20', color: '#a7aaad' }, + { name: 'Gray 70', color: '#3c434a' }, + ], + }, + { + name: 'Theme', + colors: [ + { name: 'Blue 20', color: '#72aee6' }, + { name: 'Blue 40', color: '#3582c4' }, + { name: 'Blue 70', color: '#0a4b78' }, + ], + }, + { + name: 'User', + colors: [ + { name: 'Green', color: '#00a32a' }, + { name: 'Yellow', color: '#f2d675' }, + ], + }, +]; + +const Template: StoryFn< typeof BorderControl > = ( { + onChange, + ...props +} ) => { + const [ border, setBorder ] = useState< Border >(); + const onChangeMerged: ComponentProps< + typeof BorderControl + >[ 'onChange' ] = ( newBorder ) => { + setBorder( newBorder ); + onChange( newBorder ); + }; + + return ( + <BorderControl + onChange={ onChangeMerged } + value={ border } + { ...props } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + colors, + label: 'Border', +}; + +/** + * Render a slider beside the control. + */ +export const WithSlider = Template.bind( {} ); +WithSlider.args = { + ...Default.args, + withSlider: true, +}; + +/** + * When rendering with a slider, the `width` prop is useful to customize the width of the number input. + */ +export const WithSliderCustomWidth = Template.bind( {} ); +WithSliderCustomWidth.args = { + ...Default.args, + withSlider: true, + width: '150px', +}; +WithSliderCustomWidth.storyName = 'With Slider (Custom Width)'; + +/** + * Restrict the width of the control and prevent it from expanding to take up additional space. + * When `true`, the `width` prop will be ignored. + */ +export const IsCompact = Template.bind( {} ); +IsCompact.args = { + ...Default.args, + isCompact: true, +}; + +/** + * The `colors` object can contain multiple origins. + */ +export const WithMultipleOrigins = Template.bind( {} ); +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/stories/index.tsx b/packages/components/src/border-control/stories/index.tsx deleted file mode 100644 index ce50d017ac51e6..00000000000000 --- a/packages/components/src/border-control/stories/index.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { ComponentProps } from 'react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { BorderControl } from '..'; -import { Provider as SlotFillProvider } from '../../slot-fill'; -import Popover from '../../popover'; -import type { Border } from '../types'; - -const meta: ComponentMeta< typeof BorderControl > = { - title: 'Components (Experimental)/BorderControl', - component: BorderControl, - argTypes: { - onChange: { - action: 'onChange', - }, - width: { control: { type: 'text' } }, - value: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -// Available border colors. -const colors = [ - { name: 'Blue 20', color: '#72aee6' }, - { name: 'Blue 40', color: '#3582c4' }, - { name: 'Red 40', color: '#e65054' }, - { name: 'Red 70', color: '#8a2424' }, - { name: 'Yellow 10', color: '#f2d675' }, - { name: 'Yellow 40', color: '#bd8600' }, -]; - -// Multiple origin colors. -const multipleOriginColors = [ - { - name: 'Default', - colors: [ - { name: 'Gray 20', color: '#a7aaad' }, - { name: 'Gray 70', color: '#3c434a' }, - ], - }, - { - name: 'Theme', - colors: [ - { name: 'Blue 20', color: '#72aee6' }, - { name: 'Blue 40', color: '#3582c4' }, - { name: 'Blue 70', color: '#0a4b78' }, - ], - }, - { - name: 'User', - colors: [ - { name: 'Green', color: '#00a32a' }, - { name: 'Yellow', color: '#f2d675' }, - ], - }, -]; - -const Template: ComponentStory< typeof BorderControl > = ( { - onChange, - ...props -} ) => { - const [ border, setBorder ] = useState< Border >(); - const onChangeMerged: ComponentProps< - typeof BorderControl - >[ 'onChange' ] = ( newBorder ) => { - setBorder( newBorder ); - onChange( newBorder ); - }; - - return ( - <SlotFillProvider> - <BorderControl - onChange={ onChangeMerged } - value={ border } - { ...props } - /> - { /* @ts-expect-error Ignore until Popover.Slot is converted to TS */ } - <Popover.Slot /> - </SlotFillProvider> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - colors, - label: 'Border', -}; - -/** - * Render a slider beside the control. - */ -export const WithSlider = Template.bind( {} ); -WithSlider.args = { - ...Default.args, - withSlider: true, -}; - -/** - * When rendering with a slider, the `width` prop is useful to customize the width of the number input. - */ -export const WithSliderCustomWidth = Template.bind( {} ); -WithSliderCustomWidth.args = { - ...Default.args, - withSlider: true, - width: '150px', -}; -WithSliderCustomWidth.storyName = 'With Slider (Custom Width)'; - -/** - * Restrict the width of the control and prevent it from expanding to take up additional space. - * When `true`, the `width` prop will be ignored. - */ -export const IsCompact = Template.bind( {} ); -IsCompact.args = { - ...Default.args, - isCompact: true, -}; - -/** - * The `colors` object can contain multiple origins. - */ -export const WithMultipleOrigins = Template.bind( {} ); -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/box-control/stories/index.js b/packages/components/src/box-control/stories/index.js deleted file mode 100644 index 55cf2628c670c7..00000000000000 --- a/packages/components/src/box-control/stories/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import BoxControl from '../'; - -export default { - title: 'Components (Experimental)/BoxControl', - component: BoxControl, -}; - -export const _default = () => { - return <BoxControl />; -}; - -const defaultSideValues = { - top: '10px', - right: '10px', - bottom: '10px', - left: '10px', -}; - -function DemoExample( { - sides, - defaultValues = defaultSideValues, - splitOnAxis = false, -} ) { - const [ values, setValues ] = useState( defaultValues ); - - return ( - <BoxControl - label="Padding" - values={ values } - sides={ sides } - onChange={ setValues } - splitOnAxis={ splitOnAxis } - /> - ); -} - -export const arbitrarySides = () => { - return ( - <DemoExample - sides={ [ 'top', 'bottom' ] } - defaultValues={ { top: '10px', bottom: '10px' } } - /> - ); -}; - -export const singleSide = () => { - return ( - <DemoExample - sides={ [ 'bottom' ] } - defaultValues={ { bottom: '10px' } } - /> - ); -}; - -export const axialControls = () => { - return <DemoExample splitOnAxis={ true } />; -}; - -export const axialControlsWithSingleSide = () => { - return ( - <DemoExample - sides={ [ 'horizontal' ] } - defaultValues={ { left: '10px', right: '10px' } } - splitOnAxis={ true } - /> - ); -}; diff --git a/packages/components/src/box-control/stories/index.story.js b/packages/components/src/box-control/stories/index.story.js new file mode 100644 index 00000000000000..adbd0e15f7c441 --- /dev/null +++ b/packages/components/src/box-control/stories/index.story.js @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BoxControl from '../'; + +export default { + title: 'Components (Experimental)/BoxControl', + component: BoxControl, +}; + +export const _default = () => { + return <BoxControl />; +}; + +const defaultSideValues = { + top: '10px', + right: '10px', + bottom: '10px', + left: '10px', +}; + +function DemoExample( { + sides, + defaultValues = defaultSideValues, + splitOnAxis = false, +} ) { + const [ values, setValues ] = useState( defaultValues ); + + return ( + <BoxControl + label="Padding" + values={ values } + sides={ sides } + onChange={ setValues } + splitOnAxis={ splitOnAxis } + /> + ); +} + +export const ArbitrarySides = () => { + return ( + <DemoExample + sides={ [ 'top', 'bottom' ] } + defaultValues={ { top: '10px', bottom: '10px' } } + /> + ); +}; + +export const SingleSide = () => { + return ( + <DemoExample + sides={ [ 'bottom' ] } + defaultValues={ { bottom: '10px' } } + /> + ); +}; + +export const AxialControls = () => { + return <DemoExample splitOnAxis={ true } />; +}; + +export const AxialControlsWithSingleSide = () => { + return ( + <DemoExample + sides={ [ 'horizontal' ] } + defaultValues={ { left: '10px', right: '10px' } } + splitOnAxis={ true } + /> + ); +}; diff --git a/packages/components/src/box-control/styles/box-control-visualizer-styles.ts b/packages/components/src/box-control/styles/box-control-visualizer-styles.ts index 4c6bfdf18a2f9d..bbfe66c9a71e98 100644 --- a/packages/components/src/box-control/styles/box-control-visualizer-styles.ts +++ b/packages/components/src/box-control/styles/box-control-visualizer-styles.ts @@ -35,7 +35,7 @@ export const Container = styled.div` export const Side = styled.div` box-sizing: border-box; - background: ${ COLORS.ui.theme }; + background: ${ COLORS.theme.accent }; filter: brightness( 1 ); opacity: 0; position: absolute; diff --git a/packages/components/src/box-control/test/index.tsx b/packages/components/src/box-control/test/index.tsx index acd09b26ba8abd..8a861eff37e1b2 100644 --- a/packages/components/src/box-control/test/index.tsx +++ b/packages/components/src/box-control/test/index.tsx @@ -46,15 +46,10 @@ describe( 'BoxControl', () => { name: 'Box Control', } ); - await user.type( input, '100%' ); + await user.type( input, '100' ); await user.keyboard( '{Enter}' ); expect( input ).toHaveValue( '100' ); - expect( - screen.getByRole( 'combobox', { - name: 'Select unit', - } ) - ).toHaveValue( '%' ); } ); } ); @@ -67,20 +62,15 @@ describe( 'BoxControl', () => { const input = screen.getByRole( 'textbox', { name: 'Box Control', } ); - const select = screen.getByRole( 'combobox', { - name: 'Select unit', - } ); - await user.type( input, '100px' ); + await user.type( input, '100' ); await user.keyboard( '{Enter}' ); expect( input ).toHaveValue( '100' ); - expect( select ).toHaveValue( 'px' ); await user.click( screen.getByRole( 'button', { name: 'Reset' } ) ); expect( input ).toHaveValue( '' ); - expect( select ).toHaveValue( 'px' ); } ); it( 'should reset values when clicking Reset, if controlled', async () => { @@ -91,20 +81,15 @@ describe( 'BoxControl', () => { const input = screen.getByRole( 'textbox', { name: 'Box Control', } ); - const select = screen.getByRole( 'combobox', { - name: 'Select unit', - } ); - await user.type( input, '100px' ); + await user.type( input, '100' ); await user.keyboard( '{Enter}' ); expect( input ).toHaveValue( '100' ); - expect( select ).toHaveValue( 'px' ); await user.click( screen.getByRole( 'button', { name: 'Reset' } ) ); expect( input ).toHaveValue( '' ); - expect( select ).toHaveValue( 'px' ); } ); it( 'should reset values when clicking Reset, if controlled <-> uncontrolled state changes', async () => { @@ -115,20 +100,15 @@ describe( 'BoxControl', () => { const input = screen.getByRole( 'textbox', { name: 'Box Control', } ); - const select = screen.getByRole( 'combobox', { - name: 'Select unit', - } ); - await user.type( input, '100px' ); + await user.type( input, '100' ); await user.keyboard( '{Enter}' ); expect( input ).toHaveValue( '100' ); - expect( select ).toHaveValue( 'px' ); await user.click( screen.getByRole( 'button', { name: 'Reset' } ) ); expect( input ).toHaveValue( '' ); - expect( select ).toHaveValue( 'px' ); } ); it( 'should persist cleared value when focus changes', async () => { @@ -141,15 +121,10 @@ describe( 'BoxControl', () => { name: 'Box Control', } ); - await user.type( input, '100%' ); + await user.type( input, '100' ); await user.keyboard( '{Enter}' ); expect( input ).toHaveValue( '100' ); - expect( - screen.getByRole( 'combobox', { - name: 'Select unit', - } ) - ).toHaveValue( '%' ); await user.clear( input ); expect( input ).toHaveValue( '' ); @@ -178,9 +153,8 @@ describe( 'BoxControl', () => { await user.type( screen.getByRole( 'textbox', { name: 'Top' } ), - '100px' + '100' ); - await user.keyboard( '{Enter}' ); expect( screen.getByRole( 'textbox', { name: 'Top' } ) @@ -194,12 +168,6 @@ describe( 'BoxControl', () => { expect( screen.getByRole( 'textbox', { name: 'Left' } ) ).not.toHaveValue(); - - screen - .getAllByRole( 'combobox', { name: 'Select unit' } ) - .forEach( ( combobox ) => { - expect( combobox ).toHaveValue( 'px' ); - } ); } ); it( 'should update a whole axis when value is changed when unlinked', async () => { @@ -215,9 +183,8 @@ describe( 'BoxControl', () => { screen.getByRole( 'textbox', { name: 'Vertical', } ), - '100px' + '100' ); - await user.keyboard( '{Enter}' ); expect( screen.getByRole( 'textbox', { name: 'Vertical' } ) @@ -225,12 +192,6 @@ describe( 'BoxControl', () => { expect( screen.getByRole( 'textbox', { name: 'Horizontal' } ) ).not.toHaveValue(); - - screen - .getAllByRole( 'combobox', { name: 'Select unit' } ) - .forEach( ( combobox ) => { - expect( combobox ).toHaveValue( 'px' ); - } ); } ); } ); @@ -321,23 +282,30 @@ describe( 'BoxControl', () => { describe( 'onChange updates', () => { it( 'should call onChange when values contain more than just CSS units', async () => { const user = userEvent.setup(); - const setState = jest.fn(); + const onChangeSpy = jest.fn(); - render( <BoxControl onChange={ setState } /> ); + render( <BoxControl onChange={ onChangeSpy } /> ); - await user.type( - screen.getByRole( 'textbox', { - name: 'Box Control', - } ), - '7.5rem' - ); - await user.keyboard( '{Enter}' ); + const valueInput = screen.getByRole( 'textbox', { + name: 'Box Control', + } ); + const unitSelect = screen.getByRole( 'combobox', { + name: 'Select unit', + } ); - expect( setState ).toHaveBeenCalledWith( { - top: '7.5rem', - right: '7.5rem', - bottom: '7.5rem', - left: '7.5rem', + // Typing the first letter of a unit blurs the input and focuses the combobox. + await user.type( valueInput, '7r' ); + + expect( unitSelect ).toHaveFocus(); + + // The correct expected behavior would be for the values to have "rem" + // as their unit, but the test environment doesn't seem to change + // values on `select` elements when using the keyboard. + expect( onChangeSpy ).toHaveBeenLastCalledWith( { + top: '7px', + right: '7px', + bottom: '7px', + left: '7px', } ); } ); diff --git a/packages/components/src/button-group/stories/index.story.tsx b/packages/components/src/button-group/stories/index.story.tsx new file mode 100644 index 00000000000000..958a0d137763e9 --- /dev/null +++ b/packages/components/src/button-group/stories/index.story.tsx @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import ButtonGroup from '..'; +import Button from '../../button'; + +const meta: Meta< typeof ButtonGroup > = { + title: 'Components/ButtonGroup', + component: ButtonGroup, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ButtonGroup > = ( args ) => { + const style = { margin: '0 4px' }; + return ( + <ButtonGroup { ...args }> + <Button variant="primary" style={ style }> + Button 1 + </Button> + <Button variant="primary" style={ style }> + Button 2 + </Button> + </ButtonGroup> + ); +}; + +export const Default: StoryFn< typeof ButtonGroup > = Template.bind( {} ); diff --git a/packages/components/src/button-group/stories/index.tsx b/packages/components/src/button-group/stories/index.tsx deleted file mode 100644 index 33d7cbfacd35d5..00000000000000 --- a/packages/components/src/button-group/stories/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import ButtonGroup from '..'; -import Button from '../../button'; - -const meta: ComponentMeta< typeof ButtonGroup > = { - title: 'Components/ButtonGroup', - component: ButtonGroup, - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ButtonGroup > = ( args ) => { - const style = { margin: '0 4px' }; - return ( - <ButtonGroup { ...args }> - <Button variant="primary" style={ style }> - Button 1 - </Button> - <Button variant="primary" style={ style }> - Button 2 - </Button> - </ButtonGroup> - ); -}; - -export const Default: ComponentStory< typeof ButtonGroup > = Template.bind( - {} -); diff --git a/packages/components/src/button/README.md b/packages/components/src/button/README.md index 66bbae67ebcd57..5430b869485ea0 100644 --- a/packages/components/src/button/README.md +++ b/packages/components/src/button/README.md @@ -198,6 +198,8 @@ Renders a pressed button style. Decreases the size of the button. +Deprecated in favor of the `size` prop. If both props are defined, the `size` prop will take precedence. + - Required: No #### `label`: `string` @@ -218,6 +220,19 @@ If provided, renders a [Tooltip](/packages/components/src/tooltip/README.md) com - Required: No +#### `size`: `'default'` | `'compact'` | `'small'` + +The size of the button. + +- `'default'`: For normal text-label buttons, unless it is a toggle button. +- `'compact'`: For toggle buttons, icon buttons, and buttons when used in context of either. +- `'small'`: For icon buttons associated with more advanced or auxiliary features. + +If the deprecated `isSmall` prop is also defined, this prop will take precedence. + +- Required: No +- Default: `'default'` + #### `target`: `string` If provided with `href`, sets the `target` attribute to the `a`. diff --git a/packages/components/src/button/index.native.js b/packages/components/src/button/index.native.js index 02820e1731f71c..95ca9d36b3791b 100644 --- a/packages/components/src/button/index.native.js +++ b/packages/components/src/button/index.native.js @@ -24,6 +24,7 @@ import { */ import Tooltip from '../tooltip'; import Icon from '../icon'; +import style from './style.scss'; const isAndroid = Platform.OS === 'android'; const marginBottom = isAndroid ? -0.5 : 0; @@ -51,8 +52,6 @@ const styles = StyleSheet.create( { justifyContent: 'center', alignItems: 'center', borderRadius: 6, - borderColor: '#2e4453', - backgroundColor: '#2e4453', }, subscriptInactive: { color: '#7b9ab1', // $toolbar-button. @@ -95,6 +94,7 @@ export function Button( props ) { tooltipPosition, isActiveStyle, customContainerStyles, + hitSlop, } = props; const preferredColorScheme = usePreferredColorScheme(); @@ -105,10 +105,16 @@ export function Button( props ) { customContainerStyles && { ...customContainerStyles }, ]; + const buttonActiveColorStyles = usePreferredColorSchemeStyle( + style[ 'components-button-light--active' ], + style[ 'components-button-dark--active' ] + ); + const buttonViewStyle = { opacity: isDisabled ? 0.3 : 1, ...( fixedRatio && styles.fixedRatio ), ...( isPressed ? styles.buttonActive : styles.buttonInactive ), + ...( isPressed ? buttonActiveColorStyles : {} ), ...( isPressed && isActiveStyle?.borderRadius && { borderRadius: isActiveStyle.borderRadius, @@ -158,7 +164,6 @@ export function Button( props ) { const newIcon = icon ? cloneElement( <Icon icon={ icon } size={ iconSize } />, { - colorScheme: preferredColorScheme, isPressed, } ) : null; @@ -184,6 +189,7 @@ export function Button( props ) { style={ containerStyle } disabled={ isDisabled } testID={ testID } + hitSlop={ hitSlop } > <LongPressGestureHandler minDurationMs={ 500 } diff --git a/packages/components/src/button/index.tsx b/packages/components/src/button/index.tsx index 098f6eaac3b1cf..cc91cf46425867 100644 --- a/packages/components/src/button/index.tsx +++ b/packages/components/src/button/index.tsx @@ -33,11 +33,18 @@ function useDeprecatedProps( { isSecondary, isTertiary, isLink, + isSmall, + size, variant, ...otherProps }: ButtonProps & DeprecatedButtonProps ): ButtonProps { + let computedSize = size; let computedVariant = variant; + if ( isSmall ) { + computedSize ??= 'small'; + } + if ( isPrimary ) { computedVariant ??= 'primary'; } @@ -66,6 +73,7 @@ function useDeprecatedProps( { return { ...otherProps, + size: computedSize, variant: computedVariant, }; } @@ -76,7 +84,6 @@ export function UnforwardedButton( ) { const { __next40pxDefaultSize, - isSmall, isPressed, isBusy, isDestructive, @@ -90,6 +97,7 @@ export function UnforwardedButton( shortcut, label, children, + size = 'default', text, variant, __experimentalIsFocusable: isFocusable, @@ -119,7 +127,8 @@ export function UnforwardedButton( 'is-next-40px-default-size': __next40pxDefaultSize, 'is-secondary': variant === 'secondary', 'is-primary': variant === 'primary', - 'is-small': isSmall, + 'is-small': size === 'small', + 'is-compact': size === 'compact', 'is-tertiary': variant === 'tertiary', 'is-pressed': isPressed, 'is-busy': isBusy, diff --git a/packages/components/src/button/stories/e2e/index.story.tsx b/packages/components/src/button/stories/e2e/index.story.tsx new file mode 100644 index 00000000000000..c2ec8e237d3b2a --- /dev/null +++ b/packages/components/src/button/stories/e2e/index.story.tsx @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { wordpress } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { Button } from '../..'; +import type { ButtonAsButtonProps } from '../../types'; + +const meta: Meta< typeof Button > = { + component: Button, + title: 'Components/Button', +}; +export default meta; + +export const VariantStates: StoryFn< typeof Button > = ( + props: ButtonAsButtonProps +) => { + const variants: ( typeof props.variant )[] = [ + undefined, + 'primary', + 'secondary', + 'tertiary', + 'link', + ]; + + return ( + <div style={ { display: 'flex', flexDirection: 'column', gap: 24 } }> + { variants.map( ( variant ) => ( + <div + style={ { display: 'flex', gap: 8 } } + key={ variant ?? 'undefined' } + > + <Button { ...props } variant={ variant } /> + <Button { ...props } variant={ variant } disabled /> + <Button { ...props } variant={ variant } isBusy /> + <Button { ...props } variant={ variant } isDestructive /> + <Button { ...props } variant={ variant } isPressed /> + </div> + ) ) } + </div> + ); +}; +VariantStates.args = { + children: 'Code is poetry', +}; + +export const Icon = VariantStates.bind( {} ); +Icon.args = { + icon: wordpress, +}; + +export const Dashicons: StoryFn< typeof Button > = ( props ) => { + return ( + <div style={ { display: 'flex', gap: 8 } }> + <Button { ...props } /> + <Button { ...props }>Children</Button> + <Button { ...props } iconPosition="right"> + Children (icon right) + </Button> + <Button { ...props } text="Text" /> + <Button + { ...props } + text="Text (icon right)" + iconPosition="right" + /> + </div> + ); +}; +Dashicons.args = { + icon: 'editor-help', + variant: 'primary', +}; diff --git a/packages/components/src/button/stories/index.story.tsx b/packages/components/src/button/stories/index.story.tsx new file mode 100644 index 00000000000000..a1dadab081d8ac --- /dev/null +++ b/packages/components/src/button/stories/index.story.tsx @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { + formatBold, + formatItalic, + link, + more, + wordpress, +} from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import './style.css'; +import Button from '..'; + +const meta: Meta< typeof Button > = { + title: 'Components/Button', + component: Button, + argTypes: { + // Overrides a limitation of the docgen interpreting our TS types for this as required. + href: { type: { name: 'string', required: false } }, + icon: { + control: { type: 'select' }, + options: [ 'wordpress', 'link', 'more' ], + mapping: { + wordpress, + link, + more, + }, + }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Button > = ( props ) => { + return <Button { ...props }></Button>; +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: 'Code is poetry', +}; + +export const Primary = Template.bind( {} ); +Primary.args = { + ...Default.args, + variant: 'primary', +}; + +export const Secondary = Template.bind( {} ); +Secondary.args = { + ...Default.args, + variant: 'secondary', +}; + +export const Tertiary = Template.bind( {} ); +Tertiary.args = { + ...Default.args, + variant: 'tertiary', +}; + +export const Link = Template.bind( {} ); +Link.args = { + ...Default.args, + variant: 'link', +}; + +export const IsDestructive = Template.bind( {} ); +IsDestructive.args = { + ...Default.args, + isDestructive: true, +}; + +export const Icon = Template.bind( {} ); +Icon.args = { + label: 'Code is poetry', + icon: 'wordpress', +}; + +export const GroupedIcons = () => { + const GroupContainer = ( { children }: { children: ReactNode } ) => ( + <div style={ { display: 'inline-flex' } }>{ children }</div> + ); + + return ( + <GroupContainer> + <Button icon={ formatBold } label="Bold" /> + <Button icon={ formatItalic } label="Italic" /> + <Button icon={ link } label="Link" /> + </GroupContainer> + ); +}; diff --git a/packages/components/src/button/stories/index.tsx b/packages/components/src/button/stories/index.tsx deleted file mode 100644 index ed4aafe3ae2310..00000000000000 --- a/packages/components/src/button/stories/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { ReactNode } from 'react'; - -/** - * WordPress dependencies - */ -import { - formatBold, - formatItalic, - link, - more, - wordpress, -} from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import './style.css'; -import Button from '..'; - -const meta: ComponentMeta< typeof Button > = { - title: 'Components/Button', - component: Button, - argTypes: { - // Overrides a limitation of the docgen interpreting our TS types for this as required. - href: { type: { name: 'string', required: false } }, - icon: { - control: { type: 'select' }, - options: [ 'wordpress', 'link', 'more' ], - mapping: { - wordpress, - link, - more, - }, - }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Button > = ( props ) => { - return <Button { ...props }></Button>; -}; - -export const Default: ComponentStory< typeof Button > = Template.bind( {} ); -Default.args = { - children: 'Code is poetry', -}; - -export const Primary: ComponentStory< typeof Button > = Template.bind( {} ); -Primary.args = { - ...Default.args, - variant: 'primary', -}; - -export const Secondary: ComponentStory< typeof Button > = Template.bind( {} ); -Secondary.args = { - ...Default.args, - variant: 'secondary', -}; - -export const Tertiary: ComponentStory< typeof Button > = Template.bind( {} ); -Tertiary.args = { - ...Default.args, - variant: 'tertiary', -}; - -export const Link: ComponentStory< typeof Button > = Template.bind( {} ); -Link.args = { - ...Default.args, - variant: 'link', -}; - -export const IsDestructive: ComponentStory< typeof Button > = Template.bind( - {} -); -IsDestructive.args = { - ...Default.args, - isDestructive: true, -}; - -export const Icon: ComponentStory< typeof Button > = Template.bind( {} ); -Icon.args = { - label: 'Code is poetry', - icon: 'wordpress', -}; - -export const GroupedIcons: ComponentStory< typeof Button > = () => { - const GroupContainer = ( { children }: { children: ReactNode } ) => ( - <div style={ { display: 'inline-flex' } }>{ children }</div> - ); - - return ( - <GroupContainer> - <Button icon={ formatBold } label="Bold" /> - <Button icon={ formatItalic } label="Italic" /> - <Button icon={ link } label="Link" /> - </GroupContainer> - ); -}; diff --git a/packages/components/src/button/style.native.scss b/packages/components/src/button/style.native.scss new file mode 100644 index 00000000000000..ab90f8ec64e82c --- /dev/null +++ b/packages/components/src/button/style.native.scss @@ -0,0 +1,9 @@ +.components-button-light--active { + border-color: $light-dim; + background-color: $light-dim; +} + +.components-button-dark--active { + border-color: $dark-quaternary; + background-color: $dark-quaternary; +} diff --git a/packages/components/src/button/style.scss b/packages/components/src/button/style.scss index fefc93b4fa8e0e..b572e96e4335fc 100644 --- a/packages/components/src/button/style.scss +++ b/packages/components/src/button/style.scss @@ -28,6 +28,7 @@ } // Unset some hovers, instead of adding :not specificity. + &:disabled:hover, &[aria-disabled="true"]:hover { color: initial; } @@ -168,11 +169,6 @@ background: rgba(var(--wp-admin-theme-color--rgb), 0.08); } - .dashicon { - display: inline-block; - flex: 0 0 auto; - } - // Pull left if the tertiary button stands alone after a description, so as to vertically align with items above. p + & { margin-left: -($grid-unit-15 * 0.5); @@ -190,7 +186,6 @@ // Only the default variant is styled differently from the non-destructive counterparts. &:not(.is-primary):not(.is-secondary):not(.is-tertiary):not(.is-link) { color: $alert-red; - box-shadow: inset 0 0 0 $border-width $alert-red; &:hover:not(:disabled) { color: darken($alert-red, 20%); @@ -262,16 +257,26 @@ /* stylelint-enable */ } + &.is-compact { + height: $button-size-compact; + + &.has-icon:not(.has-text) { + padding: 0; + width: $button-size-compact; + min-width: $button-size-compact; + } + } + &.is-small { - height: $icon-size; + height: $button-size-small; line-height: 22px; padding: 0 8px; font-size: 11px; &.has-icon:not(.has-text) { padding: 0; - width: $icon-size; - min-width: $icon-size; + width: $button-size-small; + min-width: $button-size-small; } } @@ -287,10 +292,11 @@ } .dashicon { - display: inline-block; - flex: 0 0 auto; - margin-left: 2px; - margin-right: 2px; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 2px; + box-sizing: content-box; } &.has-text { @@ -299,10 +305,6 @@ padding-left: $grid-unit-10; gap: $grid-unit-05; } - - &.has-text .dashicon { - margin-right: $grid-unit-10 + 2px; - } } // Toggled style. diff --git a/packages/components/src/button/test/index.tsx b/packages/components/src/button/test/index.tsx index 0219896781534e..881a71484c18f2 100644 --- a/packages/components/src/button/test/index.tsx +++ b/packages/components/src/button/test/index.tsx @@ -402,6 +402,19 @@ describe( 'Button', () => { ); expect( console ).toHaveWarned(); } ); + + it( 'should not break when the legacy isSmall prop is passed', () => { + render( <Button isSmall /> ); + expect( screen.getByRole( 'button' ) ).toHaveClass( 'is-small' ); + } ); + + it( 'should prioritize the `size` prop over `isSmall`', () => { + render( <Button size="compact" isSmall /> ); + expect( screen.getByRole( 'button' ) ).not.toHaveClass( + 'is-small' + ); + expect( screen.getByRole( 'button' ) ).toHaveClass( 'is-compact' ); + } ); } ); describe( 'static typing', () => { diff --git a/packages/components/src/button/types.ts b/packages/components/src/button/types.ts index 57e2a3df6ee61e..c55b74c0461573 100644 --- a/packages/components/src/button/types.ts +++ b/packages/components/src/button/types.ts @@ -65,8 +65,13 @@ type BaseButtonProps = { * Renders a pressed button style. */ isPressed?: boolean; + // TODO: Deprecate officially (add console warning and move to DeprecatedButtonProps). /** * Decreases the size of the button. + * + * Deprecated in favor of the `size` prop. If both props are defined, the `size` prop will take precedence. + * + * @deprecated Use the `'small'` value on the `size` prop instead. */ isSmall?: boolean; /** @@ -83,6 +88,18 @@ type BaseButtonProps = { * If provided, renders a Tooltip component for the button. */ showTooltip?: boolean; + /** + * The size of the button. + * + * - `'default'`: For normal text-label buttons, unless it is a toggle button. + * - `'compact'`: For toggle buttons, icon buttons, and buttons when used in context of either. + * - `'small'`: For icon buttons associated with more advanced or auxiliary features. + * + * If the deprecated `isSmall` prop is also defined, this prop will take precedence. + * + * @default 'default' + */ + size?: 'default' | 'compact' | 'small'; /** * If provided, displays the given text inside the button. If the button contains children elements, the text is displayed before them. */ diff --git a/packages/components/src/card/card-body/component.tsx b/packages/components/src/card/card-body/component.tsx index cab52cf02ad25d..f515162bbc1b76 100644 --- a/packages/components/src/card/card-body/component.tsx +++ b/packages/components/src/card/card-body/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { Scrollable } from '../../scrollable'; import { View } from '../../view'; import { useCardBody } from './hook'; diff --git a/packages/components/src/card/card-body/hook.ts b/packages/components/src/card/card-body/hook.ts index a02a6981b54e3d..1418571fb71f58 100644 --- a/packages/components/src/card/card-body/hook.ts +++ b/packages/components/src/card/card-body/hook.ts @@ -6,7 +6,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; import type { BodyProps } from '../types'; diff --git a/packages/components/src/card/card-divider/component.tsx b/packages/components/src/card/card-divider/component.tsx index cdd52bffd5c99f..494d3451bb5ca9 100644 --- a/packages/components/src/card/card-divider/component.tsx +++ b/packages/components/src/card/card-divider/component.tsx @@ -6,8 +6,10 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; -import { Divider, DividerProps } from '../../divider'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; +import type { DividerProps } from '../../divider'; +import { Divider } from '../../divider'; import { useCardDivider } from './hook'; function UnconnectedCardDivider( diff --git a/packages/components/src/card/card-divider/hook.ts b/packages/components/src/card/card-divider/hook.ts index aa5fdbe08eecbb..969bdf0a43b71d 100644 --- a/packages/components/src/card/card-divider/hook.ts +++ b/packages/components/src/card/card-divider/hook.ts @@ -6,7 +6,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; import type { DividerProps } from '../../divider'; diff --git a/packages/components/src/card/card-footer/component.tsx b/packages/components/src/card/card-footer/component.tsx index 576807444c3069..1507c7139cda27 100644 --- a/packages/components/src/card/card-footer/component.tsx +++ b/packages/components/src/card/card-footer/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { Flex } from '../../flex'; import { useCardFooter } from './hook'; import type { FooterProps } from '../types'; diff --git a/packages/components/src/card/card-footer/hook.ts b/packages/components/src/card/card-footer/hook.ts index 831a9c7773f778..1530faccaf15ce 100644 --- a/packages/components/src/card/card-footer/hook.ts +++ b/packages/components/src/card/card-footer/hook.ts @@ -6,7 +6,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; import type { FooterProps } from '../types'; diff --git a/packages/components/src/card/card-header/component.tsx b/packages/components/src/card/card-header/component.tsx index a055cf0966f52e..01c7942e438f2c 100644 --- a/packages/components/src/card/card-header/component.tsx +++ b/packages/components/src/card/card-header/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { Flex } from '../../flex'; import { useCardHeader } from './hook'; import type { HeaderProps } from '../types'; diff --git a/packages/components/src/card/card-header/hook.ts b/packages/components/src/card/card-header/hook.ts index 9cf9387b62a265..4804e76262d1dd 100644 --- a/packages/components/src/card/card-header/hook.ts +++ b/packages/components/src/card/card-header/hook.ts @@ -6,7 +6,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; import type { HeaderProps } from '../types'; diff --git a/packages/components/src/card/card-media/component.tsx b/packages/components/src/card/card-media/component.tsx index a940c2d2c8ec4e..13e453c2aedcd6 100644 --- a/packages/components/src/card/card-media/component.tsx +++ b/packages/components/src/card/card-media/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; import { useCardMedia } from './hook'; import type { MediaProps } from '../types'; diff --git a/packages/components/src/card/card-media/hook.ts b/packages/components/src/card/card-media/hook.ts index edbedb986a4901..dfe67b8ed19731 100644 --- a/packages/components/src/card/card-media/hook.ts +++ b/packages/components/src/card/card-media/hook.ts @@ -6,7 +6,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; import type { MediaProps } from '../types'; diff --git a/packages/components/src/card/card/README.md b/packages/components/src/card/card/README.md index b640b7159c33db..ffad1e2ec36e53 100644 --- a/packages/components/src/card/card/README.md +++ b/packages/components/src/card/card/README.md @@ -20,7 +20,7 @@ function Example() { return ( <Card> <CardHeader> - <Heading size={ 4 }>Card Title</Heading> + <Heading level={ 4 }>Card Title</Heading> </CardHeader> <CardBody> <Text>Card Content</Text> diff --git a/packages/components/src/card/card/component.tsx b/packages/components/src/card/card/component.tsx index 57f9c6430c0c08..b4f3831ce2f7e4 100644 --- a/packages/components/src/card/card/component.tsx +++ b/packages/components/src/card/card/component.tsx @@ -12,11 +12,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { - contextConnect, - ContextSystemProvider, - WordPressComponentProps, -} from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect, ContextSystemProvider } from '../../ui/context'; import { Elevation } from '../../elevation'; import { View } from '../../view'; import * as styles from '../styles'; @@ -88,15 +85,15 @@ function UnconnectedCard( * CardHeader, * CardBody, * CardFooter, - * Text, - * Heading, + * __experimentalText as Text, + * __experimentalHeading as Heading, * } from `@wordpress/components`; * * function Example() { * return ( * <Card> * <CardHeader> - * <Heading size={ 4 }>Card Title</Heading> + * <Heading level={ 4 }>Card Title</Heading> * </CardHeader> * <CardBody> * <Text>Card Content</Text> diff --git a/packages/components/src/card/card/hook.ts b/packages/components/src/card/card/hook.ts index 57e928cb7f8592..eb4580941d01c3 100644 --- a/packages/components/src/card/card/hook.ts +++ b/packages/components/src/card/card/hook.ts @@ -7,7 +7,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useSurface } from '../../surface'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; diff --git a/packages/components/src/card/stories/index.story.tsx b/packages/components/src/card/stories/index.story.tsx new file mode 100644 index 00000000000000..8a20d82b318e3b --- /dev/null +++ b/packages/components/src/card/stories/index.story.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { + Card, + CardHeader, + CardBody, + CardDivider, + CardMedia, + CardFooter, +} from '..'; +import { Text } from '../../text'; +import { Heading } from '../../heading'; +import Button from '../../button'; + +const meta: Meta< typeof Card > = { + component: Card, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { CardHeader, CardBody, CardDivider, CardMedia, CardFooter }, + title: 'Components/Card', + argTypes: { + as: { + control: { type: null }, + }, + children: { + control: { type: null }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +const Template: StoryFn< typeof Card > = ( args ) => <Card { ...args } />; + +export const Default = Template.bind( {} ); +Default.args = { + children: ( + <> + <CardHeader> + <Heading>CardHeader</Heading> + </CardHeader> + <CardBody> + <Text>CardBody</Text> + </CardBody> + <CardBody> + <Text>CardBody (before CardDivider)</Text> + </CardBody> + <CardDivider /> + <CardBody> + <Text>CardBody (after CardDivider)</Text> + </CardBody> + <CardMedia> + <img + alt="Card Media" + src="https://images.unsplash.com/photo-1566125882500-87e10f726cdc?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1867&q=80" + /> + </CardMedia> + <CardFooter> + <Text>CardFooter</Text> + <Button variant="secondary">Action Button</Button> + </CardFooter> + </> + ), +}; + +/** + * `CardMedia` provides a container for full-bleed content within a `Card`, + * such as images, video, or even just a background color. The corners will be rounded if necessary. + */ +export const FullBleedContent = Template.bind( {} ); +FullBleedContent.args = { + ...Default.args, + children: ( + <CardMedia> + <div style={ { padding: 16, background: 'beige' } }> + Some full bleed content + </div> + </CardMedia> + ), +}; diff --git a/packages/components/src/card/stories/index.tsx b/packages/components/src/card/stories/index.tsx deleted file mode 100644 index 1f903456331549..00000000000000 --- a/packages/components/src/card/stories/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { - Card, - CardHeader, - CardBody, - CardDivider, - CardMedia, - CardFooter, -} from '..'; -import { Text } from '../../text'; -import { Heading } from '../../heading'; -import Button from '../../button'; - -const meta: ComponentMeta< typeof Card > = { - component: Card, - title: 'Components/Card', - subcomponents: { CardHeader, CardBody, CardDivider, CardMedia, CardFooter }, - argTypes: { - as: { - control: { type: null }, - }, - children: { - control: { type: null }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -const Template: ComponentStory< typeof Card > = ( args ) => ( - <Card { ...args } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - children: ( - <> - <CardHeader> - <Heading>CardHeader</Heading> - </CardHeader> - <CardBody> - <Text>CardBody</Text> - </CardBody> - <CardBody> - <Text>CardBody (before CardDivider)</Text> - </CardBody> - <CardDivider /> - <CardBody> - <Text>CardBody (after CardDivider)</Text> - </CardBody> - <CardMedia> - <img - alt="Card Media" - src="https://images.unsplash.com/photo-1566125882500-87e10f726cdc?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1867&q=80" - /> - </CardMedia> - <CardFooter> - <Text>CardFooter</Text> - <Button variant="secondary">Action Button</Button> - </CardFooter> - </> - ), -}; - -/** - * `CardMedia` provides a container for full-bleed content within a `Card`, - * such as images, video, or even just a background color. The corners will be rounded if necessary. - */ -export const FullBleedContent = Template.bind( {} ); -FullBleedContent.args = { - ...Default.args, - children: ( - <CardMedia> - <div style={ { padding: 16, background: 'beige' } }> - Some full bleed content - </div> - </CardMedia> - ), -}; diff --git a/packages/components/src/checkbox-control/stories/index.story.tsx b/packages/components/src/checkbox-control/stories/index.story.tsx new file mode 100644 index 00000000000000..ce55cfb655a17c --- /dev/null +++ b/packages/components/src/checkbox-control/stories/index.story.tsx @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import CheckboxControl from '..'; +import { VStack } from '../../v-stack'; + +const meta: Meta< typeof CheckboxControl > = { + component: CheckboxControl, + title: 'Components/CheckboxControl', + argTypes: { + onChange: { + action: 'onChange', + }, + checked: { + control: { type: null }, + }, + help: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + exclude: [ 'heading' ], + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const DefaultTemplate: StoryFn< typeof CheckboxControl > = ( { + onChange, + ...args +} ) => { + const [ isChecked, setChecked ] = useState( true ); + + return ( + <CheckboxControl + { ...args } + checked={ isChecked } + onChange={ ( v ) => { + setChecked( v ); + onChange( v ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof CheckboxControl > = DefaultTemplate.bind( + {} +); +Default.args = { + label: 'Is author', + help: 'Is the user an author or not?', +}; + +export const Indeterminate: StoryFn< typeof CheckboxControl > = ( { + onChange, + ...args +} ) => { + const [ fruits, setFruits ] = useState( { apple: false, orange: false } ); + + const isAllChecked = Object.values( fruits ).every( Boolean ); + const isIndeterminate = + Object.values( fruits ).some( Boolean ) && ! isAllChecked; + + return ( + <VStack> + <CheckboxControl + { ...args } + checked={ isAllChecked } + indeterminate={ isIndeterminate } + onChange={ ( v ) => { + setFruits( { + apple: v, + orange: v, + } ); + onChange( v ); + } } + /> + <CheckboxControl + __nextHasNoMarginBottom + label="Apple" + checked={ fruits.apple } + onChange={ ( apple ) => + setFruits( ( prevState ) => ( { + ...prevState, + apple, + } ) ) + } + /> + <CheckboxControl + __nextHasNoMarginBottom + label="Orange" + checked={ fruits.orange } + onChange={ ( orange ) => + setFruits( ( prevState ) => ( { + ...prevState, + orange, + } ) ) + } + /> + </VStack> + ); +}; +Indeterminate.args = { + label: 'Select all', + __nextHasNoMarginBottom: true, +}; diff --git a/packages/components/src/checkbox-control/stories/index.tsx b/packages/components/src/checkbox-control/stories/index.tsx deleted file mode 100644 index 9054e50dc2ffc3..00000000000000 --- a/packages/components/src/checkbox-control/stories/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import CheckboxControl from '..'; -import { VStack } from '../../v-stack'; - -const meta: ComponentMeta< typeof CheckboxControl > = { - component: CheckboxControl, - title: 'Components/CheckboxControl', - argTypes: { - onChange: { - action: 'onChange', - }, - checked: { - control: { type: null }, - }, - help: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - exclude: [ 'heading' ], - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const DefaultTemplate: ComponentStory< typeof CheckboxControl > = ( { - onChange, - ...args -} ) => { - const [ isChecked, setChecked ] = useState( true ); - - return ( - <CheckboxControl - { ...args } - checked={ isChecked } - onChange={ ( v ) => { - setChecked( v ); - onChange( v ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof CheckboxControl > = - DefaultTemplate.bind( {} ); -Default.args = { - label: 'Is author', - help: 'Is the user an author or not?', -}; - -export const Indeterminate: ComponentStory< typeof CheckboxControl > = ( { - onChange, - ...args -} ) => { - const [ fruits, setFruits ] = useState( { apple: false, orange: false } ); - - const isAllChecked = Object.values( fruits ).every( Boolean ); - const isIndeterminate = - Object.values( fruits ).some( Boolean ) && ! isAllChecked; - - return ( - <VStack> - <CheckboxControl - { ...args } - checked={ isAllChecked } - indeterminate={ isIndeterminate } - onChange={ ( v ) => { - setFruits( { - apple: v, - orange: v, - } ); - onChange( v ); - } } - /> - <CheckboxControl - __nextHasNoMarginBottom - label="Apple" - checked={ fruits.apple } - onChange={ ( apple ) => - setFruits( ( prevState ) => ( { - ...prevState, - apple, - } ) ) - } - /> - <CheckboxControl - __nextHasNoMarginBottom - label="Orange" - checked={ fruits.orange } - onChange={ ( orange ) => - setFruits( ( prevState ) => ( { - ...prevState, - orange, - } ) ) - } - /> - </VStack> - ); -}; -Indeterminate.args = { - label: 'Select all', - __nextHasNoMarginBottom: true, -}; diff --git a/packages/components/src/circular-option-picker/stories/index.story.tsx b/packages/components/src/circular-option-picker/stories/index.story.tsx new file mode 100644 index 00000000000000..4ad346b93cf5cc --- /dev/null +++ b/packages/components/src/circular-option-picker/stories/index.story.tsx @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +/** + * WordPress dependencies + */ +import { useState, createContext, useContext } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { + ButtonAction, + default as CircularOptionPicker, + DropdownLinkAction, +} from '..'; + +const CircularOptionPickerStoryContext = createContext< { + currentColor?: string; + setCurrentColor?: ( v: string | undefined ) => void; +} >( {} ); + +const meta: Meta< typeof CircularOptionPicker > = { + title: 'Components/CircularOptionPicker', + component: CircularOptionPicker, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'CircularOptionPicker.Option': Option, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'CircularOptionPicker.ButtonAction': ButtonAction, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'CircularOptionPicker.DropdownLinkAction': DropdownLinkAction, + }, + argTypes: { + actions: { control: { type: null } }, + options: { control: { type: null } }, + children: { control: { type: 'text' } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { + canvas: { sourceState: 'shown' }, + source: { excludeDecorators: true }, + }, + }, + decorators: [ + // Share current color state between main component, `actions` and `options` + ( Story ) => { + const [ currentColor, setCurrentColor ] = useState< string >(); + + return ( + <CircularOptionPickerStoryContext.Provider + value={ { + currentColor, + setCurrentColor, + } } + > + <Story /> + </CircularOptionPickerStoryContext.Provider> + ); + }, + ], +}; +export default meta; + +const colors = [ + { color: '#f00', name: 'Red' }, + { color: '#0f0', name: 'Green' }, + { color: '#0af', name: 'Blue' }, +]; + +const DefaultOptions = () => { + const { currentColor, setCurrentColor } = useContext( + CircularOptionPickerStoryContext + ); + + return ( + <> + { colors.map( ( { color, name }, index ) => { + return ( + <CircularOptionPicker.Option + key={ `${ color }-${ index }` } + tooltipText={ name } + style={ { backgroundColor: color, color } } + isSelected={ color === currentColor } + onClick={ () => { + setCurrentColor?.( color ); + } } + aria-label={ name } + /> + ); + } ) } + </> + ); +}; + +const DefaultActions = () => { + const { setCurrentColor } = useContext( CircularOptionPickerStoryContext ); + + return ( + <CircularOptionPicker.ButtonAction + onClick={ () => setCurrentColor?.( undefined ) } + > + { 'Clear' } + </CircularOptionPicker.ButtonAction> + ); +}; + +const Template: StoryFn< typeof CircularOptionPicker > = ( props ) => ( + <CircularOptionPicker { ...props } /> +); + +export const Default = Template.bind( {} ); +Default.args = { options: <DefaultOptions /> }; + +export const WithButtonAction = Template.bind( {} ); +WithButtonAction.args = { + ...Default.args, + actions: <DefaultActions />, +}; +WithButtonAction.storyName = 'With ButtonAction'; + +export const WithDropdownLinkAction = Template.bind( {} ); +WithDropdownLinkAction.args = { + ...Default.args, + actions: ( + <CircularOptionPicker.DropdownLinkAction + dropdownProps={ { + popoverProps: { position: 'top right' }, + renderContent: () => ( + <div>This is an example of a DropdownLinkAction.</div> + ), + } } + linkText="Learn More" + ></CircularOptionPicker.DropdownLinkAction> + ), +}; +WithDropdownLinkAction.storyName = 'With DropdownLinkAction'; diff --git a/packages/components/src/circular-option-picker/stories/index.tsx b/packages/components/src/circular-option-picker/stories/index.tsx deleted file mode 100644 index a2e1bb5b0e482b..00000000000000 --- a/packages/components/src/circular-option-picker/stories/index.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -/** - * WordPress dependencies - */ -import { useState, createContext, useContext } from '@wordpress/element'; -/** - * Internal dependencies - */ -import { - default as CircularOptionPicker, - ButtonAction, - DropdownLinkAction, - Option, -} from '..'; - -const CircularOptionPickerStoryContext = createContext< { - currentColor?: string; - setCurrentColor?: ( v: string | undefined ) => void; -} >( {} ); - -const meta: ComponentMeta< typeof CircularOptionPicker > = { - title: 'Components/CircularOptionPicker', - component: CircularOptionPicker, - subcomponents: { - 'CircularOptionPicker.Option': Option, - 'CircularOptionPicker.ButtonAction': ButtonAction, - 'CircularOptionPicker.DropdownLinkAction': DropdownLinkAction, - }, - argTypes: { - actions: { control: { type: null } }, - options: { control: { type: null } }, - children: { control: { type: 'text' } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open', excludeDecorators: true } }, - }, - decorators: [ - // Share current color state between main component, `actions` and `options` - ( Story ) => { - const [ currentColor, setCurrentColor ] = useState< string >(); - - return ( - <CircularOptionPickerStoryContext.Provider - value={ { - currentColor, - setCurrentColor, - } } - > - <Story /> - </CircularOptionPickerStoryContext.Provider> - ); - }, - ], -}; -export default meta; - -const colors = [ - { color: '#f00', name: 'Red' }, - { color: '#0f0', name: 'Green' }, - { color: '#0af', name: 'Blue' }, -]; - -const DefaultOptions = () => { - const { currentColor, setCurrentColor } = useContext( - CircularOptionPickerStoryContext - ); - - return ( - <> - { colors.map( ( { color, name }, index ) => { - return ( - <CircularOptionPicker.Option - key={ `${ color }-${ index }` } - tooltipText={ name } - style={ { backgroundColor: color, color } } - isSelected={ color === currentColor } - onClick={ () => { - setCurrentColor?.( color ); - } } - aria-label={ name } - /> - ); - } ) } - </> - ); -}; - -const DefaultActions = () => { - const { setCurrentColor } = useContext( CircularOptionPickerStoryContext ); - - return ( - <CircularOptionPicker.ButtonAction - onClick={ () => setCurrentColor?.( undefined ) } - > - { 'Clear' } - </CircularOptionPicker.ButtonAction> - ); -}; - -const Template: ComponentStory< typeof CircularOptionPicker > = ( props ) => ( - <CircularOptionPicker { ...props } /> -); - -export const Default = Template.bind( {} ); -Default.args = { options: <DefaultOptions /> }; - -export const WithButtonAction = Template.bind( {} ); -WithButtonAction.args = { - ...Default.args, - actions: <DefaultActions />, -}; -WithButtonAction.storyName = 'With ButtonAction'; - -export const WithDropdownLinkAction = Template.bind( {} ); -WithDropdownLinkAction.args = { - ...Default.args, - actions: ( - <CircularOptionPicker.DropdownLinkAction - dropdownProps={ { - popoverProps: { position: 'top right' }, - renderContent: () => ( - <div>This is an example of a DropdownLinkAction.</div> - ), - } } - linkText="Learn More" - ></CircularOptionPicker.DropdownLinkAction> - ), -}; -WithDropdownLinkAction.storyName = 'With DropdownLinkAction'; diff --git a/packages/components/src/circular-option-picker/types.ts b/packages/components/src/circular-option-picker/types.ts index cd966681c55f7b..a72ad44356dcd2 100644 --- a/packages/components/src/circular-option-picker/types.ts +++ b/packages/components/src/circular-option-picker/types.ts @@ -12,7 +12,7 @@ import type { Icon } from '@wordpress/icons'; * Internal dependencies */ import type { ButtonAsButtonProps } from '../button/types'; -import type Dropdown from '../dropdown'; +import type { DropdownProps } from '../dropdown/types'; import type { WordPressComponentProps } from '../ui/context'; export type CircularOptionPickerProps = { @@ -44,10 +44,7 @@ export type DropdownLinkActionProps = { 'children' >; linkText: string; - dropdownProps: Omit< - React.ComponentProps< typeof Dropdown >, - 'className' | 'renderToggle' - >; + dropdownProps: Omit< DropdownProps, 'className' | 'renderToggle' >; className?: string; }; diff --git a/packages/components/src/clipboard-button/index.js b/packages/components/src/clipboard-button/index.js deleted file mode 100644 index 9cce2dbefa9a79..00000000000000 --- a/packages/components/src/clipboard-button/index.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - -/** - * WordPress dependencies - */ -import { useRef, useEffect } from '@wordpress/element'; -import { useCopyToClipboard } from '@wordpress/compose'; -import deprecated from '@wordpress/deprecated'; - -/** - * Internal dependencies - */ -import Button from '../button'; - -const TIMEOUT = 4000; - -/** - * @param {Object} props - * @param {string} [props.className] - * @param {import('react').ReactNode} props.children - * @param {() => void} props.onCopy - * @param {() => void} [props.onFinishCopy] - * @param {string} props.text - */ -export default function ClipboardButton( { - className, - children, - onCopy, - onFinishCopy, - text, - ...buttonProps -} ) { - deprecated( 'wp.components.ClipboardButton', { - since: '5.8', - alternative: 'wp.compose.useCopyToClipboard', - } ); - - /** @type {import('react').MutableRefObject<ReturnType<setTimeout> | undefined>} */ - const timeoutId = useRef(); - const ref = useCopyToClipboard( text, () => { - onCopy(); - // @ts-expect-error: Should check if .current is defined, but not changing because this component is deprecated. - clearTimeout( timeoutId.current ); - - if ( onFinishCopy ) { - timeoutId.current = setTimeout( () => onFinishCopy(), TIMEOUT ); - } - } ); - - useEffect( () => { - // @ts-expect-error: Should check if .current is defined, but not changing because this component is deprecated. - clearTimeout( timeoutId.current ); - }, [] ); - - const classes = classnames( 'components-clipboard-button', className ); - - // Workaround for inconsistent behavior in Safari, where <textarea> is not - // the document.activeElement at the moment when the copy event fires. - // This causes documentHasSelection() in the copy-handler component to - // mistakenly override the ClipboardButton, and copy a serialized string - // of the current block instead. - /** @type {import('react').ClipboardEventHandler<HTMLButtonElement>} */ - const focusOnCopyEventTarget = ( event ) => { - // @ts-expect-error: Should be currentTarget, but not changing because this component is deprecated. - event.target.focus(); - }; - - return ( - <Button - { ...buttonProps } - className={ classes } - ref={ ref } - onCopy={ focusOnCopyEventTarget } - > - { children } - </Button> - ); -} diff --git a/packages/components/src/clipboard-button/index.tsx b/packages/components/src/clipboard-button/index.tsx new file mode 100644 index 00000000000000..ded4e9ad45d1a6 --- /dev/null +++ b/packages/components/src/clipboard-button/index.tsx @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useRef, useEffect } from '@wordpress/element'; +import { useCopyToClipboard } from '@wordpress/compose'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import type { ClipboardButtonProps } from './types'; +import type { WordPressComponentProps } from '../ui/context'; + +const TIMEOUT = 4000; + +export default function ClipboardButton( { + className, + children, + onCopy, + onFinishCopy, + text, + ...buttonProps +}: WordPressComponentProps< ClipboardButtonProps, 'button', false > ) { + deprecated( 'wp.components.ClipboardButton', { + since: '5.8', + alternative: 'wp.compose.useCopyToClipboard', + } ); + + const timeoutId = useRef< NodeJS.Timeout >(); + const ref = useCopyToClipboard( text, () => { + onCopy(); + if ( timeoutId.current ) { + clearTimeout( timeoutId.current ); + } + + if ( onFinishCopy ) { + timeoutId.current = setTimeout( () => onFinishCopy(), TIMEOUT ); + } + } ); + + useEffect( () => { + if ( timeoutId.current ) { + clearTimeout( timeoutId.current ); + } + }, [] ); + + const classes = classnames( 'components-clipboard-button', className ); + + // Workaround for inconsistent behavior in Safari, where <textarea> is not + // the document.activeElement at the moment when the copy event fires. + // This causes documentHasSelection() in the copy-handler component to + // mistakenly override the ClipboardButton, and copy a serialized string + // of the current block instead. + const focusOnCopyEventTarget: React.ClipboardEventHandler = ( event ) => { + // @ts-expect-error: Should be currentTarget, but not changing because this component is deprecated. + event.target.focus(); + }; + + return ( + <Button + { ...buttonProps } + className={ classes } + ref={ ref } + onCopy={ focusOnCopyEventTarget } + > + { children } + </Button> + ); +} diff --git a/packages/components/src/clipboard-button/types.ts b/packages/components/src/clipboard-button/types.ts new file mode 100644 index 00000000000000..7987230598fbbb --- /dev/null +++ b/packages/components/src/clipboard-button/types.ts @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +export interface ClipboardButtonProps { + children: ReactNode; + onCopy: () => void; + onFinishCopy?: () => void; + text: string; +} diff --git a/packages/components/src/color-indicator/stories/index.story.tsx b/packages/components/src/color-indicator/stories/index.story.tsx new file mode 100644 index 00000000000000..8a2557f30659e0 --- /dev/null +++ b/packages/components/src/color-indicator/stories/index.story.tsx @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import ColorIndicator from '..'; + +const meta: Meta< typeof ColorIndicator > = { + component: ColorIndicator, + title: 'Components/ColorIndicator', + argTypes: { + colorValue: { + control: { type: 'color' }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ColorIndicator > = ( { ...args } ) => ( + <ColorIndicator { ...args } /> +); + +export const Default: StoryFn< typeof ColorIndicator > = Template.bind( {} ); +Default.args = { + colorValue: '#0073aa', +}; diff --git a/packages/components/src/color-indicator/stories/index.tsx b/packages/components/src/color-indicator/stories/index.tsx deleted file mode 100644 index 40019fbf0ba1a2..00000000000000 --- a/packages/components/src/color-indicator/stories/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import ColorIndicator from '..'; - -const meta: ComponentMeta< typeof ColorIndicator > = { - component: ColorIndicator, - title: 'Components/ColorIndicator', - argTypes: { - colorValue: { - control: { type: 'color' }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ColorIndicator > = ( { ...args } ) => ( - <ColorIndicator { ...args } /> -); - -export const Default: ComponentStory< typeof ColorIndicator > = Template.bind( - {} -); -Default.args = { - colorValue: '#0073aa', -}; diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx index f741e35eebef7e..b3b46a68ff21f4 100644 --- a/packages/components/src/color-palette/index.tsx +++ b/packages/components/src/color-palette/index.tsx @@ -5,6 +5,7 @@ import type { ForwardedRef } from 'react'; import { colord, extend } from 'colord'; import namesPlugin from 'colord/plugins/names'; import a11yPlugin from 'colord/plugins/a11y'; +import classnames from 'classnames'; /** * WordPress dependencies @@ -19,7 +20,6 @@ import Dropdown from '../dropdown'; import { ColorPicker } from '../color-picker'; import CircularOptionPicker from '../circular-option-picker'; import { VStack } from '../v-stack'; -import { Flex, FlexItem } from '../flex'; import { Truncate } from '../truncate'; import { ColorHeading } from './styles'; import DropdownContentWrapper from '../dropdown/dropdown-content-wrapper'; @@ -37,7 +37,6 @@ import { extractColorNameFromCurrentValue, isMultiplePaletteArray, normalizeColorValue, - showTransparentBackground, } from './utils'; extend( [ namesPlugin, a11yPlugin ] ); @@ -219,21 +218,18 @@ function UnforwardedColorPalette( /> </DropdownContentWrapper> ); + const isHex = value?.startsWith( '#' ); - const colordColor = colord( normalizedColorValue ?? '' ); - - const valueWithoutLeadingHash = value?.startsWith( '#' ) - ? value.substring( 1 ) - : value ?? ''; - - const customColorAccessibleLabel = !! valueWithoutLeadingHash + // Leave hex values as-is. Remove the `var()` wrapper from CSS vars. + const displayValue = value?.replace( /^var\((.+)\)$/, '$1' ); + const customColorAccessibleLabel = !! displayValue ? sprintf( // translators: %1$s: The name of the color e.g: "vivid red". %2$s: The color's hex code e.g: "#f00". __( 'Custom color picker. The currently selected color is called "%1$s" and has a value of "%2$s".' ), buttonLabelName, - valueWithoutLeadingHash + displayValue ) : __( 'Custom color picker.' ); @@ -257,43 +253,48 @@ function UnforwardedColorPalette( isRenderedInSidebar={ __experimentalIsRenderedInSidebar } renderContent={ renderCustomColorPicker } renderToggle={ ( { isOpen, onToggle } ) => ( - <Flex - as={ 'button' } - ref={ customColorPaletteCallbackRef } - justify="space-between" - align="flex-start" - className="components-color-palette__custom-color" - aria-expanded={ isOpen } - aria-haspopup="true" - onClick={ onToggle } - aria-label={ customColorAccessibleLabel } - style={ - showTransparentBackground( value ) - ? { color: '#000' } - : { - background: value, - color: - colordColor.contrast() > - colordColor.contrast( '#000' ) - ? '#fff' - : '#000', - } - } + <VStack + className="components-color-palette__custom-color-wrapper" + spacing={ 0 } > - <FlexItem - isBlock - as={ Truncate } - className="components-color-palette__custom-color-name" - > - { buttonLabelName } - </FlexItem> - <FlexItem - as="span" - className="components-color-palette__custom-color-value" + <button + ref={ customColorPaletteCallbackRef } + className="components-color-palette__custom-color-button" + aria-expanded={ isOpen } + aria-haspopup="true" + onClick={ onToggle } + aria-label={ customColorAccessibleLabel } + style={ { + background: value, + } } + /> + <VStack + className="components-color-palette__custom-color-text-wrapper" + spacing={ 0.5 } > - { valueWithoutLeadingHash } - </FlexItem> - </Flex> + <Truncate className="components-color-palette__custom-color-name"> + { value + ? buttonLabelName + : 'No color selected' } + </Truncate> + { /* + This `Truncate` is always rendered, even if + there is no `displayValue`, to ensure the layout + does not shift + */ } + <Truncate + className={ classnames( + 'components-color-palette__custom-color-value', + { + 'components-color-palette__custom-color-value--is-hex': + isHex, + } + ) } + > + { displayValue } + </Truncate> + </VStack> + </VStack> ) } /> ) } diff --git a/packages/components/src/color-palette/stories/index.story.tsx b/packages/components/src/color-palette/stories/index.story.tsx new file mode 100644 index 00000000000000..9ef253dea4fa36 --- /dev/null +++ b/packages/components/src/color-palette/stories/index.story.tsx @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import type { CSSProperties } from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ColorPalette from '..'; + +const meta: Meta< typeof ColorPalette > = { + title: 'Components/ColorPalette', + component: ColorPalette, + argTypes: { + as: { control: { type: null } }, + onChange: { action: 'onChange', control: { type: null } }, + value: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ColorPalette > = ( { onChange, ...args } ) => { + const [ color, setColor ] = useState< string | undefined >(); + + return ( + <ColorPalette + { ...args } + value={ color } + onChange={ ( newColor ) => { + setColor( newColor ); + onChange?.( newColor ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + colors: [ + { name: 'Red', color: '#f00' }, + { name: 'White', color: '#fff' }, + { name: 'Blue', color: '#00f' }, + ], +}; + +export const MultipleOrigins = Template.bind( {} ); +MultipleOrigins.args = { + colors: [ + { + name: 'Primary colors', + colors: [ + { name: 'Red', color: '#f00' }, + { name: 'Yellow', color: '#ff0' }, + { name: 'Blue', color: '#00f' }, + ], + }, + { + name: 'Secondary colors', + colors: [ + { name: 'Orange', color: '#f60' }, + { name: 'Green', color: '#0f0' }, + { name: 'Purple', color: '#60f' }, + ], + }, + ], +}; + +export const CSSVariables: StoryFn< typeof ColorPalette > = ( args ) => { + return ( + <div + style={ + { + '--red': '#f00', + '--yellow': '#ff0', + '--blue': '#00f', + } as CSSProperties + } + > + <Template { ...args } /> + </div> + ); +}; +CSSVariables.args = { + colors: [ + { name: 'Red', color: 'var(--red)' }, + { name: 'Yellow', color: 'var(--yellow)' }, + { name: 'Blue', color: 'var(--blue)' }, + ], +}; diff --git a/packages/components/src/color-palette/stories/index.tsx b/packages/components/src/color-palette/stories/index.tsx deleted file mode 100644 index 2399b7e9220c0e..00000000000000 --- a/packages/components/src/color-palette/stories/index.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * External dependencies - */ -import type { CSSProperties } from 'react'; -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import ColorPalette from '..'; -import Popover from '../../popover'; -import { Provider as SlotFillProvider } from '../../slot-fill'; - -const meta: ComponentMeta< typeof ColorPalette > = { - title: 'Components/ColorPalette', - component: ColorPalette, - argTypes: { - as: { control: { type: null } }, - onChange: { action: 'onChange', control: { type: null } }, - value: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ColorPalette > = ( { - onChange, - ...args -} ) => { - const [ color, setColor ] = useState< string | undefined >(); - - return ( - <SlotFillProvider> - <ColorPalette - { ...args } - value={ color } - onChange={ ( newColor ) => { - setColor( newColor ); - onChange?.( newColor ); - } } - /> - { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } - <Popover.Slot /> - </SlotFillProvider> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - colors: [ - { name: 'Red', color: '#f00' }, - { name: 'White', color: '#fff' }, - { name: 'Blue', color: '#00f' }, - ], -}; - -export const MultipleOrigins = Template.bind( {} ); -MultipleOrigins.args = { - colors: [ - { - name: 'Primary colors', - colors: [ - { name: 'Red', color: '#f00' }, - { name: 'Yellow', color: '#ff0' }, - { name: 'Blue', color: '#00f' }, - ], - }, - { - name: 'Secondary colors', - colors: [ - { name: 'Orange', color: '#f60' }, - { name: 'Green', color: '#0f0' }, - { name: 'Purple', color: '#60f' }, - ], - }, - ], -}; - -export const CSSVariables: ComponentStory< typeof ColorPalette > = ( args ) => { - return ( - <div - style={ - { - '--red': '#f00', - '--yellow': '#ff0', - '--blue': '#00f', - } as CSSProperties - } - > - <Template { ...args } /> - </div> - ); -}; -CSSVariables.args = { - colors: [ - { name: 'Red', color: 'var(--red)' }, - { name: 'Yellow', color: 'var(--yellow)' }, - { name: 'Blue', color: 'var(--blue)' }, - ], -}; diff --git a/packages/components/src/color-palette/style.scss b/packages/components/src/color-palette/style.scss index 82f1858d64d3c4..2d6bc4ddc1db3d 100644 --- a/packages/components/src/color-palette/style.scss +++ b/packages/components/src/color-palette/style.scss @@ -1,41 +1,79 @@ -.components-color-palette__custom-color { +$border-as-box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.2); + +.components-color-palette__custom-color-wrapper { + position: relative; + z-index: 0; +} +.components-color-palette__custom-color-button { position: relative; border: none; background: none; - border-radius: $radius-block-ui; height: $grid-unit-80; - padding: $grid-unit-15; - font-family: inherit; width: 100%; - // The background image creates a checkerboard pattern. Ignore rtlcss to - // make it work both in LTR and RTL. - // See https://github.com/WordPress/gutenberg/pull/42510 - /*rtl:begin:ignore*/ - background-image: - repeating-linear-gradient(45deg, $gray-200 25%, transparent 25%, transparent 75%, $gray-200 75%, $gray-200), - repeating-linear-gradient(45deg, $gray-200 25%, transparent 25%, transparent 75%, $gray-200 75%, $gray-200); - background-position: 0 0, 24px 24px; - /*rtl:end:ignore*/ - background-size: calc(2 * 24px) calc(2 * 24px); box-sizing: border-box; - color: $white; cursor: pointer; - box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.2); // Show a thin outline in Windows high contrast mode. outline: 1px solid transparent; + border-radius: $radius-block-ui $radius-block-ui 0 0; + box-shadow: $border-as-box-shadow; &:focus { box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent; // Show a outline in Windows high contrast mode. outline-width: 2px; } + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + // The background image creates a checkerboard pattern. Ignore rtlcss to + // make it work both in LTR and RTL. + // See https://github.com/WordPress/gutenberg/pull/42510 + /*rtl:begin:ignore*/ + background-image: + repeating-linear-gradient(45deg, $gray-200 25%, transparent 25%, transparent 75%, $gray-200 75%, $gray-200), + repeating-linear-gradient(45deg, $gray-200 25%, transparent 25%, transparent 75%, $gray-200 75%, $gray-200); + background-position: 0 0, 24px 24px; + /*rtl:end:ignore*/ + background-size: calc(2 * 24px) calc(2 * 24px); + } +} + +.components-color-palette__custom-color-text-wrapper { + padding: $grid-unit-15 $grid-unit-20; + border-radius: 0 0 $radius-block-ui $radius-block-ui; + position: relative; + font-size: $default-font-size; + + // Add a border with the same technique as the button above, + // but only for left, bottom, and right sides. + box-shadow: + inset 0 -1 * $border-width 0 0 rgba(0, 0, 0, 0.2), + inset $border-width 0 0 0 rgba(0, 0, 0, 0.2), + inset -1 * $border-width 0 0 0 rgba(0, 0, 0, 0.2); } .components-color-palette__custom-color-name { - text-align: left; + color: $components-color-foreground; + margin: 0 $border-width; } .components-color-palette__custom-color-value { - margin-left: $grid-unit-20; - text-transform: uppercase; + color: $gray-700; + + &--is-hex { + text-transform: uppercase; + } + + // Add a zero-width space when this element is empty to preserve + // a minimum height of 1 line of text, and avoid layout jumps. + &:empty::after { + content: "\200B"; + visibility: hidden; + } } diff --git a/packages/components/src/color-palette/test/__snapshots__/index.tsx.snap b/packages/components/src/color-palette/test/__snapshots__/index.tsx.snap index efbcf7a4aa4a40..a08760a33e252a 100644 --- a/packages/components/src/color-palette/test/__snapshots__/index.tsx.snap +++ b/packages/components/src/color-palette/test/__snapshots__/index.tsx.snap @@ -127,45 +127,52 @@ exports[`ColorPalette should render a dynamic toolbar of colors 1`] = ` display: -webkit-flex; display: -ms-flexbox; display: flex; - -webkit-align-items: flex-start; - -webkit-box-align: flex-start; - -ms-flex-align: flex-start; - align-items: flex-start; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - gap: calc(4px * 2); - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - justify-content: space-between; - width: 100%; + -webkit-align-items: stretch; + -webkit-box-align: stretch; + -ms-flex-align: stretch; + align-items: stretch; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + gap: 0; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; } .emotion-2>* { - min-width: 0; + min-height: 0; } -.emotion-5 { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: block; - max-height: 100%; - max-width: 100%; +.emotion-4 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: stretch; + -webkit-box-align: stretch; + -ms-flex-align: stretch; + align-items: stretch; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + gap: calc(4px * 0.5); + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; +} + +.emotion-4>* { min-height: 0; - min-width: 0; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; } -.emotion-7 { +.emotion-6 { display: block; - max-height: 100%; - max-width: 100%; - min-height: 0; - min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } <div> @@ -178,30 +185,39 @@ exports[`ColorPalette should render a dynamic toolbar of colors 1`] = ` class="components-dropdown" tabindex="-1" > - <button - aria-expanded="false" - aria-haspopup="true" - aria-label="Custom color picker. The currently selected color is called "red" and has a value of "f00"." - class="components-flex components-color-palette__custom-color emotion-2 emotion-1" + <div + class="components-flex components-h-stack components-v-stack components-color-palette__custom-color-wrapper emotion-2 emotion-1" data-wp-c16t="true" - data-wp-component="Flex" - style="background: rgb(255, 0, 0); color: rgb(0, 0, 0);" + data-wp-component="VStack" > - <span - class="components-truncate components-flex-item components-color-palette__custom-color-name emotion-1 emotion-5 emotion-1" - data-wp-c16t="true" - data-wp-component="FlexItem" - > - red - </span> - <span - class="components-flex-item components-color-palette__custom-color-value emotion-7 emotion-1" + <button + aria-expanded="false" + aria-haspopup="true" + aria-label="Custom color picker. The currently selected color is called "red" and has a value of "#f00"." + class="components-color-palette__custom-color-button" + style="background: rgb(255, 0, 0);" + /> + <div + class="components-flex components-h-stack components-v-stack components-color-palette__custom-color-text-wrapper emotion-4 emotion-1" data-wp-c16t="true" - data-wp-component="FlexItem" + data-wp-component="VStack" > - f00 - </span> - </button> + <span + class="components-truncate components-color-palette__custom-color-name emotion-6 emotion-1" + data-wp-c16t="true" + data-wp-component="Truncate" + > + red + </span> + <span + class="components-truncate components-color-palette__custom-color-value components-color-palette__custom-color-value--is-hex emotion-6 emotion-1" + data-wp-c16t="true" + data-wp-component="Truncate" + > + #f00 + </span> + </div> + </div> </div> <div class="components-circular-option-picker" diff --git a/packages/components/src/color-palette/test/index.tsx b/packages/components/src/color-palette/test/index.tsx index 46b5bc6ba22bdc..62b9cdf51a59d9 100644 --- a/packages/components/src/color-palette/test/index.tsx +++ b/packages/components/src/color-palette/test/index.tsx @@ -1,9 +1,12 @@ /** * External dependencies */ -import { render, screen, within, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; /** * Internal dependencies */ @@ -20,6 +23,25 @@ function getWrappingPopoverElement( element: HTMLElement ) { return element.closest( '.components-popover' ); } +const ControlledColorPalette = ( { + onChange, +}: { + onChange?: ( newColor?: string ) => void; +} ) => { + const [ color, setColor ] = useState< string | undefined >( undefined ); + + return ( + <ColorPalette + value={ color } + colors={ EXAMPLE_COLORS } + onChange={ ( newColor ) => { + setColor( newColor ); + onChange?.( newColor ); + } } + /> + ); +}; + describe( 'ColorPalette', () => { it( 'should render a dynamic toolbar of colors', () => { const onChange = jest.fn(); @@ -142,6 +164,12 @@ describe( 'ColorPalette', () => { /> ); + // Check that custom color popover is not visible by default. + expect( + screen.queryByLabelText( 'Hex color' ) + ).not.toBeInTheDocument(); + + // Click the dropdown button while the dropdown is not expanded. await user.click( screen.getByRole( 'button', { name: /^Custom color picker/, @@ -149,23 +177,13 @@ describe( 'ColorPalette', () => { } ) ); + // Confirm the dropdown is now expanded, and the button is still visible. const dropdownButton = screen.getByRole( 'button', { name: /^Custom color picker/, expanded: true, } ); - expect( dropdownButton ).toBeVisible(); - expect( - within( dropdownButton ).getByText( EXAMPLE_COLORS[ 0 ].name ) - ).toBeVisible(); - - expect( - within( dropdownButton ).getByText( - EXAMPLE_COLORS[ 0 ].color.replace( '#', '' ) - ) - ).toBeVisible(); - // Check that the popover with custom color input has appeared. const dropdownColorInput = screen.getByLabelText( 'Hex color' ); @@ -201,4 +219,51 @@ describe( 'ColorPalette', () => { screen.getByRole( 'button', { name: 'Clear' } ) ).toBeInTheDocument(); } ); + + it( 'should display the selected color name and value', async () => { + const user = userEvent.setup(); + + render( <ControlledColorPalette /> ); + + const { name: colorName, color: colorCode } = EXAMPLE_COLORS[ 0 ]; + + expect( screen.getByText( 'No color selected' ) ).toBeVisible(); + + // Click the first unpressed button + await user.click( + screen.getAllByRole( 'button', { + name: /^Color:/, + pressed: false, + } )[ 0 ] + ); + + // Confirm the correct color name, color value, and button label are used + expect( + screen.getByText( colorName, { + selector: '.components-color-palette__custom-color-name', + } ) + ).toBeVisible(); + expect( + screen.getByText( colorCode, { + selector: '.components-color-palette__custom-color-value', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { + name: `Custom color picker. The currently selected color is called "${ colorName }" and has a value of "${ colorCode }".`, + expanded: false, + } ) + ).toBeInTheDocument(); + + // Clear the color, confirm that the relative values are cleared/updated. + await user.click( screen.getByRole( 'button', { name: 'Clear' } ) ); + expect( screen.getByText( 'No color selected' ) ).toBeVisible(); + expect( screen.queryByText( colorName ) ).not.toBeInTheDocument(); + expect( screen.queryByText( colorCode ) ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'button', { + name: /^Custom color picker.$/, + } ) + ).toBeInTheDocument(); + } ); } ); diff --git a/packages/components/src/color-palette/test/utils.ts b/packages/components/src/color-palette/test/utils.ts index d862d35be5f52f..65e829a58a06bd 100644 --- a/packages/components/src/color-palette/test/utils.ts +++ b/packages/components/src/color-palette/test/utils.ts @@ -3,7 +3,6 @@ */ import { extractColorNameFromCurrentValue, - showTransparentBackground, normalizeColorValue, } from '../utils'; @@ -25,20 +24,6 @@ describe( 'ColorPalette: Utils', () => { expect( result ).toBe( 'Blue' ); } ); } ); - describe( 'showTransparentBackground', () => { - test( 'should return true for undefined color values', () => { - expect( showTransparentBackground( undefined ) ).toBe( true ); - } ); - test( 'should return true for transparent colors', () => { - expect( showTransparentBackground( 'transparent' ) ).toBe( true ); - expect( showTransparentBackground( '#75757500' ) ).toBe( true ); - } ); - test( 'should return false for non-transparent colors', () => { - expect( showTransparentBackground( '#FFF' ) ).toBe( false ); - expect( showTransparentBackground( '#757575' ) ).toBe( false ); - expect( showTransparentBackground( '#f5f5f524' ) ).toBe( false ); // 0.14 alpha. - } ); - } ); describe( 'normalizeColorValue', () => { test( 'should return the value as is if the color value is not a CSS variable', () => { diff --git a/packages/components/src/color-palette/utils.ts b/packages/components/src/color-palette/utils.ts index 1ef7f308d01230..79141bf752eb73 100644 --- a/packages/components/src/color-palette/utils.ts +++ b/packages/components/src/color-palette/utils.ts @@ -52,13 +52,6 @@ export const extractColorNameFromCurrentValue = ( return __( 'Custom' ); }; -export const showTransparentBackground = ( currentValue?: string ) => { - if ( typeof currentValue === 'undefined' ) { - return true; - } - return colord( currentValue ).alpha() === 0; -}; - // The PaletteObject type has a `colors` property (an array of ColorObject), // while the ColorObject type has a `color` property (the CSS color value). export const isMultiplePaletteObject = ( diff --git a/packages/components/src/color-picker/component.tsx b/packages/components/src/color-picker/component.tsx index 852e0756b3cf16..b4183dd071bfda 100644 --- a/packages/components/src/color-picker/component.tsx +++ b/packages/components/src/color-picker/component.tsx @@ -2,7 +2,8 @@ * External dependencies */ import type { ForwardedRef } from 'react'; -import { colord, extend, Colord } from 'colord'; +import type { Colord } from 'colord'; +import { colord, extend } from 'colord'; import namesPlugin from 'colord/plugins/names'; /** diff --git a/packages/components/src/color-picker/hex-input.tsx b/packages/components/src/color-picker/hex-input.tsx index 8107144aeea8b8..edbb5350ef1d59 100644 --- a/packages/components/src/color-picker/hex-input.tsx +++ b/packages/components/src/color-picker/hex-input.tsx @@ -49,7 +49,7 @@ export const HexInput = ( { color, onChange, enableAlpha }: HexInputProps ) => { <Spacer as={ Text } marginLeft={ space( 4 ) } - color={ COLORS.ui.theme } + color={ COLORS.theme.accent } lineHeight={ 1 } > # diff --git a/packages/components/src/color-picker/hsv-color-picker.native.js b/packages/components/src/color-picker/hsv-color-picker.native.js new file mode 100644 index 00000000000000..46499c94df5b3e --- /dev/null +++ b/packages/components/src/color-picker/hsv-color-picker.native.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { View, Dimensions } from 'react-native'; + +/** + * WordPress dependencies + */ +import { useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import HuePicker from './hue-picker'; +import SaturationValuePicker from './saturation-picker'; +import styles from './style.native.scss'; + +const HsvColorPicker = ( props ) => { + const maxWidth = Dimensions.get( 'window' ).width - 32; + const satValPickerRef = useRef( null ); + + const { + containerStyle = {}, + currentColor, + huePickerContainerStyle = {}, + huePickerBorderRadius = 0, + huePickerHue = 0, + huePickerBarWidth = maxWidth, + huePickerBarHeight = 12, + huePickerSliderSize = 24, + onHuePickerDragStart, + onHuePickerDragMove, + onHuePickerDragEnd, + onHuePickerDragTerminate, + onHuePickerPress, + satValPickerContainerStyle = {}, + satValPickerBorderRadius = 0, + satValPickerSize = { width: maxWidth, height: 200 }, + satValPickerSliderSize = 24, + satValPickerHue = 0, + satValPickerSaturation = 1, + satValPickerValue = 1, + onSatValPickerDragStart, + onSatValPickerDragMove, + onSatValPickerDragEnd, + onSatValPickerDragTerminate, + onSatValPickerPress, + } = props; + + return ( + <View + style={ [ styles[ 'hsv-container' ], containerStyle ] } + testID="hsv-color-picker" + > + <SaturationValuePicker + containerStyle={ satValPickerContainerStyle } + currentColor={ currentColor } + borderRadius={ satValPickerBorderRadius } + size={ satValPickerSize } + sliderSize={ satValPickerSliderSize } + hue={ satValPickerHue } + saturation={ satValPickerSaturation } + value={ satValPickerValue } + onDragStart={ onSatValPickerDragStart } + onDragMove={ onSatValPickerDragMove } + onDragEnd={ onSatValPickerDragEnd } + onDragTerminate={ onSatValPickerDragTerminate } + onPress={ onSatValPickerPress } + ref={ satValPickerRef } + /> + <HuePicker + containerStyle={ huePickerContainerStyle } + borderRadius={ huePickerBorderRadius } + hue={ huePickerHue } + barWidth={ huePickerBarWidth } + barHeight={ huePickerBarHeight } + sliderSize={ huePickerSliderSize } + onDragStart={ onHuePickerDragStart } + onDragMove={ onHuePickerDragMove } + onDragEnd={ onHuePickerDragEnd } + onDragTerminate={ onHuePickerDragTerminate } + onPress={ onHuePickerPress } + /> + </View> + ); +}; + +export default HsvColorPicker; diff --git a/packages/components/src/color-picker/hue-picker.native.js b/packages/components/src/color-picker/hue-picker.native.js new file mode 100644 index 00000000000000..d7d391e1837656 --- /dev/null +++ b/packages/components/src/color-picker/hue-picker.native.js @@ -0,0 +1,194 @@ +/** + * External dependencies + */ +import { Animated, View, PanResponder } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; + +/** + * WordPress dependencies + */ +import React, { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export default class HuePicker extends Component { + constructor( props ) { + super( props ); + this.hueColors = [ + '#ff0000', + '#ffff00', + '#00ff00', + '#00ffff', + '#0000ff', + '#ff00ff', + '#ff0000', + ]; + this.sliderX = new Animated.Value( + ( props.barHeight * props.hue ) / 360 + ); + this.panResponder = PanResponder.create( { + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderGrant: ( evt, gestureState ) => { + const { onPress } = this.props; + this.dragStartValue = this.computeHueValuePress( evt ); + + if ( onPress ) { + onPress( { + hue: this.computeHueValuePress( evt ), + nativeEvent: evt.nativeEvent, + } ); + } + + this.fireDragEvent( 'onDragStart', gestureState ); + }, + onPanResponderMove: ( evt, gestureState ) => { + this.fireDragEvent( 'onDragMove', gestureState ); + }, + onPanResponderTerminationRequest: () => true, + onPanResponderRelease: ( evt, gestureState ) => { + this.fireDragEvent( 'onDragEnd', gestureState ); + }, + onPanResponderTerminate: ( evt, gestureState ) => { + this.fireDragEvent( 'onDragTerminate', gestureState ); + }, + onShouldBlockNativeResponder: () => true, + } ); + } + + componentDidUpdate( prevProps ) { + const { hue = 0, barWidth = 200, sliderSize = 24 } = this.props; + const borderWidth = sliderSize / 10; + if ( prevProps.hue !== hue || prevProps.barWidth !== barWidth ) { + this.sliderX.setValue( + ( ( barWidth - sliderSize + borderWidth ) * hue ) / 360 + ); + } + } + + normalizeValue( value ) { + if ( value < 0 ) return 0; + if ( value > 1 ) return 1; + return value; + } + + getContainerStyle() { + const { + sliderSize = 24, + barHeight = 12, + containerStyle = {}, + } = this.props; + const paddingLeft = sliderSize / 2; + const paddingTop = + sliderSize - barHeight > 0 ? ( sliderSize - barHeight ) / 2 : 0; + return [ + styles[ 'hsv-container' ], + containerStyle, + { + paddingTop, + paddingBottom: paddingTop, + paddingLeft, + paddingRight: paddingLeft, + }, + ]; + } + + computeHueValueDrag( gestureState ) { + const { dx } = gestureState; + const { barWidth = 200 } = this.props; + const { dragStartValue } = this; + const diff = dx / barWidth; + const updatedHue = + this.normalizeValue( dragStartValue / 360 + diff ) * 360; + return updatedHue; + } + + computeHueValuePress( event ) { + const { nativeEvent } = event; + const { locationX } = nativeEvent; + const { barWidth = 200 } = this.props; + const updatedHue = this.normalizeValue( locationX / barWidth ) * 360; + return updatedHue; + } + + fireDragEvent( eventName, gestureState ) { + const { [ eventName ]: event } = this.props; + if ( event ) { + event( { + hue: this.computeHueValueDrag( gestureState ), + gestureState, + } ); + } + } + + firePressEvent( event ) { + const { onPress } = this.props; + if ( onPress ) { + onPress( { + hue: this.computeHueValuePress( event ), + nativeEvent: event.nativeEvent, + } ); + } + } + + render() { + const { hueColors } = this; + const { + sliderSize = 24, + barWidth = 200, + barHeight = 12, + borderRadius = 0, + } = this.props; + const borderWidth = sliderSize / 10; + return ( + <View + style={ this.getContainerStyle() } + { ...this.panResponder.panHandlers } + hitSlop={ { + top: 10, + bottom: 10, + left: 0, + right: 0, + } } + > + <LinearGradient + colors={ hueColors } + style={ { + borderRadius, + } } + start={ { x: 0, y: 0 } } + end={ { x: 1, y: 0 } } + > + <View + style={ { + width: barWidth, + height: barHeight, + } } + /> + </LinearGradient> + <Animated.View + pointerEvents="none" + style={ [ + styles[ 'hue-slider' ], + { + width: sliderSize, + height: sliderSize, + left: ( sliderSize - borderWidth ) / 2, + borderRadius: sliderSize / 2, + transform: [ + { + translateX: this.sliderX, + }, + ], + }, + ] } + /> + </View> + ); + } +} diff --git a/packages/components/src/color-picker/index.native.js b/packages/components/src/color-picker/index.native.js index 1dfd2353cad1cc..a2fee512ce26ff 100644 --- a/packages/components/src/color-picker/index.native.js +++ b/packages/components/src/color-picker/index.native.js @@ -2,7 +2,6 @@ * External dependencies */ import { View, Text, TouchableWithoutFeedback, Platform } from 'react-native'; -import HsvColorPicker from 'react-native-hsv-color-picker'; import { colord, extend } from 'colord'; import namesPlugin from 'colord/plugins/names'; /** @@ -17,6 +16,7 @@ import { Icon, check, close } from '@wordpress/icons'; * Internal dependencies */ import styles from './style.scss'; +import HsvColorPicker from './hsv-color-picker.native.js'; extend( [ namesPlugin ] ); @@ -122,6 +122,7 @@ function ColorPicker( { <> <HsvColorPicker huePickerHue={ hue } + currentColor={ currentColor } onHuePickerDragMove={ updateColor } onHuePickerPress={ ! isBottomSheetContentScrolling && updateColor diff --git a/packages/components/src/color-picker/input-with-slider.tsx b/packages/components/src/color-picker/input-with-slider.tsx index 5f249b03475f6f..cb346bff8fd86e 100644 --- a/packages/components/src/color-picker/input-with-slider.tsx +++ b/packages/components/src/color-picker/input-with-slider.tsx @@ -42,7 +42,7 @@ export const InputWithSlider = ( { <Spacer as={ Text } paddingLeft={ space( 4 ) } - color={ COLORS.ui.theme } + color={ COLORS.theme.accent } lineHeight={ 1 } > { abbreviation } diff --git a/packages/components/src/color-picker/saturation-picker.native.js b/packages/components/src/color-picker/saturation-picker.native.js new file mode 100644 index 00000000000000..5c7e2e6d562a1b --- /dev/null +++ b/packages/components/src/color-picker/saturation-picker.native.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import { View, PanResponder } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import { colord } from 'colord'; + +/** + * WordPress dependencies + */ +import React, { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import styles from './style.native.scss'; + +export default class SaturationValuePicker extends Component { + constructor( props ) { + super( props ); + + this.panResponder = PanResponder.create( { + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderGrant: ( evt, gestureState ) => { + const { onPress } = this.props; + const { saturation, value } = this.computeSatValPress( evt ); + this.dragStartValue = { + saturation, + value, + }; + + if ( onPress ) { + onPress( { + ...this.computeSatValPress( evt ), + nativeEvent: evt.nativeEvent, + } ); + } + + this.fireDragEvent( 'onDragStart', gestureState ); + }, + onPanResponderMove: ( evt, gestureState ) => { + this.fireDragEvent( 'onDragMove', gestureState ); + }, + onPanResponderTerminationRequest: () => true, + onPanResponderRelease: ( evt, gestureState ) => { + this.fireDragEvent( 'onDragEnd', gestureState ); + }, + onPanResponderTerminate: ( evt, gestureState ) => { + this.fireDragEvent( 'onDragTerminate', gestureState ); + }, + onShouldBlockNativeResponder: () => true, + } ); + } + + normalizeValue( value ) { + if ( value < 0 ) return 0; + if ( value > 1 ) return 1; + return value; + } + + computeSatValDrag( gestureState ) { + const { dx, dy } = gestureState; + const { size } = this.props; + const { saturation, value } = this.dragStartValue; + const diffx = dx / size.width; + const diffy = dy / size.height; + return { + saturation: this.normalizeValue( saturation + diffx ), + value: this.normalizeValue( value - diffy ), + }; + } + + computeSatValPress( event ) { + const { nativeEvent } = event; + const { locationX, locationY } = nativeEvent; + const { size } = this.props; + return { + saturation: this.normalizeValue( locationX / size.width ), + value: 1 - this.normalizeValue( locationY / size.height ), + }; + } + + fireDragEvent( eventName, gestureState ) { + const { [ eventName ]: event } = this.props; + if ( event ) { + event( { + ...this.computeSatValDrag( gestureState ), + gestureState, + } ); + } + } + + render() { + const { + size, + sliderSize = 24, + hue = 0, + value = 1, + saturation = 1, + containerStyle = {}, + borderRadius = 0, + currentColor, + } = this.props; + + return ( + <View + style={ [ + styles[ 'hsv-container' ], + containerStyle, + { + height: size.height + sliderSize, + width: size.width + sliderSize, + }, + ] } + { ...this.panResponder.panHandlers } + > + <LinearGradient + style={ [ + styles[ 'gradient-container' ], + { + borderRadius, + }, + ] } + colors={ [ + '#fff', + colord( { h: hue, s: 100, l: 50 } ).toHex(), + ] } + start={ { x: 0, y: 0.5 } } + end={ { x: 1, y: 0.5 } } + > + <LinearGradient colors={ [ 'rgba(0, 0, 0, 0)', '#000' ] }> + <View + style={ { + height: size.height, + width: size.width, + } } + /> + </LinearGradient> + </LinearGradient> + <View + pointerEvents="none" + style={ [ + styles[ 'saturation-slider' ], + { + width: sliderSize, + height: sliderSize, + borderRadius: sliderSize / 2, + borderWidth: sliderSize / 10, + backgroundColor: currentColor, + transform: [ + { translateX: size.width * saturation }, + { translateY: size.height * ( 1 - value ) }, + ], + }, + ] } + /> + </View> + ); + } +} diff --git a/packages/components/src/color-picker/stories/index.story.tsx b/packages/components/src/color-picker/stories/index.story.tsx new file mode 100644 index 00000000000000..81500c15588222 --- /dev/null +++ b/packages/components/src/color-picker/stories/index.story.tsx @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ColorPicker } from '../component'; + +const meta: Meta< typeof ColorPicker > = { + component: ColorPicker, + title: 'Components/ColorPicker', + argTypes: { + as: { control: { type: null } }, + color: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ColorPicker > = ( { onChange, ...props } ) => { + const [ color, setColor ] = useState< string | undefined >(); + + return ( + <ColorPicker + { ...props } + color={ color } + onChange={ ( ...changeArgs ) => { + onChange?.( ...changeArgs ); + setColor( ...changeArgs ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); diff --git a/packages/components/src/color-picker/stories/index.tsx b/packages/components/src/color-picker/stories/index.tsx deleted file mode 100644 index 419c7195714b69..00000000000000 --- a/packages/components/src/color-picker/stories/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { ColorPicker } from '../component'; - -const meta: ComponentMeta< typeof ColorPicker > = { - component: ColorPicker, - title: 'Components/ColorPicker', - argTypes: { - as: { control: { type: null } }, - color: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ColorPicker > = ( { - onChange, - ...props -} ) => { - const [ color, setColor ] = useState< string | undefined >(); - - return ( - <ColorPicker - { ...props } - color={ color } - onChange={ ( ...changeArgs ) => { - onChange?.( ...changeArgs ); - setColor( ...changeArgs ); - } } - /> - ); -}; - -export const Default = Template.bind( {} ); diff --git a/packages/components/src/color-picker/style.native.scss b/packages/components/src/color-picker/style.native.scss index 9932830e4e313d..248a464ae4ad82 100644 --- a/packages/components/src/color-picker/style.native.scss +++ b/packages/components/src/color-picker/style.native.scss @@ -62,3 +62,26 @@ .pickerPointer { height: 16px; } + +.hsv-container { + justify-content: center; + align-items: center; +} + +.gradient-gontainer { + overflow: hidden; +} + +.saturation-slider { + top: 0; + left: 0; + position: absolute; + border-color: $white; +} + +.hue-slider { + position: absolute; + background-color: #fff; + box-shadow: 0 7px 10px rgba(0, 0, 0, 0.5); + z-index: 5; +} diff --git a/packages/components/src/combobox-control/stories/index.story.tsx b/packages/components/src/combobox-control/stories/index.story.tsx new file mode 100644 index 00000000000000..9c0e5455ebc06c --- /dev/null +++ b/packages/components/src/combobox-control/stories/index.story.tsx @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ComboboxControl from '..'; +import type { ComboboxControlProps } from '../types'; + +const countries = [ + { name: 'Afghanistan', code: 'AF' }, + { name: 'Åland Islands', code: 'AX' }, + { name: 'Albania', code: 'AL' }, + { name: 'Algeria', code: 'DZ' }, + { name: 'American Samoa', code: 'AS' }, +]; + +const meta: Meta< typeof ComboboxControl > = { + title: 'Components/ComboboxControl', + component: ComboboxControl, + argTypes: { + value: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const mapCountryOption = ( country: ( typeof countries )[ number ] ) => ( { + value: country.code, + label: country.name, +} ); + +const countryOptions = countries.map( mapCountryOption ); + +const Template: StoryFn< typeof ComboboxControl > = ( { + onChange, + ...args +} ) => { + const [ value, setValue ] = + useState< ComboboxControlProps[ 'value' ] >( null ); + + return ( + <> + <ComboboxControl + { ...args } + value={ value } + onChange={ ( ...changeArgs ) => { + setValue( ...changeArgs ); + onChange?.( ...changeArgs ); + } } + /> + </> + ); +}; +export const Default = Template.bind( {} ); +Default.args = { + allowReset: false, + label: 'Select a country', + options: countryOptions, +}; + +/** + * The rendered output of each suggestion can be customized by passing a + * render function to the `__experimentalRenderItem` prop. (This is still an experimental feature + * and is subject to change.) + */ +export const WithCustomRenderItem = Template.bind( {} ); +WithCustomRenderItem.args = { + ...Default.args, + label: 'Select an author', + options: [ + { + value: 'parsley', + label: 'Parsley Montana', + age: 48, + country: 'Germany', + }, + { + value: 'cabbage', + label: 'Cabbage New York', + age: 44, + country: 'France', + }, + { + value: 'jake', + label: 'Jake Weary', + age: 41, + country: 'United Kingdom', + }, + ], + __experimentalRenderItem: ( { item } ) => { + const { label, age, country } = item; + return ( + <div> + <div style={ { marginBottom: '0.2rem' } }>{ label }</div> + <small> + Age: { age }, Country: { country } + </small> + </div> + ); + }, +}; diff --git a/packages/components/src/combobox-control/stories/index.tsx b/packages/components/src/combobox-control/stories/index.tsx deleted file mode 100644 index fe6fe2a9ef9ef1..00000000000000 --- a/packages/components/src/combobox-control/stories/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import ComboboxControl from '..'; -import type { ComboboxControlProps } from '../types'; - -const countries = [ - { name: 'Afghanistan', code: 'AF' }, - { name: 'Åland Islands', code: 'AX' }, - { name: 'Albania', code: 'AL' }, - { name: 'Algeria', code: 'DZ' }, - { name: 'American Samoa', code: 'AS' }, -]; - -const meta: ComponentMeta< typeof ComboboxControl > = { - title: 'Components/ComboboxControl', - component: ComboboxControl, - argTypes: { - value: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const mapCountryOption = ( country: ( typeof countries )[ number ] ) => ( { - value: country.code, - label: country.name, -} ); - -const countryOptions = countries.map( mapCountryOption ); - -const Template: ComponentStory< typeof ComboboxControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = - useState< ComboboxControlProps[ 'value' ] >( null ); - - return ( - <> - <ComboboxControl - { ...args } - value={ value } - onChange={ ( ...changeArgs ) => { - setValue( ...changeArgs ); - onChange?.( ...changeArgs ); - } } - /> - </> - ); -}; -export const Default = Template.bind( {} ); -Default.args = { - allowReset: false, - label: 'Select a country', - options: countryOptions, -}; - -/** - * The rendered output of each suggestion can be customized by passing a - * render function to the `__experimentalRenderItem` prop. (This is still an experimental feature - * and is subject to change.) - */ -export const WithCustomRenderItem = Template.bind( {} ); -WithCustomRenderItem.args = { - ...Default.args, - label: 'Select an author', - options: [ - { - value: 'parsley', - label: 'Parsley Montana', - age: 48, - country: 'Germany', - }, - { - value: 'cabbage', - label: 'Cabbage New York', - age: 44, - country: 'France', - }, - { - value: 'jake', - label: 'Jake Weary', - age: 41, - country: 'United Kingdom', - }, - ], - __experimentalRenderItem: ( { item } ) => { - const { label, age, country } = item; - return ( - <div> - <div style={ { marginBottom: '0.2rem' } }>{ label }</div> - <small> - Age: { age }, Country: { country } - </small> - </div> - ); - }, -}; diff --git a/packages/components/src/confirm-dialog/component.tsx b/packages/components/src/confirm-dialog/component.tsx index 92fb3fa08fbb17..1de1c08ffbcf40 100644 --- a/packages/components/src/confirm-dialog/component.tsx +++ b/packages/components/src/confirm-dialog/component.tsx @@ -7,18 +7,15 @@ import type { ForwardedRef, KeyboardEvent } from 'react'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; /** * Internal dependencies */ import Modal from '../modal'; import type { OwnProps, DialogInputEvent } from './types'; -import { - useContextSystem, - contextConnect, - WordPressComponentProps, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem, contextConnect } from '../ui/context'; import { Flex } from '../flex'; import Button from '../button'; import { Text } from '../text'; @@ -42,6 +39,8 @@ function ConfirmDialog( const cx = useCx(); const wrapperClassName = cx( styles.wrapper ); + const cancelButtonRef = useRef(); + const confirmButtonRef = useRef(); const [ isOpen, setIsOpen ] = useState< boolean >(); const [ shouldSelfClose, setShouldSelfClose ] = useState< boolean >(); @@ -69,7 +68,13 @@ function ConfirmDialog( const handleEnter = useCallback( ( event: KeyboardEvent< HTMLDivElement > ) => { - if ( event.key === 'Enter' ) { + // Avoid triggering the 'confirm' action when a button is focused, + // as this can cause a double submission. + const isConfirmOrCancelButton = + event.target === cancelButtonRef.current || + event.target === confirmButtonRef.current; + + if ( ! isConfirmOrCancelButton && event.key === 'Enter' ) { handleEvent( onConfirm )( event ); } }, @@ -96,12 +101,14 @@ function ConfirmDialog( <Text>{ children }</Text> <Flex direction="row" justify="flex-end"> <Button + ref={ cancelButtonRef } variant="tertiary" onClick={ handleEvent( onCancel ) } > { cancelLabel } </Button> <Button + ref={ confirmButtonRef } variant="primary" onClick={ handleEvent( onConfirm ) } > diff --git a/packages/components/src/confirm-dialog/stories/index.js b/packages/components/src/confirm-dialog/stories/index.js deleted file mode 100644 index 92274c70aaf794..00000000000000 --- a/packages/components/src/confirm-dialog/stories/index.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import { Heading } from '../../heading'; -import { ConfirmDialog } from '..'; - -const meta = { - component: ConfirmDialog, - title: 'Components (Experimental)/ConfirmDialog', - argTypes: { - children: { - control: { type: 'text' }, - }, - confirmButtonText: { - control: { type: 'text' }, - }, - cancelButtonText: { - control: { type: 'text' }, - }, - isOpen: { - control: { type: null }, - }, - onConfirm: { - control: { type: null }, - }, - onCancel: { - control: { type: null }, - }, - }, - args: { - children: 'Would you like to privately publish the post now?', - }, - parameters: { - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -const Template = ( args ) => { - const [ isOpen, setIsOpen ] = useState( false ); - const [ confirmVal, setConfirmVal ] = useState( '' ); - - const handleConfirm = () => { - setConfirmVal( 'Confirmed!' ); - setIsOpen( false ); - }; - - const handleCancel = () => { - setConfirmVal( 'Cancelled' ); - setIsOpen( false ); - }; - return ( - <> - <Button variant="primary" onClick={ () => setIsOpen( true ) }> - Open ConfirmDialog - </Button> - - <ConfirmDialog - { ...args } - isOpen={ isOpen } - onConfirm={ handleConfirm } - onCancel={ handleCancel } - > - { args.children } - </ConfirmDialog> - - <Heading level={ 1 }>{ confirmVal }</Heading> - </> - ); -}; - -// Simplest usage: just declare the component with the required `onConfirm` prop. Note: the `onCancel` prop is optional here, unless you'd like to render the component in Controlled mode (see below) -export const _default = Template.bind( {} ); -const _defaultSnippet = `() => { - const [ isOpen, setIsOpen ] = useState( false ); - const [ confirmVal, setConfirmVal ] = useState(''); - - const handleConfirm = () => { - setConfirmVal( 'Confirmed!' ); - setIsOpen( false ); - }; - - const handleCancel = () => { - setConfirmVal( 'Cancelled' ); - setIsOpen( false ); - }; - - return ( - <> - <ConfirmDialog - isOpen={ isOpen } - onConfirm={ handleConfirm } - onCancel={ handleCancel } - > - Would you like to privately publish the post now? - </ConfirmDialog> - - <Heading level={ 1 }>{ confirmVal }</Heading> - - <Button variant="primary" onClick={ () => setIsOpen( true ) }> - Open ConfirmDialog - </Button> - </> - ); - };`; -_default.args = {}; -_default.parameters = { - docs: { - source: { - code: _defaultSnippet, - language: 'jsx', - type: 'auto', - format: 'true', - }, - }, -}; - -// To customize button text, pass the `cancelButtonText` and/or `confirmButtonText` props. -export const withCustomButtonLabels = Template.bind( {} ); -withCustomButtonLabels.args = { - cancelButtonText: 'No thanks', - confirmButtonText: 'Yes please!', -}; diff --git a/packages/components/src/confirm-dialog/stories/index.story.js b/packages/components/src/confirm-dialog/stories/index.story.js new file mode 100644 index 00000000000000..f308206ad1df88 --- /dev/null +++ b/packages/components/src/confirm-dialog/stories/index.story.js @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import { ConfirmDialog } from '..'; + +const meta = { + component: ConfirmDialog, + title: 'Components (Experimental)/ConfirmDialog', + argTypes: { + children: { + control: { type: 'text' }, + }, + confirmButtonText: { + control: { type: 'text' }, + }, + cancelButtonText: { + control: { type: 'text' }, + }, + isOpen: { + control: { type: null }, + }, + onConfirm: { action: 'onConfirm' }, + onCancel: { action: 'onCancel' }, + }, + args: { + children: 'Would you like to privately publish the post now?', + }, + parameters: { + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +const Template = ( { onConfirm, onCancel, ...args } ) => { + const [ isOpen, setIsOpen ] = useState( false ); + + const handleConfirm = ( ...confirmArgs ) => { + onConfirm( ...confirmArgs ); + setIsOpen( false ); + }; + + const handleCancel = ( ...cancelArgs ) => { + onCancel( ...cancelArgs ); + setIsOpen( false ); + }; + + return ( + <> + <Button variant="primary" onClick={ () => setIsOpen( true ) }> + Open ConfirmDialog + </Button> + + <ConfirmDialog + { ...args } + isOpen={ isOpen } + onConfirm={ handleConfirm } + onCancel={ handleCancel } + > + { args.children } + </ConfirmDialog> + </> + ); +}; + +// Simplest usage: just declare the component with the required `onConfirm` prop. Note: the `onCancel` prop is optional here, unless you'd like to render the component in Controlled mode (see below) +export const _default = Template.bind( {} ); +const _defaultSnippet = `() => { + const [ isOpen, setIsOpen ] = useState( false ); + const [ confirmVal, setConfirmVal ] = useState(''); + + const handleConfirm = () => { + setConfirmVal( 'Confirmed!' ); + setIsOpen( false ); + }; + + const handleCancel = () => { + setConfirmVal( 'Cancelled' ); + setIsOpen( false ); + }; + + return ( + <> + <ConfirmDialog + isOpen={ isOpen } + onConfirm={ handleConfirm } + onCancel={ handleCancel } + > + Would you like to privately publish the post now? + </ConfirmDialog> + + <Heading level={ 1 }>{ confirmVal }</Heading> + + <Button variant="primary" onClick={ () => setIsOpen( true ) }> + Open ConfirmDialog + </Button> + </> + ); + };`; +_default.args = {}; +_default.parameters = { + docs: { + source: { + code: _defaultSnippet, + language: 'jsx', + type: 'auto', + format: 'true', + }, + }, +}; + +// To customize button text, pass the `cancelButtonText` and/or `confirmButtonText` props. +export const WithCustomButtonLabels = Template.bind( {} ); +WithCustomButtonLabels.args = { + cancelButtonText: 'No thanks', + confirmButtonText: 'Yes please!', +}; diff --git a/packages/components/src/confirm-dialog/test/index.js b/packages/components/src/confirm-dialog/test/index.js index c9ea800cba3cb1..4aecd43f570861 100644 --- a/packages/components/src/confirm-dialog/test/index.js +++ b/packages/components/src/confirm-dialog/test/index.js @@ -194,6 +194,48 @@ describe( 'Confirm', () => { expect( confirmDialog ).not.toBeInTheDocument(); expect( onConfirm ).toHaveBeenCalled(); } ); + + it( 'calls only the `onCancel` callback and not the `onConfirm` callback when the cancel button is submitted using the keyboard', async () => { + const user = userEvent.setup(); + + const onConfirm = jest.fn().mockName( 'onConfirm()' ); + const onCancel = jest.fn().mockName( 'onCancel()' ); + + render( + <ConfirmDialog + onConfirm={ onConfirm } + onCancel={ onCancel } + > + Are you sure? + </ConfirmDialog> + ); + + await user.keyboard( '[Tab][Enter]' ); + + expect( onConfirm ).not.toHaveBeenCalled(); + expect( onCancel ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'calls only the `onConfirm` callback when the confirm button is submitted using the keyboard', async () => { + const user = userEvent.setup(); + + const onConfirm = jest.fn().mockName( 'onConfirm()' ); + const onCancel = jest.fn().mockName( 'onCancel()' ); + + render( + <ConfirmDialog + onConfirm={ onConfirm } + onCancel={ onCancel } + > + Are you sure? + </ConfirmDialog> + ); + + await user.keyboard( '[Tab][Tab][Enter]' ); + + expect( onConfirm ).toHaveBeenCalledTimes( 1 ); + expect( onCancel ).not.toHaveBeenCalled(); + } ); } ); } ); diff --git a/packages/components/src/custom-gradient-picker/stories/index.story.tsx b/packages/components/src/custom-gradient-picker/stories/index.story.tsx new file mode 100644 index 00000000000000..6d19157238d28c --- /dev/null +++ b/packages/components/src/custom-gradient-picker/stories/index.story.tsx @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import CustomGradientPicker from '../'; + +const meta: Meta< typeof CustomGradientPicker > = { + title: 'Components/CustomGradientPicker', + component: CustomGradientPicker, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const CustomGradientPickerWithState: StoryFn< + typeof CustomGradientPicker +> = ( { onChange, ...props } ) => { + const [ gradient, setGradient ] = useState< string >(); + return ( + <CustomGradientPicker + { ...props } + value={ gradient } + onChange={ ( newGradient ) => { + setGradient( newGradient ); + onChange( newGradient ); + } } + /> + ); +}; + +export const Default = CustomGradientPickerWithState.bind( {} ); +Default.args = { + __nextHasNoMargin: true, +}; diff --git a/packages/components/src/custom-gradient-picker/stories/index.tsx b/packages/components/src/custom-gradient-picker/stories/index.tsx deleted file mode 100644 index 3648db3abe571e..00000000000000 --- a/packages/components/src/custom-gradient-picker/stories/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import CustomGradientPicker from '../'; - -const meta: ComponentMeta< typeof CustomGradientPicker > = { - title: 'Components/CustomGradientPicker', - component: CustomGradientPicker, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const CustomGradientPickerWithState: ComponentStory< - typeof CustomGradientPicker -> = ( { onChange, ...props } ) => { - const [ gradient, setGradient ] = useState< string >(); - return ( - <CustomGradientPicker - { ...props } - value={ gradient } - onChange={ ( newGradient ) => { - setGradient( newGradient ); - onChange( newGradient ); - } } - /> - ); -}; - -export const Default = CustomGradientPickerWithState.bind( {} ); -Default.args = { - __nextHasNoMargin: true, -}; diff --git a/packages/components/src/custom-select-control/stories/index.js b/packages/components/src/custom-select-control/stories/index.story.js similarity index 100% rename from packages/components/src/custom-select-control/stories/index.js rename to packages/components/src/custom-select-control/stories/index.story.js diff --git a/packages/components/src/date-time/date/styles.ts b/packages/components/src/date-time/date/styles.ts index a0f1853098f16e..0e7a3881d821c6 100644 --- a/packages/components/src/date-time/date/styles.ts +++ b/packages/components/src/date-time/date/styles.ts @@ -90,7 +90,7 @@ export const DayButton = styled( Button, { ${ ( props ) => props.isSelected && ` - background: ${ COLORS.ui.theme }; + background: ${ COLORS.theme.accent }; color: ${ COLORS.white }; ` } @@ -106,7 +106,7 @@ export const DayButton = styled( Button, { props.hasEvents && ` ::before { - background: ${ props.isSelected ? COLORS.white : COLORS.ui.theme }; + background: ${ props.isSelected ? COLORS.white : COLORS.theme.accent }; border-radius: 2px; bottom: 0; content: " "; diff --git a/packages/components/src/date-time/date/test/index.tsx b/packages/components/src/date-time/date/test/index.tsx index fa502cbae29644..a0c21dce2be76f 100644 --- a/packages/components/src/date-time/date/test/index.tsx +++ b/packages/components/src/date-time/date/test/index.tsx @@ -112,6 +112,6 @@ describe( 'DatePicker', () => { const button = screen.getByRole( 'button', { name: 'May 20, 2022', } ) as HTMLButtonElement; - expect( button.disabled ).toBe( true ); + expect( button ).toBeDisabled(); } ); } ); diff --git a/packages/components/src/date-time/stories/date-time.story.tsx b/packages/components/src/date-time/stories/date-time.story.tsx new file mode 100644 index 00000000000000..86a627bbec35e0 --- /dev/null +++ b/packages/components/src/date-time/stories/date-time.story.tsx @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DateTimePicker from '../date-time'; +import { daysFromNow, isWeekend } from './utils'; + +const meta: Meta< typeof DateTimePicker > = { + title: 'Components/DateTimePicker', + component: DateTimePicker, + argTypes: { + currentDate: { control: 'date' }, + onChange: { action: 'onChange', control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof DateTimePicker > = ( { + currentDate, + onChange, + ...args +} ) => { + const [ date, setDate ] = useState( currentDate ); + useEffect( () => { + setDate( currentDate ); + }, [ currentDate ] ); + return ( + <DateTimePicker + { ...args } + currentDate={ date } + onChange={ ( newDate ) => { + setDate( newDate ); + onChange?.( newDate ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof DateTimePicker > = Template.bind( {} ); + +export const WithEvents: StoryFn< typeof DateTimePicker > = Template.bind( {} ); +WithEvents.args = { + currentDate: new Date(), + events: [ + { date: daysFromNow( 2 ) }, + { date: daysFromNow( 4 ) }, + { date: daysFromNow( 6 ) }, + { date: daysFromNow( 8 ) }, + ], +}; + +export const WithInvalidDates: StoryFn< typeof DateTimePicker > = Template.bind( + {} +); +WithInvalidDates.args = { + currentDate: new Date(), + isInvalidDate: isWeekend, +}; diff --git a/packages/components/src/date-time/stories/date-time.tsx b/packages/components/src/date-time/stories/date-time.tsx deleted file mode 100644 index 447b9cf265ab86..00000000000000 --- a/packages/components/src/date-time/stories/date-time.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import DateTimePicker from '../date-time'; -import { daysFromNow, isWeekend } from './utils'; - -const meta: ComponentMeta< typeof DateTimePicker > = { - title: 'Components/DateTimePicker', - component: DateTimePicker, - argTypes: { - currentDate: { control: 'date' }, - onChange: { action: 'onChange', control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof DateTimePicker > = ( { - currentDate, - onChange, - ...args -} ) => { - const [ date, setDate ] = useState( currentDate ); - useEffect( () => { - setDate( currentDate ); - }, [ currentDate ] ); - return ( - <DateTimePicker - { ...args } - currentDate={ date } - onChange={ ( newDate ) => { - setDate( newDate ); - onChange?.( newDate ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof DateTimePicker > = Template.bind( - {} -); - -export const WithEvents: ComponentStory< typeof DateTimePicker > = - Template.bind( {} ); -WithEvents.args = { - currentDate: new Date(), - events: [ - { date: daysFromNow( 2 ) }, - { date: daysFromNow( 4 ) }, - { date: daysFromNow( 6 ) }, - { date: daysFromNow( 8 ) }, - ], -}; - -export const WithInvalidDates: ComponentStory< typeof DateTimePicker > = - Template.bind( {} ); -WithInvalidDates.args = { - currentDate: new Date(), - isInvalidDate: isWeekend, -}; diff --git a/packages/components/src/date-time/stories/date.story.tsx b/packages/components/src/date-time/stories/date.story.tsx new file mode 100644 index 00000000000000..8d1513d014c8c8 --- /dev/null +++ b/packages/components/src/date-time/stories/date.story.tsx @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DatePicker from '../date'; +import { daysFromNow, isWeekend } from './utils'; + +const meta: Meta< typeof DatePicker > = { + title: 'Components/DatePicker', + component: DatePicker, + argTypes: { + currentDate: { control: 'date' }, + onChange: { action: 'onChange', control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof DatePicker > = ( { + currentDate, + onChange, + ...args +} ) => { + const [ date, setDate ] = useState( currentDate ); + useEffect( () => { + setDate( currentDate ); + }, [ currentDate ] ); + return ( + <DatePicker + { ...args } + currentDate={ date } + onChange={ ( newDate ) => { + setDate( newDate ); + onChange?.( newDate ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof DatePicker > = Template.bind( {} ); + +export const WithEvents: StoryFn< typeof DatePicker > = Template.bind( {} ); +WithEvents.args = { + currentDate: new Date(), + events: [ + { date: daysFromNow( 2 ) }, + { date: daysFromNow( 4 ) }, + { date: daysFromNow( 6 ) }, + { date: daysFromNow( 8 ) }, + ], +}; + +export const WithInvalidDates: StoryFn< typeof DatePicker > = Template.bind( + {} +); +WithInvalidDates.args = { + currentDate: new Date(), + isInvalidDate: isWeekend, +}; diff --git a/packages/components/src/date-time/stories/date.tsx b/packages/components/src/date-time/stories/date.tsx deleted file mode 100644 index 58d3295b425a1f..00000000000000 --- a/packages/components/src/date-time/stories/date.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import DatePicker from '../date'; -import { daysFromNow, isWeekend } from './utils'; - -const meta: ComponentMeta< typeof DatePicker > = { - title: 'Components/DatePicker', - component: DatePicker, - argTypes: { - currentDate: { control: 'date' }, - onChange: { action: 'onChange', control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof DatePicker > = ( { - currentDate, - onChange, - ...args -} ) => { - const [ date, setDate ] = useState( currentDate ); - useEffect( () => { - setDate( currentDate ); - }, [ currentDate ] ); - return ( - <DatePicker - { ...args } - currentDate={ date } - onChange={ ( newDate ) => { - setDate( newDate ); - onChange?.( newDate ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof DatePicker > = Template.bind( {} ); - -export const WithEvents: ComponentStory< typeof DatePicker > = Template.bind( - {} -); -WithEvents.args = { - currentDate: new Date(), - events: [ - { date: daysFromNow( 2 ) }, - { date: daysFromNow( 4 ) }, - { date: daysFromNow( 6 ) }, - { date: daysFromNow( 8 ) }, - ], -}; - -export const WithInvalidDates: ComponentStory< typeof DatePicker > = - Template.bind( {} ); -WithInvalidDates.args = { - currentDate: new Date(), - isInvalidDate: isWeekend, -}; diff --git a/packages/components/src/date-time/stories/time.story.tsx b/packages/components/src/date-time/stories/time.story.tsx new file mode 100644 index 00000000000000..c48b8fb1d15922 --- /dev/null +++ b/packages/components/src/date-time/stories/time.story.tsx @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TimePicker from '../time'; + +const meta: Meta< typeof TimePicker > = { + title: 'Components/TimePicker', + component: TimePicker, + argTypes: { + currentTime: { control: 'date' }, + onChange: { action: 'onChange', control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof TimePicker > = ( { + currentTime, + onChange, + ...args +} ) => { + const [ time, setTime ] = useState( currentTime ); + useEffect( () => { + setTime( currentTime ); + }, [ currentTime ] ); + return ( + <TimePicker + { ...args } + currentTime={ time } + onChange={ ( newTime ) => { + setTime( newTime ); + onChange?.( newTime ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof TimePicker > = Template.bind( {} ); diff --git a/packages/components/src/date-time/stories/time.tsx b/packages/components/src/date-time/stories/time.tsx deleted file mode 100644 index 9fc72086075f7c..00000000000000 --- a/packages/components/src/date-time/stories/time.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import TimePicker from '../time'; - -const meta: ComponentMeta< typeof TimePicker > = { - title: 'Components/TimePicker', - component: TimePicker, - argTypes: { - currentTime: { control: 'date' }, - onChange: { action: 'onChange', control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof TimePicker > = ( { - currentTime, - onChange, - ...args -} ) => { - const [ time, setTime ] = useState( currentTime ); - useEffect( () => { - setTime( currentTime ); - }, [ currentTime ] ); - return ( - <TimePicker - { ...args } - currentTime={ time } - onChange={ ( newTime ) => { - setTime( newTime ); - onChange?.( newTime ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof TimePicker > = Template.bind( {} ); diff --git a/packages/components/src/date-time/time/index.tsx b/packages/components/src/date-time/time/index.tsx index 3af6eec9cc12c8..e5772bf7ab34c0 100644 --- a/packages/components/src/date-time/time/index.tsx +++ b/packages/components/src/date-time/time/index.tsx @@ -33,9 +33,9 @@ import { HStack } from '../../h-stack'; import { Spacer } from '../../spacer'; import type { InputChangeCallback } from '../../input-control/types'; import type { InputState } from '../../input-control/reducer/state'; +import type { InputAction } from '../../input-control/reducer/actions'; import { COMMIT, - InputAction, PRESS_DOWN, PRESS_UP, } from '../../input-control/reducer/actions'; diff --git a/packages/components/src/dimension-control/stories/index.story.tsx b/packages/components/src/dimension-control/stories/index.story.tsx new file mode 100644 index 00000000000000..0698125c446ca7 --- /dev/null +++ b/packages/components/src/dimension-control/stories/index.story.tsx @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +/** + * Internal dependencies + */ +import { DimensionControl } from '..'; +import sizes from '../sizes'; + +/** + * WordPress dependencies + */ +import { desktop, tablet, mobile } from '@wordpress/icons'; + +export default { + component: DimensionControl, + title: 'Components (Experimental)/DimensionControl', + argTypes: { + onChange: { action: 'onChange' }, + value: { control: { type: null } }, + icon: { + control: { type: 'select' }, + options: [ '-', 'desktop', 'tablet', 'mobile' ], + mapping: { + '-': undefined, + desktop, + tablet, + mobile, + }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, + }, +} as Meta< typeof DimensionControl >; + +const Template: StoryFn< typeof DimensionControl > = ( args ) => ( + <DimensionControl { ...args } /> +); + +export const Default = Template.bind( {} ); + +Default.args = { + label: 'Please select a size', + sizes, +}; diff --git a/packages/components/src/dimension-control/stories/index.tsx b/packages/components/src/dimension-control/stories/index.tsx deleted file mode 100644 index e34200754c9c4a..00000000000000 --- a/packages/components/src/dimension-control/stories/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -/** - * Internal dependencies - */ -import { DimensionControl } from '..'; -import sizes from '../sizes'; - -/** - * WordPress dependencies - */ -import { desktop, tablet, mobile } from '@wordpress/icons'; - -export default { - component: DimensionControl, - title: 'Components (Experimental)/DimensionControl', - argTypes: { - onChange: { action: 'onChange' }, - value: { control: { type: null } }, - icon: { - control: { type: 'select' }, - options: [ '-', 'desktop', 'tablet', 'mobile' ], - mapping: { - '-': undefined, - desktop, - tablet, - mobile, - }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, - }, -} as ComponentMeta< typeof DimensionControl >; - -const Template: ComponentStory< typeof DimensionControl > = ( args ) => ( - <DimensionControl { ...args } /> -); - -export const Default = Template.bind( {} ); - -Default.args = { - label: 'Please select a size', - sizes, -}; diff --git a/packages/components/src/disabled/stories/index.story.tsx b/packages/components/src/disabled/stories/index.story.tsx new file mode 100644 index 00000000000000..b5da6ccedddc07 --- /dev/null +++ b/packages/components/src/disabled/stories/index.story.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Disabled from '../'; +import SelectControl from '../../select-control/'; +import TextControl from '../../text-control/'; +import TextareaControl from '../../textarea-control/'; +import { VStack } from '../../v-stack/'; + +const meta: Meta< typeof Disabled > = { + title: 'Components/Disabled', + component: Disabled, + argTypes: { + as: { control: { type: null } }, + children: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +const Form = () => { + const [ textControlValue, setTextControlValue ] = useState( '' ); + const [ textAreaValue, setTextAreaValue ] = useState( '' ); + return ( + <VStack> + <TextControl + __nextHasNoMarginBottom + label="Text Control" + value={ textControlValue } + onChange={ setTextControlValue } + /> + <TextareaControl + __nextHasNoMarginBottom + label="TextArea Control" + value={ textAreaValue } + onChange={ setTextAreaValue } + /> + <SelectControl + __nextHasNoMarginBottom + label="Select Control" + onChange={ () => {} } + options={ [ + { value: '', label: 'Select an option', disabled: true }, + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + { value: 'c', label: 'Option C' }, + ] } + /> + </VStack> + ); +}; + +export const Default: StoryFn< typeof Disabled > = ( args ) => { + return ( + <Disabled { ...args }> + <Form /> + </Disabled> + ); +}; +Default.args = { + isDisabled: true, +}; + +export const ContentEditable: StoryFn< typeof Disabled > = ( args ) => { + return ( + <Disabled { ...args }> + <div contentEditable tabIndex={ 0 }> + contentEditable + </div> + </Disabled> + ); +}; +ContentEditable.args = { + isDisabled: true, +}; diff --git a/packages/components/src/disabled/stories/index.tsx b/packages/components/src/disabled/stories/index.tsx deleted file mode 100644 index 81f2bf4c079233..00000000000000 --- a/packages/components/src/disabled/stories/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Disabled from '../'; -import SelectControl from '../../select-control/'; -import TextControl from '../../text-control/'; -import TextareaControl from '../../textarea-control/'; -import { VStack } from '../../v-stack/'; - -const meta: ComponentMeta< typeof Disabled > = { - title: 'Components/Disabled', - component: Disabled, - argTypes: { - as: { control: { type: null } }, - children: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -const Form = () => { - const [ textControlValue, setTextControlValue ] = useState( '' ); - const [ textAreaValue, setTextAreaValue ] = useState( '' ); - return ( - <VStack> - <TextControl - __nextHasNoMarginBottom - label="Text Control" - value={ textControlValue } - onChange={ setTextControlValue } - /> - <TextareaControl - __nextHasNoMarginBottom - label="TextArea Control" - value={ textAreaValue } - onChange={ setTextAreaValue } - /> - <SelectControl - __nextHasNoMarginBottom - label="Select Control" - onChange={ () => {} } - options={ [ - { value: '', label: 'Select an option', disabled: true }, - { value: 'a', label: 'Option A' }, - { value: 'b', label: 'Option B' }, - { value: 'c', label: 'Option C' }, - ] } - /> - </VStack> - ); -}; - -export const Default: ComponentStory< typeof Disabled > = ( args ) => { - return ( - <Disabled { ...args }> - <Form /> - </Disabled> - ); -}; -Default.args = { - isDisabled: true, -}; - -export const ContentEditable: ComponentStory< typeof Disabled > = ( args ) => { - return ( - <Disabled { ...args }> - <div contentEditable tabIndex={ 0 }> - contentEditable - </div> - </Disabled> - ); -}; -ContentEditable.args = { - isDisabled: true, -}; diff --git a/packages/components/src/divider/component.tsx b/packages/components/src/divider/component.tsx index ee03d9bd4dbb3e..98b4edd61d4937 100644 --- a/packages/components/src/divider/component.tsx +++ b/packages/components/src/divider/component.tsx @@ -8,11 +8,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { - contextConnect, - useContextSystem, - WordPressComponentProps, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect, useContextSystem } from '../ui/context'; import { DividerView } from './styles'; import type { DividerProps } from './types'; diff --git a/packages/components/src/divider/stories/index.story.tsx b/packages/components/src/divider/stories/index.story.tsx new file mode 100644 index 00000000000000..d60a43164506b3 --- /dev/null +++ b/packages/components/src/divider/stories/index.story.tsx @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Text } from '../../text'; +import { Divider } from '..'; +import { Flex } from '../../flex'; + +const meta: Meta< typeof Divider > = { + component: Divider, + title: 'Components (Experimental)/Divider', + argTypes: { + margin: { + control: { type: 'text' }, + }, + marginStart: { + control: { type: 'text' }, + }, + marginEnd: { + control: { type: 'text' }, + }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Divider > = ( args ) => ( + <div> + <Text>Some text before the divider</Text> + <Divider { ...args } /> + <Text>Some text after the divider</Text> + </div> +); + +export const Horizontal: StoryFn< typeof Divider > = Template.bind( {} ); +Horizontal.args = { + margin: '2', +}; + +export const Vertical: StoryFn< typeof Divider > = Template.bind( {} ); +Vertical.args = { + ...Horizontal.args, + orientation: 'vertical', +}; + +// Inside a `flex` container, the divider will need to be `stretch` aligned in order to be visible. +export const InFlexContainer: StoryFn< typeof Divider > = ( args ) => { + return ( + <Flex align="stretch"> + <Text> + Some text before the divider Some text before the divider Some + text before the divider Some text before the divider Some text + before the divider Some text before the divider Some text before + the divider + </Text> + <Divider { ...args } /> + <Text> + Some text after the divider Some text after the divider Some + text after the divider + </Text> + </Flex> + ); +}; +InFlexContainer.args = { + ...Vertical.args, +}; diff --git a/packages/components/src/divider/stories/index.tsx b/packages/components/src/divider/stories/index.tsx deleted file mode 100644 index 312e5b8d7f6bea..00000000000000 --- a/packages/components/src/divider/stories/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Text } from '../../text'; -import { Divider } from '..'; -import { Flex } from '../../flex'; - -const meta: ComponentMeta< typeof Divider > = { - component: Divider, - title: 'Components (Experimental)/Divider', - argTypes: { - margin: { - control: { type: 'text' }, - }, - marginStart: { - control: { type: 'text' }, - }, - marginEnd: { - control: { type: 'text' }, - }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Divider > = ( args ) => ( - <div> - <Text>Some text before the divider</Text> - <Divider { ...args } /> - <Text>Some text after the divider</Text> - </div> -); - -export const Horizontal: ComponentStory< typeof Divider > = Template.bind( {} ); -Horizontal.args = { - margin: '2', -}; - -export const Vertical: ComponentStory< typeof Divider > = Template.bind( {} ); -Vertical.args = { - ...Horizontal.args, - orientation: 'vertical', -}; - -// Inside a `flex` container, the divider will need to be `stretch` aligned in order to be visible. -export const InFlexContainer: ComponentStory< typeof Divider > = ( args ) => { - return ( - <Flex align="stretch"> - <Text> - Some text before the divider Some text before the divider Some - text before the divider Some text before the divider Some text - before the divider Some text before the divider Some text before - the divider - </Text> - <Divider { ...args } /> - <Text> - Some text after the divider Some text after the divider Some - text after the divider - </Text> - </Flex> - ); -}; -InFlexContainer.args = { - ...Vertical.args, -}; diff --git a/packages/components/src/draggable/stories/index.story.tsx b/packages/components/src/draggable/stories/index.story.tsx new file mode 100644 index 00000000000000..fc48618b1083a3 --- /dev/null +++ b/packages/components/src/draggable/stories/index.story.tsx @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { DragEvent } from 'react'; + +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; +import { useState } from '@wordpress/element'; +import { Icon, more } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Draggable from '..'; + +const meta: Meta< typeof Draggable > = { + component: Draggable, + title: 'Components/Draggable', + argTypes: { + elementId: { control: { type: null } }, + __experimentalDragComponent: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { source: { code: '' } }, + }, +}; +export default meta; + +const DefaultTemplate: StoryFn< typeof Draggable > = ( args ) => { + const [ isDragging, setDragging ] = useState( false ); + const instanceId = useInstanceId( DefaultTemplate ); + + // Allow for the use of ID in the example. + return ( + <div> + <p + style={ { + padding: '1em', + position: 'relative', + zIndex: 1000, + backgroundColor: 'whitesmoke', + } } + > + Is Dragging? { isDragging ? 'Yes' : 'No!' } + </p> + <div + style={ { + zIndex: 100, + position: 'relative', + } } + > + <div + id={ `draggable-example-box-${ instanceId }` } + style={ { + display: 'inline-flex', + position: 'relative', + } } + > + <Draggable + { ...args } + elementId={ `draggable-example-box-${ instanceId }` } + > + { ( { onDraggableStart, onDraggableEnd } ) => { + const handleOnDragStart = ( event: DragEvent ) => { + setDragging( true ); + onDraggableStart( event ); + }; + const handleOnDragEnd = ( event: DragEvent ) => { + setDragging( false ); + onDraggableEnd( event ); + }; + + return ( + <div + onDragStart={ handleOnDragStart } + onDragEnd={ handleOnDragEnd } + draggable + style={ { + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + width: 100, + height: 100, + background: '#ddd', + } } + > + <Icon icon={ more } /> + </div> + ); + } } + </Draggable> + </div> + </div> + </div> + ); +}; + +export const Default: StoryFn< typeof Draggable > = DefaultTemplate.bind( {} ); +Default.args = {}; + +/** + * `appendToOwnerDocument` is used to append the element being dragged to the body of the owner document. + * + * This is useful when the element being dragged should not receive styles from its parent. + * For example, when the element's parent sets a `z-index` value that would cause the dragged + * element to be rendered behind other elements. + */ +export const AppendElementToOwnerDocument: StoryFn< typeof Draggable > = + DefaultTemplate.bind( {} ); +AppendElementToOwnerDocument.args = { + appendToOwnerDocument: true, +}; diff --git a/packages/components/src/draggable/stories/index.tsx b/packages/components/src/draggable/stories/index.tsx deleted file mode 100644 index ad94802feb93a6..00000000000000 --- a/packages/components/src/draggable/stories/index.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { DragEvent } from 'react'; - -/** - * WordPress dependencies - */ -import { useInstanceId } from '@wordpress/compose'; -import { useState } from '@wordpress/element'; -import { Icon, more } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import Draggable from '..'; - -const meta: ComponentMeta< typeof Draggable > = { - component: Draggable, - title: 'Components/Draggable', - argTypes: { - elementId: { control: { type: null } }, - __experimentalDragComponent: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { code: '' } }, - }, -}; -export default meta; - -const DefaultTemplate: ComponentStory< typeof Draggable > = ( args ) => { - const [ isDragging, setDragging ] = useState( false ); - const instanceId = useInstanceId( DefaultTemplate ); - - // Allow for the use of ID in the example. - return ( - <div> - <p - style={ { - padding: '1em', - position: 'relative', - zIndex: 1000, - backgroundColor: 'whitesmoke', - } } - > - Is Dragging? { isDragging ? 'Yes' : 'No!' } - </p> - <div - style={ { - zIndex: 100, - position: 'relative', - } } - > - <div - id={ `draggable-example-box-${ instanceId }` } - style={ { - display: 'inline-flex', - position: 'relative', - } } - > - <Draggable - { ...args } - elementId={ `draggable-example-box-${ instanceId }` } - > - { ( { onDraggableStart, onDraggableEnd } ) => { - const handleOnDragStart = ( event: DragEvent ) => { - setDragging( true ); - onDraggableStart( event ); - }; - const handleOnDragEnd = ( event: DragEvent ) => { - setDragging( false ); - onDraggableEnd( event ); - }; - - return ( - <div - onDragStart={ handleOnDragStart } - onDragEnd={ handleOnDragEnd } - draggable - style={ { - alignItems: 'center', - display: 'flex', - justifyContent: 'center', - width: 100, - height: 100, - background: '#ddd', - } } - > - <Icon icon={ more } /> - </div> - ); - } } - </Draggable> - </div> - </div> - </div> - ); -}; - -export const Default: ComponentStory< typeof Draggable > = DefaultTemplate.bind( - {} -); -Default.args = {}; - -/** - * `appendToOwnerDocument` is used to append the element being dragged to the body of the owner document. - * - * This is useful when the element being dragged should not receive styles from its parent. - * For example, when the element's parent sets a `z-index` value that would cause the dragged - * element to be rendered behind other elements. - */ -export const AppendElementToOwnerDocument: ComponentStory< typeof Draggable > = - DefaultTemplate.bind( {} ); -AppendElementToOwnerDocument.args = { - appendToOwnerDocument: true, -}; diff --git a/packages/components/src/draggable/test/index.native.js b/packages/components/src/draggable/test/index.native.js index d0553d4c944a5f..3a160d81a81fdd 100644 --- a/packages/components/src/draggable/test/index.native.js +++ b/packages/components/src/draggable/test/index.native.js @@ -110,6 +110,10 @@ describe( 'Draggable', () => { }, { state: State.END }, ] ); + // TODO(jest-console): Fix the warning and remove the expect below. + expect( console ).toHaveWarnedWith( + '[Reanimated] You can not use setGestureState in non-worklet function.' + ); expect( onDragStart ).toHaveBeenCalledTimes( 1 ); expect( onDragStart ).toHaveBeenCalledWith( { diff --git a/packages/components/src/drop-zone/stories/index.story.tsx b/packages/components/src/drop-zone/stories/index.story.tsx new file mode 100644 index 00000000000000..1ee9af5b851ebc --- /dev/null +++ b/packages/components/src/drop-zone/stories/index.story.tsx @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +/** + * Internal dependencies + */ +import DropZone from '..'; + +const meta: Meta< typeof DropZone > = { + component: DropZone, + title: 'Components/DropZone', + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof DropZone > = ( props ) => { + return ( + <div style={ { background: 'lightgray', padding: 16 } }> + Drop something here + <DropZone { ...props } /> + </div> + ); +}; + +export const Default = Template.bind( {} ); diff --git a/packages/components/src/drop-zone/stories/index.tsx b/packages/components/src/drop-zone/stories/index.tsx deleted file mode 100644 index de94c98e4be031..00000000000000 --- a/packages/components/src/drop-zone/stories/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -/** - * Internal dependencies - */ -import DropZone from '..'; - -const meta: ComponentMeta< typeof DropZone > = { - component: DropZone, - title: 'Components/DropZone', - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof DropZone > = ( props ) => { - return ( - <div style={ { background: 'lightgray', padding: 16 } }> - Drop something here - <DropZone { ...props } /> - </div> - ); -}; - -export const Default = Template.bind( {} ); diff --git a/packages/components/src/dropdown-menu-v2/README.md b/packages/components/src/dropdown-menu-v2/README.md index c3896f8ca11093..910690280015f5 100644 --- a/packages/components/src/dropdown-menu-v2/README.md +++ b/packages/components/src/dropdown-menu-v2/README.md @@ -110,7 +110,7 @@ The distance in pixels from the trigger. The preferred alignment against the trigger. May change when collisions occur. - Required: no -- Default: `"center"` +- Default: `"start"` ##### `alignOffset`: `number` diff --git a/packages/components/src/dropdown-menu-v2/index.tsx b/packages/components/src/dropdown-menu-v2/index.tsx index 7a0197f69fc3dd..02f8322aa6a52f 100644 --- a/packages/components/src/dropdown-menu-v2/index.tsx +++ b/packages/components/src/dropdown-menu-v2/index.tsx @@ -6,7 +6,12 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; /** * WordPress dependencies */ -import { forwardRef } from '@wordpress/element'; +import { + forwardRef, + createContext, + useContext, + useMemo, +} from '@wordpress/element'; import { isRTL } from '@wordpress/i18n'; import { check, chevronRightSmall, lineSolid } from '@wordpress/icons'; import { SVG, Circle } from '@wordpress/primitives'; @@ -14,7 +19,10 @@ import { SVG, Circle } from '@wordpress/primitives'; /** * Internal dependencies */ +import { useContextSystem, contextConnectWithoutRef } from '../ui/context'; +import { useSlot } from '../slot-fill'; import Icon from '../icon'; +import { SLOT_NAME as POPOVER_DEFAULT_SLOT_NAME } from '../popover'; import * as DropdownMenuStyled from './styles'; import type { DropdownMenuProps, @@ -27,32 +35,56 @@ import type { DropdownMenuRadioItemProps, DropdownMenuSeparatorProps, DropdownSubMenuTriggerProps, + DropdownMenuInternalContext, + DropdownMenuPrivateContext as DropdownMenuPrivateContextType, } from './types'; // Menu content's side padding + 4px -const SUB_MENU_OFFSET_SIDE = 12; +const SUB_MENU_OFFSET_SIDE = 16; // Opposite amount of the top padding of the menu item const SUB_MENU_OFFSET_ALIGN = -8; -/** - * `DropdownMenu` displays a menu to the user (such as a set of actions - * or functions) triggered by a button. - */ -export const DropdownMenu = ( { - // Root props - defaultOpen, - open, - onOpenChange, - modal = true, - // Content positioning props - side = 'bottom', - sideOffset = 0, - align = 'center', - alignOffset = 0, - // Render props - children, - trigger, -}: DropdownMenuProps ) => { +const DropdownMenuPrivateContext = + createContext< DropdownMenuPrivateContextType >( { + variant: undefined, + portalContainer: null, + } ); + +const UnconnectedDropdownMenu = ( props: DropdownMenuProps ) => { + const { + // Root props + defaultOpen, + open, + onOpenChange, + modal = true, + // Content positioning props + side = 'bottom', + sideOffset = 0, + align = 'center', + alignOffset = 0, + // Render props + children, + trigger, + + // From internal components context + variant, + } = useContextSystem< + // Adding `className` to the context type to avoid a TS error + DropdownMenuProps & DropdownMenuInternalContext & { className?: string } + >( props, 'DropdownMenu' ); + + // Render the portal in the default slot used by the legacy Popover component. + const slot = useSlot( POPOVER_DEFAULT_SLOT_NAME ); + const portalContainer = slot.ref?.current; + + const privateContextValue = useMemo( + () => ( { + variant, + portalContainer, + } ), + [ variant, portalContainer ] + ); + return ( <DropdownMenuPrimitive.Root defaultOpen={ defaultOpen } @@ -64,21 +96,35 @@ export const DropdownMenu = ( { <DropdownMenuPrimitive.Trigger asChild> { trigger } </DropdownMenuPrimitive.Trigger> - <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Portal container={ portalContainer }> <DropdownMenuStyled.Content side={ side } align={ align } sideOffset={ sideOffset } alignOffset={ alignOffset } loop={ true } + variant={ variant } > - { children } + <DropdownMenuPrivateContext.Provider + value={ privateContextValue } + > + { children } + </DropdownMenuPrivateContext.Provider> </DropdownMenuStyled.Content> </DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Root> ); }; +/** + * `DropdownMenu` displays a menu to the user (such as a set of actions + * or functions) triggered by a button. + */ +export const DropdownMenu = contextConnectWithoutRef( + UnconnectedDropdownMenu, + 'DropdownMenu' +); + export const DropdownSubMenuTrigger = ( { prefix, suffix = ( @@ -118,6 +164,10 @@ export const DropdownSubMenu = ( { children, trigger, }: DropdownSubMenuProps ) => { + const { variant, portalContainer } = useContext( + DropdownMenuPrivateContext + ); + return ( <DropdownMenuPrimitive.Sub defaultOpen={ defaultOpen } @@ -130,11 +180,12 @@ export const DropdownSubMenu = ( { > { trigger } </DropdownMenuStyled.SubTrigger> - <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Portal container={ portalContainer }> <DropdownMenuStyled.SubContent loop sideOffset={ SUB_MENU_OFFSET_SIDE } alignOffset={ SUB_MENU_OFFSET_ALIGN } + variant={ variant } > { children } </DropdownMenuStyled.SubContent> @@ -236,6 +287,7 @@ export const DropdownMenuRadioItem = ( { ); }; -export const DropdownMenuSeparator = ( props: DropdownMenuSeparatorProps ) => ( - <DropdownMenuStyled.Separator { ...props } /> -); +export const DropdownMenuSeparator = ( props: DropdownMenuSeparatorProps ) => { + const { variant } = useContext( DropdownMenuPrivateContext ); + return <DropdownMenuStyled.Separator { ...props } variant={ variant } />; +}; diff --git a/packages/components/src/dropdown-menu-v2/stories/index.story.tsx b/packages/components/src/dropdown-menu-v2/stories/index.story.tsx new file mode 100644 index 00000000000000..78aee12bf1f93a --- /dev/null +++ b/packages/components/src/dropdown-menu-v2/stories/index.story.tsx @@ -0,0 +1,221 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import styled from '@emotion/styled'; + +/** + * Internal dependencies + */ +import { COLORS } from '../../utils'; +import { + DropdownMenu, + DropdownMenuItem, + DropdownSubMenu, + DropdownMenuSeparator, + DropdownMenuCheckboxItem, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownSubMenuTrigger, +} from '..'; +import Button from '../../button'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { menu, wordpress } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Icon from '../../icon'; +import { ContextSystemProvider } from '../../ui/context'; + +const meta: Meta< typeof DropdownMenu > = { + title: 'Components (Experimental)/DropdownMenu v2', + component: DropdownMenu, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuItem, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownSubMenu, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownSubMenuTrigger, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuSeparator, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuCheckboxItem, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuGroup, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuLabel, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuRadioGroup, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + DropdownMenuRadioItem, + }, + argTypes: { + children: { control: { type: null } }, + trigger: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { + canvas: { sourceState: 'shown' }, + source: { excludeDecorators: true }, + }, + }, + decorators: [ + // Layout wrapper + ( Story ) => ( + <div + style={ { + minHeight: '300px', + } } + > + <Story /> + </div> + ), + ], +}; +export default meta; + +const ItemHelpText = styled.span` + font-size: 12px; + color: ${ COLORS.gray[ '700' ] }; + + /* "> * > &" syntax is to target only immediate parent menu item */ + [data-highlighted] > * > &, + [data-state='open'] > * > &, + [data-disabled] > * & { + color: inherit; + } +`; + +const CheckboxItemsGroup = () => { + const [ itemOneChecked, setItemOneChecked ] = useState( true ); + const [ itemTwoChecked, setItemTwoChecked ] = useState( false ); + + return ( + <DropdownMenuGroup> + <DropdownMenuLabel>Checkbox group label</DropdownMenuLabel> + <DropdownMenuCheckboxItem + checked={ itemOneChecked } + onCheckedChange={ setItemOneChecked } + suffix={ <span>⌘+B</span> } + > + Checkbox item one + </DropdownMenuCheckboxItem> + + <DropdownMenuCheckboxItem + checked={ itemTwoChecked } + onCheckedChange={ setItemTwoChecked } + > + Checkbox item two + </DropdownMenuCheckboxItem> + </DropdownMenuGroup> + ); +}; + +const RadioItemsGroup = () => { + const [ radioValue, setRadioValue ] = useState( 'radio-one' ); + + return ( + <DropdownMenuRadioGroup + value={ radioValue } + onValueChange={ setRadioValue } + > + <DropdownMenuLabel>Radio group label</DropdownMenuLabel> + <DropdownMenuRadioItem value="radio-one"> + Radio item one + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="radio-two"> + Radio item two + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + ); +}; + +const Template: StoryFn< typeof DropdownMenu > = ( props ) => ( + <DropdownMenu { ...props } /> +); +export const Default = Template.bind( {} ); +Default.args = { + trigger: <Button __next40pxDefaultSize label="Open menu" icon={ menu } />, + sideOffset: 12, + children: ( + <> + <DropdownMenuGroup> + <DropdownMenuItem>Menu item</DropdownMenuItem> + <DropdownMenuItem + prefix={ <Icon icon={ wordpress } size={ 24 } /> } + > + Menu item with prefix + </DropdownMenuItem> + <DropdownMenuItem suffix={ <span>⌥⌘T</span> }> + Menu item with suffix + </DropdownMenuItem> + <DropdownMenuItem disabled>Disabled menu item</DropdownMenuItem> + <DropdownSubMenu + trigger={ + <DropdownSubMenuTrigger>Submenu</DropdownSubMenuTrigger> + } + > + <DropdownMenuItem suffix={ <span>⌘+S</span> }> + Submenu item with suffix + </DropdownMenuItem> + <DropdownMenuItem> + <div + style={ { + display: 'inline-flex', + flexDirection: 'column', + } } + > + Submenu item + <ItemHelpText> + With additional custom text + </ItemHelpText> + </div> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownSubMenu + trigger={ + <DropdownSubMenuTrigger> + Second level submenu + </DropdownSubMenuTrigger> + } + > + <DropdownMenuItem>Submenu item</DropdownMenuItem> + <DropdownMenuItem>Submenu item</DropdownMenuItem> + </DropdownSubMenu> + </DropdownSubMenu> + </DropdownMenuGroup> + + <DropdownMenuSeparator /> + + <CheckboxItemsGroup /> + + <DropdownMenuSeparator /> + + <RadioItemsGroup /> + </> + ), +}; + +const toolbarVariantContextValue = { + DropdownMenu: { + variant: 'toolbar', + }, +}; +export const ToolbarVariant: StoryFn< typeof DropdownMenu > = ( props ) => ( + <ContextSystemProvider value={ toolbarVariantContextValue }> + <DropdownMenu { ...props } /> + </ContextSystemProvider> +); +ToolbarVariant.args = { + ...Default.args, +}; diff --git a/packages/components/src/dropdown-menu-v2/stories/index.tsx b/packages/components/src/dropdown-menu-v2/stories/index.tsx deleted file mode 100644 index 1171273028f9e1..00000000000000 --- a/packages/components/src/dropdown-menu-v2/stories/index.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import styled from '@emotion/styled'; - -/** - * Internal dependencies - */ -import { - DropdownMenu, - DropdownMenuItem, - DropdownSubMenu, - DropdownMenuSeparator, - DropdownMenuCheckboxItem, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownSubMenuTrigger, -} from '..'; -import Button from '../../button'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -import { menu, wordpress } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import Icon from '../../icon'; - -const meta: ComponentMeta< typeof DropdownMenu > = { - title: 'Components (Experimental)/DropdownMenu v2', - component: DropdownMenu, - subcomponents: { - DropdownMenuItem, - DropdownSubMenu, - DropdownSubMenuTrigger, - DropdownMenuSeparator, - DropdownMenuCheckboxItem, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - }, - argTypes: { - children: { control: { type: null } }, - trigger: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open', excludeDecorators: true } }, - }, - decorators: [ - // Layout wrapper - ( Story ) => ( - <div - style={ { - minHeight: '300px', - } } - > - <Story /> - </div> - ), - ], -}; -export default meta; - -const ItemHelpText = styled.span` - font-size: 10px; - color: #777; - - /* "> * > &" syntax is to target only immediate parent menu item */ - [data-highlighted] > * > &, - [data-state='open'] > * > &, - [data-disabled] > * & { - color: inherit; - } -`; - -const CheckboxItemsGroup = () => { - const [ itemOneChecked, setItemOneChecked ] = useState( true ); - const [ itemTwoChecked, setItemTwoChecked ] = useState( false ); - - return ( - <DropdownMenuGroup> - <DropdownMenuLabel>Checkbox group label</DropdownMenuLabel> - <DropdownMenuCheckboxItem - checked={ itemOneChecked } - onCheckedChange={ setItemOneChecked } - suffix={ <span>⌘+B</span> } - > - Checkbox item one - </DropdownMenuCheckboxItem> - - <DropdownMenuCheckboxItem - checked={ itemTwoChecked } - onCheckedChange={ setItemTwoChecked } - > - Checkbox item two - </DropdownMenuCheckboxItem> - </DropdownMenuGroup> - ); -}; - -const RadioItemsGroup = () => { - const [ radioValue, setRadioValue ] = useState( 'radio-one' ); - - return ( - <DropdownMenuRadioGroup - value={ radioValue } - onValueChange={ setRadioValue } - > - <DropdownMenuLabel>Radio group label</DropdownMenuLabel> - <DropdownMenuRadioItem value="radio-one"> - Radio item one - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="radio-two"> - Radio item two - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - ); -}; - -const Template: ComponentStory< typeof DropdownMenu > = ( props ) => ( - <DropdownMenu { ...props } /> -); -export const Default = Template.bind( {} ); -Default.args = { - trigger: <Button __next40pxDefaultSize label="Open menu" icon={ menu } />, - sideOffset: 12, - children: ( - <> - <DropdownMenuGroup> - <DropdownMenuItem>Menu item</DropdownMenuItem> - <DropdownMenuItem - prefix={ <Icon icon={ wordpress } size={ 18 } /> } - > - Menu item with prefix - </DropdownMenuItem> - <DropdownMenuItem suffix={ <span>⌥⌘T</span> }> - Menu item with suffix - </DropdownMenuItem> - <DropdownMenuItem disabled>Disabled menu item</DropdownMenuItem> - <DropdownSubMenu - trigger={ - <DropdownSubMenuTrigger>Submenu</DropdownSubMenuTrigger> - } - > - <DropdownMenuItem suffix={ <span>⌘+S</span> }> - Submenu item with suffix - </DropdownMenuItem> - <DropdownMenuItem> - <div - style={ { - display: 'inline-flex', - flexDirection: 'column', - } } - > - Submenu item - <ItemHelpText> - With additional custom text - </ItemHelpText> - </div> - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownSubMenu - trigger={ - <DropdownSubMenuTrigger> - Second level submenu - </DropdownSubMenuTrigger> - } - > - <DropdownMenuItem>Submenu item</DropdownMenuItem> - <DropdownMenuItem>Submenu item</DropdownMenuItem> - </DropdownSubMenu> - </DropdownSubMenu> - </DropdownMenuGroup> - - <DropdownMenuSeparator /> - - <CheckboxItemsGroup /> - - <DropdownMenuSeparator /> - - <RadioItemsGroup /> - </> - ), -}; diff --git a/packages/components/src/dropdown-menu-v2/styles.ts b/packages/components/src/dropdown-menu-v2/styles.ts index c8843d052ec724..eb1aec2d8a2d72 100644 --- a/packages/components/src/dropdown-menu-v2/styles.ts +++ b/packages/components/src/dropdown-menu-v2/styles.ts @@ -8,9 +8,10 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; /** * Internal dependencies */ -import { COLORS, font, rtl } from '../utils'; +import { COLORS, font, rtl, CONFIG } from '../utils'; import { space } from '../ui/utils/space'; import Icon from '../icon'; +import type { DropdownMenuInternalContext } from './types'; const ANIMATION_PARAMS = { SLIDE_AMOUNT: '2px', @@ -18,10 +19,17 @@ const ANIMATION_PARAMS = { EASING: 'cubic-bezier( 0.16, 1, 0.3, 1 )', }; +const CONTENT_WRAPPER_PADDING = space( 2 ); const ITEM_PREFIX_WIDTH = space( 7 ); const ITEM_PADDING_INLINE_START = space( 2 ); const ITEM_PADDING_INLINE_END = space( 2.5 ); +// TODO: should bring this into the config, and make themeable +const DEFAULT_BORDER_COLOR = COLORS.ui.borderDisabled; +const TOOLBAR_VARIANT_BORDER_COLOR = COLORS.gray[ '900' ]; +const DEFAULT_BOX_SHADOW = `0 0 0 ${ CONFIG.borderWidth } ${ DEFAULT_BORDER_COLOR }, ${ CONFIG.popoverShadow }`; +const TOOLBAR_VARIANT_BOX_SHADOW = `0 0 0 ${ CONFIG.borderWidth } ${ TOOLBAR_VARIANT_BORDER_COLOR }`; + const slideUpAndFade = keyframes( { '0%': { opacity: 0, @@ -54,15 +62,16 @@ const slideLeftAndFade = keyframes( { '100%': { opacity: 1, transform: 'translateX(0)' }, } ); -const baseContent = css` +const baseContent = ( + variant: DropdownMenuInternalContext[ 'variant' ] +) => css` min-width: 220px; background-color: ${ COLORS.ui.background }; - border-radius: 6px; - padding: ${ space( 2 ) }; - box-shadow: 0.1px 4px 16.4px -0.5px rgba( 0, 0, 0, 0.1 ), - 0px 5.5px 7.8px -0.3px rgba( 0, 0, 0, 0.1 ), - 0px 2.7px 3.8px -0.2px rgba( 0, 0, 0, 0.1 ), - 0px 0.7px 1px rgba( 0, 0, 0, 0.1 ); + border-radius: ${ CONFIG.radiusBlockUi }; + padding: ${ CONTENT_WRAPPER_PADDING }; + box-shadow: ${ variant === 'toolbar' + ? TOOLBAR_VARIANT_BOX_SHADOW + : DEFAULT_BOX_SHADOW }; animation-duration: ${ ANIMATION_PARAMS.DURATION }; animation-timing-function: ${ ANIMATION_PARAMS.EASING }; will-change: transform, opacity; @@ -155,7 +164,7 @@ const baseItem = css` font-weight: normal; line-height: 20px; color: ${ COLORS.gray[ 900 ] }; - border-radius: 3px; + border-radius: ${ CONFIG.radiusBlockUi }; display: flex; align-items: center; padding: ${ space( 2 ) } ${ ITEM_PADDING_INLINE_END } ${ space( 2 ) } @@ -174,14 +183,13 @@ const baseItem = css` pointer-events: none; } + /* Hover and Focus styles */ &[data-highlighted] { - /* - TODO: reconcile with global focus styles - (incl high contrast mode fallbacks) - */ + /* TODO: reconcile with global focus styles */ + background-color: ${ COLORS.gray[ '100' ] }; - background-color: ${ COLORS.ui.theme }; - color: white; + /* Only visible in Windows High Contrast mode */ + outline: 2px solid transparent; } svg { @@ -193,11 +201,15 @@ const baseItem = css` } `; -export const Content = styled( DropdownMenu.Content )` - ${ baseContent } +export const Content = styled( DropdownMenu.Content )< + Pick< DropdownMenuInternalContext, 'variant' > +>` + ${ ( props ) => baseContent( props.variant ) } `; -export const SubContent = styled( DropdownMenu.SubContent )` - ${ baseContent } +export const SubContent = styled( DropdownMenu.SubContent )< + Pick< DropdownMenuInternalContext, 'variant' > +>` + ${ ( props ) => baseContent( props.variant ) } `; export const Item = styled( DropdownMenu.Item )` @@ -210,13 +222,11 @@ export const RadioItem = styled( DropdownMenu.RadioItem )` ${ baseItem } `; export const SubTrigger = styled( DropdownMenu.SubTrigger )` - &[data-state='open']:not( [data-highlighted] ) { - /* TODO: use variable */ - background-color: rgba( 56, 88, 233, 0.04 ); - color: ${ COLORS.ui.theme }; - } - ${ baseItem } + + &[data-state='open'] { + background-color: ${ COLORS.gray[ '100' ] }; + } `; export const Label = styled( DropdownMenu.Label )` @@ -237,12 +247,17 @@ export const Label = styled( DropdownMenu.Label )` text-transform: uppercase; `; -export const Separator = styled( DropdownMenu.Separator )` - height: 1px; +export const Separator = styled( DropdownMenu.Separator )< + Pick< DropdownMenuInternalContext, 'variant' > +>` + height: ${ CONFIG.borderWidth }; /* TODO: doesn't match border color from variables */ - background-color: ${ COLORS.ui.borderDisabled }; + background-color: ${ ( props ) => + props.variant === 'toolbar' + ? TOOLBAR_VARIANT_BORDER_COLOR + : DEFAULT_BORDER_COLOR }; /* Negative horizontal margin to make separator go from side to side */ - margin: ${ space( 2 ) } 0; + margin: ${ space( 2 ) } calc( -1 * ${ CONTENT_WRAPPER_PADDING } ); `; export const ItemIndicator = styled( DropdownMenu.ItemIndicator )` diff --git a/packages/components/src/dropdown-menu-v2/types.ts b/packages/components/src/dropdown-menu-v2/types.ts index 1fb246fafd6537..5c7d6469b656c9 100644 --- a/packages/components/src/dropdown-menu-v2/types.ts +++ b/packages/components/src/dropdown-menu-v2/types.ts @@ -44,7 +44,7 @@ export type DropdownMenuProps = { * The preferred alignment against the trigger. * May change when collisions occur. * - * @default 'center' + * @default 'start' */ align?: DropdownMenuPrimitive.DropdownMenuContentProps[ 'align' ]; /** @@ -248,3 +248,18 @@ export type DropdownMenuGroupProps = { }; export type DropdownMenuSeparatorProps = {}; + +export type DropdownMenuInternalContext = { + /** + * This variant can be used to change the appearance of the component in + * specific contexts, ie. when rendered inside the `Toolbar` component. + */ + variant?: 'toolbar'; +}; + +export type DropdownMenuPrivateContext = Pick< + DropdownMenuInternalContext, + 'variant' +> & { + portalContainer: HTMLElement | null; +}; diff --git a/packages/components/src/dropdown-menu/index.tsx b/packages/components/src/dropdown-menu/index.tsx index 805bcd06611798..b5b7533e52bc40 100644 --- a/packages/components/src/dropdown-menu/index.tsx +++ b/packages/components/src/dropdown-menu/index.tsx @@ -11,10 +11,15 @@ import { menu } from '@wordpress/icons'; /** * Internal dependencies */ +import { contextConnectWithoutRef, useContextSystem } from '../ui/context'; import Button from '../button'; import Dropdown from '../dropdown'; import { NavigableMenu } from '../navigable-container'; -import type { DropdownMenuProps, DropdownOption } from './types'; +import type { + DropdownMenuProps, + DropdownOption, + DropdownMenuInternalContext, +} from './types'; function mergeProps< T extends { className?: string; [ key: string ]: unknown } @@ -38,88 +43,7 @@ function isFunction( maybeFunc: unknown ): maybeFunc is () => void { return typeof maybeFunc === 'function'; } -/** - * - * The DropdownMenu displays a list of actions (each contained in a MenuItem, - * MenuItemsChoice, or MenuGroup) in a compact way. It appears in a Popover - * after the user has interacted with an element (a button or icon) or when - * they perform a specific action. - * - * Render a Dropdown Menu with a set of controls: - * - * ```jsx - * import { DropdownMenu } from '@wordpress/components'; - * import { - * more, - * arrowLeft, - * arrowRight, - * arrowUp, - * arrowDown, - * } from '@wordpress/icons'; - * - * const MyDropdownMenu = () => ( - * <DropdownMenu - * icon={ more } - * label="Select a direction" - * controls={ [ - * { - * title: 'Up', - * icon: arrowUp, - * onClick: () => console.log( 'up' ), - * }, - * { - * title: 'Right', - * icon: arrowRight, - * onClick: () => console.log( 'right' ), - * }, - * { - * title: 'Down', - * icon: arrowDown, - * onClick: () => console.log( 'down' ), - * }, - * { - * title: 'Left', - * icon: arrowLeft, - * onClick: () => console.log( 'left' ), - * }, - * ] } - * /> - * ); - * ``` - * - * Alternatively, specify a `children` function which returns elements valid for - * use in a DropdownMenu: `MenuItem`, `MenuItemsChoice`, or `MenuGroup`. - * - * ```jsx - * import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; - * import { more, arrowUp, arrowDown, trash } from '@wordpress/icons'; - * - * const MyDropdownMenu = () => ( - * <DropdownMenu icon={ more } label="Select a direction"> - * { ( { onClose } ) => ( - * <> - * <MenuGroup> - * <MenuItem icon={ arrowUp } onClick={ onClose }> - * Move Up - * </MenuItem> - * <MenuItem icon={ arrowDown } onClick={ onClose }> - * Move Down - * </MenuItem> - * </MenuGroup> - * <MenuGroup> - * <MenuItem icon={ trash } onClick={ onClose }> - * Remove - * </MenuItem> - * </MenuGroup> - * </> - * ) } - * </DropdownMenu> - * ); - * ``` - * - */ - -function DropdownMenu( dropdownMenuProps: DropdownMenuProps ) { +function UnconnectedDropdownMenu( dropdownMenuProps: DropdownMenuProps ) { const { children, className, @@ -132,7 +56,13 @@ function DropdownMenu( dropdownMenuProps: DropdownMenuProps ) { disableOpenOnArrowDown = false, text, noIcons, - } = dropdownMenuProps; + + // Context + variant, + } = useContextSystem< DropdownMenuProps & DropdownMenuInternalContext >( + dropdownMenuProps, + 'DropdownMenu' + ); if ( ! controls?.length && ! isFunction( children ) ) { return null; @@ -154,13 +84,14 @@ function DropdownMenu( dropdownMenuProps: DropdownMenuProps ) { const mergedPopoverProps = mergeProps( { className: 'components-dropdown-menu__popover', + variant, }, popoverProps ); return ( <Dropdown - className={ classnames( 'components-dropdown-menu', className ) } + className={ className } popoverProps={ mergedPopoverProps } renderToggle={ ( { isOpen, onToggle } ) => { const openOnArrowDown = ( event: React.KeyboardEvent ) => { @@ -284,4 +215,89 @@ function DropdownMenu( dropdownMenuProps: DropdownMenuProps ) { ); } +/** + * + * The DropdownMenu displays a list of actions (each contained in a MenuItem, + * MenuItemsChoice, or MenuGroup) in a compact way. It appears in a Popover + * after the user has interacted with an element (a button or icon) or when + * they perform a specific action. + * + * Render a Dropdown Menu with a set of controls: + * + * ```jsx + * import { DropdownMenu } from '@wordpress/components'; + * import { + * more, + * arrowLeft, + * arrowRight, + * arrowUp, + * arrowDown, + * } from '@wordpress/icons'; + * + * const MyDropdownMenu = () => ( + * <DropdownMenu + * icon={ more } + * label="Select a direction" + * controls={ [ + * { + * title: 'Up', + * icon: arrowUp, + * onClick: () => console.log( 'up' ), + * }, + * { + * title: 'Right', + * icon: arrowRight, + * onClick: () => console.log( 'right' ), + * }, + * { + * title: 'Down', + * icon: arrowDown, + * onClick: () => console.log( 'down' ), + * }, + * { + * title: 'Left', + * icon: arrowLeft, + * onClick: () => console.log( 'left' ), + * }, + * ] } + * /> + * ); + * ``` + * + * Alternatively, specify a `children` function which returns elements valid for + * use in a DropdownMenu: `MenuItem`, `MenuItemsChoice`, or `MenuGroup`. + * + * ```jsx + * import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; + * import { more, arrowUp, arrowDown, trash } from '@wordpress/icons'; + * + * const MyDropdownMenu = () => ( + * <DropdownMenu icon={ more } label="Select a direction"> + * { ( { onClose } ) => ( + * <> + * <MenuGroup> + * <MenuItem icon={ arrowUp } onClick={ onClose }> + * Move Up + * </MenuItem> + * <MenuItem icon={ arrowDown } onClick={ onClose }> + * Move Down + * </MenuItem> + * </MenuGroup> + * <MenuGroup> + * <MenuItem icon={ trash } onClick={ onClose }> + * Remove + * </MenuItem> + * </MenuGroup> + * </> + * ) } + * </DropdownMenu> + * ); + * ``` + * + */ +export const DropdownMenu = contextConnectWithoutRef( + UnconnectedDropdownMenu, + 'DropdownMenu' +); + export default DropdownMenu; diff --git a/packages/components/src/dropdown-menu/stories/index.story.tsx b/packages/components/src/dropdown-menu/stories/index.story.tsx new file mode 100644 index 00000000000000..0490636cfa2067 --- /dev/null +++ b/packages/components/src/dropdown-menu/stories/index.story.tsx @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +/** + * Internal dependencies + */ +import { DropdownMenu } from '..'; +import MenuItem from '../../menu-item'; +import MenuGroup from '../../menu-group'; + +/** + * WordPress dependencies + */ +import { + menu, + arrowUp, + arrowDown, + chevronDown, + more, + trash, +} from '@wordpress/icons'; + +const meta: Meta< typeof DropdownMenu > = { + title: 'Components/DropdownMenu', + component: DropdownMenu, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, + argTypes: { + icon: { + options: [ 'menu', 'chevronDown', 'more' ], + mapping: { menu, chevronDown, more }, + control: { type: 'select' }, + }, + }, +}; +export default meta; + +const Template: StoryFn< typeof DropdownMenu > = ( props ) => ( + <div style={ { height: 150 } }> + <DropdownMenu { ...props } /> + </div> +); + +export const Default = Template.bind( {} ); +Default.args = { + label: 'Select a direction.', + icon: menu, + controls: [ + { + title: 'First Menu Item Label', + icon: arrowUp, + // eslint-disable-next-line no-console + onClick: () => console.log( 'up!' ), + }, + { + title: 'Second Menu Item Label', + icon: arrowDown, + // eslint-disable-next-line no-console + onClick: () => console.log( 'down!' ), + }, + ], +}; + +export const WithChildren = Template.bind( {} ); +// Adding custom source because Storybook is not able to show the contents of +// the `children` prop correctly in the code snippet. +WithChildren.parameters = { + docs: { + source: { + code: `<DropdownMenu label="Select a direction." icon={ more }> + <MenuGroup> + <MenuItem icon={ arrowUp } onClick={ onClose }> + Move Up + </MenuItem> + <MenuItem icon={ arrowDown } onClick={ onClose }> + Move Down + </MenuItem> + </MenuGroup> + <MenuGroup> + <MenuItem icon={ trash } onClick={ onClose }> + Remove + </MenuItem> + </MenuGroup> +</DropdownMenu>`, + language: 'jsx', + type: 'auto', + }, + }, +}; +WithChildren.args = { + label: 'Select a direction.', + icon: more, + children: ( { onClose } ) => ( + <> + <MenuGroup> + <MenuItem icon={ arrowUp } onClick={ onClose }> + Move Up + </MenuItem> + <MenuItem icon={ arrowDown } onClick={ onClose }> + Move Down + </MenuItem> + </MenuGroup> + <MenuGroup> + <MenuItem icon={ trash } onClick={ onClose }> + Remove + </MenuItem> + </MenuGroup> + </> + ), +}; diff --git a/packages/components/src/dropdown-menu/stories/index.tsx b/packages/components/src/dropdown-menu/stories/index.tsx deleted file mode 100644 index 8bc652269422e8..00000000000000 --- a/packages/components/src/dropdown-menu/stories/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -/** - * Internal dependencies - */ -import DropdownMenu from '..'; -import MenuItem from '../../menu-item'; -import MenuGroup from '../../menu-group'; - -/** - * WordPress dependencies - */ -import { - menu, - arrowUp, - arrowDown, - chevronDown, - more, - trash, -} from '@wordpress/icons'; - -const meta: ComponentMeta< typeof DropdownMenu > = { - title: 'Components/DropdownMenu', - component: DropdownMenu, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, - argTypes: { - icon: { - options: [ 'menu', 'chevronDown', 'more' ], - mapping: { menu, chevronDown, more }, - control: { type: 'select' }, - }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof DropdownMenu > = ( props ) => ( - <div style={ { height: 150 } }> - <DropdownMenu { ...props } /> - </div> -); - -export const Default = Template.bind( {} ); -Default.args = { - label: 'Select a direction.', - icon: menu, - controls: [ - { - title: 'First Menu Item Label', - icon: arrowUp, - // eslint-disable-next-line no-console - onClick: () => console.log( 'up!' ), - }, - { - title: 'Second Menu Item Label', - icon: arrowDown, - // eslint-disable-next-line no-console - onClick: () => console.log( 'down!' ), - }, - ], -}; - -export const WithChildren = Template.bind( {} ); -// Adding custom source because Storybook is not able to show the contents of -// the `children` prop correctly in the code snippet. -WithChildren.parameters = { - docs: { - source: { - code: `<DropdownMenu label="Select a direction." icon={ more }> - <MenuGroup> - <MenuItem icon={ arrowUp } onClick={ onClose }> - Move Up - </MenuItem> - <MenuItem icon={ arrowDown } onClick={ onClose }> - Move Down - </MenuItem> - </MenuGroup> - <MenuGroup> - <MenuItem icon={ trash } onClick={ onClose }> - Remove - </MenuItem> - </MenuGroup> -</DropdownMenu>`, - language: 'jsx', - type: 'auto', - }, - }, -}; -WithChildren.args = { - label: 'Select a direction.', - icon: more, - children: ( { onClose } ) => ( - <> - <MenuGroup> - <MenuItem icon={ arrowUp } onClick={ onClose }> - Move Up - </MenuItem> - <MenuItem icon={ arrowDown } onClick={ onClose }> - Move Down - </MenuItem> - </MenuGroup> - <MenuGroup> - <MenuItem icon={ trash } onClick={ onClose }> - Remove - </MenuItem> - </MenuGroup> - </> - ), -}; diff --git a/packages/components/src/dropdown-menu/style.scss b/packages/components/src/dropdown-menu/style.scss index 64303ea9049eb7..7d9e1b997f7804 100644 --- a/packages/components/src/dropdown-menu/style.scss +++ b/packages/components/src/dropdown-menu/style.scss @@ -30,19 +30,15 @@ height: 1px; } - &.is-active svg { - // Block UI appearance. - color: $white; - background: $gray-900; - box-shadow: 0 0 0 $border-width $gray-900; - border-radius: $border-width; - } - - // Formatting buttons - > svg { - border-radius: $radius-block-ui; - width: $button-size-small; - height: $button-size-small; + &.is-active { + svg, + .dashicon { + // Block UI appearance. + color: $white; + background: $gray-900; + box-shadow: 0 0 0 $border-width $gray-900; + border-radius: $border-width; + } } // If menu items are icon-only, make them stretch only to the icon size. diff --git a/packages/components/src/dropdown-menu/types.ts b/packages/components/src/dropdown-menu/types.ts index badfcb54d60727..1063631c65113e 100644 --- a/packages/components/src/dropdown-menu/types.ts +++ b/packages/components/src/dropdown-menu/types.ts @@ -141,3 +141,11 @@ export type DropdownMenuProps = { */ controls?: DropdownOption[] | DropdownOption[][]; }; + +export type DropdownMenuInternalContext = { + /** + * This variant can be used to change the appearance of the component in + * specific contexts, ie. when rendered inside the `Toolbar` component. + */ + variant?: 'toolbar'; +}; diff --git a/packages/components/src/dropdown/dropdown-content-wrapper.tsx b/packages/components/src/dropdown/dropdown-content-wrapper.tsx index 48c04a33657216..ba9a15218f6f3a 100644 --- a/packages/components/src/dropdown/dropdown-content-wrapper.tsx +++ b/packages/components/src/dropdown/dropdown-content-wrapper.tsx @@ -6,11 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { - WordPressComponentProps, - contextConnect, - useContextSystem, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect, useContextSystem } from '../ui/context'; import { DropdownContentWrapperDiv } from './styles'; import type { DropdownContentWrapperProps } from './types'; diff --git a/packages/components/src/dropdown/index.tsx b/packages/components/src/dropdown/index.tsx index 06499be7f9c303..2060254fa73c11 100644 --- a/packages/components/src/dropdown/index.tsx +++ b/packages/components/src/dropdown/index.tsx @@ -7,15 +7,16 @@ import type { ForwardedRef } from 'react'; /** * WordPress dependencies */ -import { forwardRef, useEffect, useRef, useState } from '@wordpress/element'; +import { useEffect, useRef, useState } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ +import { contextConnect, useContextSystem } from '../ui/context'; import Popover from '../popover'; -import type { DropdownProps } from './types'; +import type { DropdownProps, DropdownInternalContext } from './types'; function useObservableState( initialState: boolean, @@ -33,8 +34,11 @@ function useObservableState( ] as const; } -function UnforwardedDropdown( - { +const UnconnectedDropdown = ( + props: DropdownProps, + forwardedRef: ForwardedRef< any > +) => { + const { renderContent, renderToggle, className, @@ -49,9 +53,14 @@ function UnforwardedDropdown( // Deprecated props position, - }: DropdownProps, - forwardedRef: ForwardedRef< any > -) { + + // From context system + variant, + } = useContextSystem< DropdownProps & DropdownInternalContext >( + props, + 'Dropdown' + ); + if ( position !== undefined ) { deprecated( '`position` prop in wp.components.Dropdown', { since: '6.2', @@ -120,7 +129,7 @@ function UnforwardedDropdown( return ( <div - className={ classnames( 'components-dropdown', className ) } + className={ className } ref={ useMergeRefs( [ containerRef, forwardedRef, @@ -149,6 +158,7 @@ function UnforwardedDropdown( ? fallbackPopoverAnchor : undefined } + variant={ variant } { ...popoverProps } className={ classnames( 'components-dropdown__content', @@ -161,7 +171,7 @@ function UnforwardedDropdown( ) } </div> ); -} +}; /** * Renders a button that opens a floating content modal when clicked. @@ -188,6 +198,6 @@ function UnforwardedDropdown( * ); * ``` */ -export const Dropdown = forwardRef( UnforwardedDropdown ); +export const Dropdown = contextConnect( UnconnectedDropdown, 'Dropdown' ); export default Dropdown; diff --git a/packages/components/src/dropdown/stories/index.story.tsx b/packages/components/src/dropdown/stories/index.story.tsx new file mode 100644 index 00000000000000..0b29da916b8d89 --- /dev/null +++ b/packages/components/src/dropdown/stories/index.story.tsx @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Dropdown from '..'; +import Button from '../../button'; +import { DropdownContentWrapper } from '../dropdown-content-wrapper'; + +const meta: Meta< typeof Dropdown > = { + title: 'Components/Dropdown', + component: Dropdown, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { DropdownContentWrapper }, + argTypes: { + focusOnMount: { + options: [ 'firstElement', true, false ], + control: { + type: 'radio', + }, + }, + position: { control: { type: null } }, + renderContent: { control: { type: null } }, + renderToggle: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Dropdown > = ( args ) => { + return ( + <div style={ { height: 150 } }> + <Dropdown { ...args } /> + </div> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + renderToggle: ( { isOpen, onToggle } ) => ( + <Button onClick={ onToggle } aria-expanded={ isOpen } variant="primary"> + Open dropdown + </Button> + ), + renderContent: () => <div>This is the dropdown content.</div>, +}; + +/** + * To apply more padding to the dropdown content, use the provided `<DropdownContentWrapper>` + * convenience wrapper. A `paddingSize` of `"medium"` is suitable for relatively larger dropdowns (default is `"small"`). + */ +export const WithMorePadding = Template.bind( {} ); +WithMorePadding.args = { + ...Default.args, + renderContent: () => ( + <DropdownContentWrapper paddingSize="medium"> + Content wrapped with <code>{ `paddingSize="medium"` }</code>. + </DropdownContentWrapper> + ), +}; + +/** + * The `<DropdownContentWrapper>` convenience wrapper can also be used to remove padding entirely, + * with a `paddingSize` of `"none"`. This can also serve as a clean foundation to add arbitrary + * paddings, for example when child components already have padding on their own. + */ +export const WithNoPadding = Template.bind( {} ); +WithNoPadding.args = { + ...Default.args, + renderContent: () => ( + <DropdownContentWrapper paddingSize="none"> + Content wrapped with <code>{ `paddingSize="none"` }</code>. + </DropdownContentWrapper> + ), +}; diff --git a/packages/components/src/dropdown/stories/index.tsx b/packages/components/src/dropdown/stories/index.tsx deleted file mode 100644 index 4e634cce5ae7b6..00000000000000 --- a/packages/components/src/dropdown/stories/index.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Dropdown from '..'; -import Button from '../../button'; -import { DropdownContentWrapper } from '../dropdown-content-wrapper'; - -const meta: ComponentMeta< typeof Dropdown > = { - title: 'Components/Dropdown', - component: Dropdown, - subcomponents: { DropdownContentWrapper }, - argTypes: { - focusOnMount: { - options: [ 'firstElement', true, false ], - control: { - type: 'radio', - }, - }, - position: { control: { type: null } }, - renderContent: { control: { type: null } }, - renderToggle: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Dropdown > = ( args ) => { - return ( - <div style={ { height: 150 } }> - <Dropdown { ...args } /> - </div> - ); -}; - -export const Default: ComponentStory< typeof Dropdown > = Template.bind( {} ); -Default.args = { - renderToggle: ( { isOpen, onToggle } ) => ( - <Button onClick={ onToggle } aria-expanded={ isOpen } variant="primary"> - Open dropdown - </Button> - ), - renderContent: () => <div>This is the dropdown content.</div>, -}; - -/** - * To apply more padding to the dropdown content, use the provided `<DropdownContentWrapper>` - * convenience wrapper. A `paddingSize` of `"medium"` is suitable for relatively larger dropdowns (default is `"small"`). - */ -export const WithMorePadding: ComponentStory< typeof Dropdown > = Template.bind( - {} -); -WithMorePadding.args = { - ...Default.args, - renderContent: () => ( - <DropdownContentWrapper paddingSize="medium"> - Content wrapped with <code>{ `paddingSize="medium"` }</code>. - </DropdownContentWrapper> - ), -}; - -/** - * The `<DropdownContentWrapper>` convenience wrapper can also be used to remove padding entirely, - * with a `paddingSize` of `"none"`. This can also serve as a clean foundation to add arbitrary - * paddings, for example when child components already have padding on their own. - */ -export const WithNoPadding: ComponentStory< typeof Dropdown > = Template.bind( - {} -); -WithNoPadding.args = { - ...Default.args, - renderContent: () => ( - <DropdownContentWrapper paddingSize="none"> - Content wrapped with <code>{ `paddingSize="none"` }</code>. - </DropdownContentWrapper> - ), -}; diff --git a/packages/components/src/dropdown/types.ts b/packages/components/src/dropdown/types.ts index 4842dc7d0a362a..c95953f37b1fb1 100644 --- a/packages/components/src/dropdown/types.ts +++ b/packages/components/src/dropdown/types.ts @@ -112,3 +112,11 @@ export type DropdownProps = { */ position?: PopoverProps[ 'position' ]; }; + +export type DropdownInternalContext = { + /** + * This variant can be used to change the appearance of the component in + * specific contexts, ie. when rendered inside the `Toolbar` component. + */ + variant?: 'toolbar'; +}; diff --git a/packages/components/src/duotone-picker/stories/duotone-picker.story.tsx b/packages/components/src/duotone-picker/stories/duotone-picker.story.tsx new file mode 100644 index 00000000000000..f06d0ee40a6ce3 --- /dev/null +++ b/packages/components/src/duotone-picker/stories/duotone-picker.story.tsx @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { DuotonePicker } from '..'; +import type { DuotonePickerProps } from '../types'; + +const meta: Meta< typeof DuotonePicker > = { + title: 'Components/DuotonePicker', + component: DuotonePicker, + argTypes: { + onChange: { action: 'onChange' }, + value: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const DUOTONE_PALETTE = [ + { + colors: [ '#8c00b7', '#fcff41' ], + name: 'Purple and yellow', + slug: 'purple-yellow', + }, + { + colors: [ '#000097', '#ff4747' ], + name: 'Blue and red', + slug: 'blue-red', + }, +]; + +const COLOR_PALETTE = [ + { color: '#ff4747', name: 'Red', slug: 'red' }, + { color: '#fcff41', name: 'Yellow', slug: 'yellow' }, + { color: '#000097', name: 'Blue', slug: 'blue' }, + { color: '#8c00b7', name: 'Purple', slug: 'purple' }, +]; + +const Template: StoryFn< typeof DuotonePicker > = ( { onChange, ...args } ) => { + const [ value, setValue ] = useState< DuotonePickerProps[ 'value' ] >(); + + return ( + <DuotonePicker + { ...args } + onChange={ ( ...changeArgs ) => { + setValue( ...changeArgs ); + onChange( ...changeArgs ); + } } + value={ value } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + colorPalette: COLOR_PALETTE, + duotonePalette: DUOTONE_PALETTE, +}; diff --git a/packages/components/src/duotone-picker/stories/duotone-picker.tsx b/packages/components/src/duotone-picker/stories/duotone-picker.tsx deleted file mode 100644 index a8a1111720d369..00000000000000 --- a/packages/components/src/duotone-picker/stories/duotone-picker.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { DuotonePicker } from '..'; -import type { DuotonePickerProps } from '../types'; - -const meta: ComponentMeta< typeof DuotonePicker > = { - title: 'Components/DuotonePicker', - component: DuotonePicker, - argTypes: { - onChange: { action: 'onChange' }, - value: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const DUOTONE_PALETTE = [ - { - colors: [ '#8c00b7', '#fcff41' ], - name: 'Purple and yellow', - slug: 'purple-yellow', - }, - { - colors: [ '#000097', '#ff4747' ], - name: 'Blue and red', - slug: 'blue-red', - }, -]; - -const COLOR_PALETTE = [ - { color: '#ff4747', name: 'Red', slug: 'red' }, - { color: '#fcff41', name: 'Yellow', slug: 'yellow' }, - { color: '#000097', name: 'Blue', slug: 'blue' }, - { color: '#8c00b7', name: 'Purple', slug: 'purple' }, -]; - -const Template: ComponentStory< typeof DuotonePicker > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState< DuotonePickerProps[ 'value' ] >(); - - return ( - <DuotonePicker - { ...args } - onChange={ ( ...changeArgs ) => { - setValue( ...changeArgs ); - onChange( ...changeArgs ); - } } - value={ value } - /> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - colorPalette: COLOR_PALETTE, - duotonePalette: DUOTONE_PALETTE, -}; diff --git a/packages/components/src/duotone-picker/stories/duotone-swatch.story.tsx b/packages/components/src/duotone-picker/stories/duotone-swatch.story.tsx new file mode 100644 index 00000000000000..edcca5114d0260 --- /dev/null +++ b/packages/components/src/duotone-picker/stories/duotone-swatch.story.tsx @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { DuotoneSwatch } from '..'; + +const meta: Meta< typeof DuotoneSwatch > = { + title: 'Components/DuotoneSwatch', + component: DuotoneSwatch, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof DuotoneSwatch > = ( args ) => { + return <DuotoneSwatch { ...args } />; +}; + +export const Default = Template.bind( {} ); +Default.args = { + values: [ '#000', '#fff' ], +}; + +export const SingleColor = Template.bind( {} ); +SingleColor.args = { + values: [ 'pink' ], +}; + +export const Null = Template.bind( {} ); +Null.args = { + values: null, +}; diff --git a/packages/components/src/duotone-picker/stories/duotone-swatch.tsx b/packages/components/src/duotone-picker/stories/duotone-swatch.tsx deleted file mode 100644 index 1485bca10e1d8f..00000000000000 --- a/packages/components/src/duotone-picker/stories/duotone-swatch.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { DuotoneSwatch } from '..'; - -const meta: ComponentMeta< typeof DuotoneSwatch > = { - title: 'Components/DuotoneSwatch', - component: DuotoneSwatch, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof DuotoneSwatch > = ( args ) => { - return <DuotoneSwatch { ...args } />; -}; - -export const Default = Template.bind( {} ); -Default.args = { - values: [ '#000', '#fff' ], -}; - -export const SingleColor = Template.bind( {} ); -SingleColor.args = { - values: [ 'pink' ], -}; - -export const Null = Template.bind( {} ); -Null.args = { - values: null, -}; diff --git a/packages/components/src/elevation/component.tsx b/packages/components/src/elevation/component.tsx index bdcdd90e718b59..269929820824f3 100644 --- a/packages/components/src/elevation/component.tsx +++ b/packages/components/src/elevation/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import { useElevation } from './hook'; import type { ElevationProps } from './types'; diff --git a/packages/components/src/elevation/hook.ts b/packages/components/src/elevation/hook.ts index 797fe784c2e400..1aa97c9167bc93 100644 --- a/packages/components/src/elevation/hook.ts +++ b/packages/components/src/elevation/hook.ts @@ -1,7 +1,8 @@ /** * External dependencies */ -import { css, SerializedStyles } from '@emotion/react'; +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; /** * WordPress dependencies @@ -11,7 +12,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import * as styles from './styles'; import { CONFIG, reduceMotion } from '../utils'; import { useCx } from '../utils/hooks/use-cx'; diff --git a/packages/components/src/elevation/stories/index.story.tsx b/packages/components/src/elevation/stories/index.story.tsx new file mode 100644 index 00000000000000..912268f5341147 --- /dev/null +++ b/packages/components/src/elevation/stories/index.story.tsx @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Elevation } from '..'; + +const meta: Meta< typeof Elevation > = { + component: Elevation, + title: 'Components (Experimental)/Elevation', + argTypes: { + as: { control: { type: 'text' } }, + borderRadius: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Elevation > = ( args ) => { + return ( + <div + style={ { + width: 150, + height: 150, + position: 'relative', + } } + > + <Elevation { ...args } /> + </div> + ); +}; + +const InteractiveTemplate: StoryFn< typeof Elevation > = ( args ) => { + return ( + <button + style={ { + border: 0, + background: 'transparent', + width: 150, + height: 150, + position: 'relative', + } } + > + Click me + <Elevation { ...args } /> + </button> + ); +}; + +export const Default: StoryFn< typeof Elevation > = Template.bind( {} ); +Default.args = { + value: 5, +}; + +/** + * Enable the `isInteractive` prop to automatically generate values + * for the hover/active/focus states. + */ +export const WithInteractive: StoryFn< typeof Elevation > = + InteractiveTemplate.bind( {} ); +WithInteractive.args = { + ...Default.args, + isInteractive: true, +}; + +/** + * You can also provide custom values for the hover/active/focus states + * instead of using the `isInteractive` prop. + */ +export const WithCustomInteractive: StoryFn< typeof Elevation > = + InteractiveTemplate.bind( {} ); +WithCustomInteractive.args = { + ...Default.args, + hover: 7, + active: 1, + focus: 10, +}; diff --git a/packages/components/src/elevation/stories/index.tsx b/packages/components/src/elevation/stories/index.tsx deleted file mode 100644 index af4be87daa0cde..00000000000000 --- a/packages/components/src/elevation/stories/index.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Elevation } from '..'; - -const meta: ComponentMeta< typeof Elevation > = { - component: Elevation, - title: 'Components (Experimental)/Elevation', - argTypes: { - as: { control: { type: 'text' } }, - borderRadius: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Elevation > = ( args ) => { - return ( - <div - style={ { - width: 150, - height: 150, - position: 'relative', - } } - > - <Elevation { ...args } /> - </div> - ); -}; - -const InteractiveTemplate: ComponentStory< typeof Elevation > = ( args ) => { - return ( - <button - style={ { - border: 0, - background: 'transparent', - width: 150, - height: 150, - position: 'relative', - } } - > - Click me - <Elevation { ...args } /> - </button> - ); -}; - -export const Default: ComponentStory< typeof Elevation > = Template.bind( {} ); -Default.args = { - value: 5, -}; - -/** - * Enable the `isInteractive` prop to automatically generate values - * for the hover/active/focus states. - */ -export const WithInteractive: ComponentStory< typeof Elevation > = - InteractiveTemplate.bind( {} ); -WithInteractive.args = { - ...Default.args, - isInteractive: true, -}; - -/** - * You can also provide custom values for the hover/active/focus states - * instead of using the `isInteractive` prop. - */ -export const WithCustomInteractive: ComponentStory< typeof Elevation > = - InteractiveTemplate.bind( {} ); -WithCustomInteractive.args = { - ...Default.args, - hover: 7, - active: 1, - focus: 10, -}; diff --git a/packages/components/src/external-link/stories/index.story.tsx b/packages/components/src/external-link/stories/index.story.tsx new file mode 100644 index 00000000000000..91131e0c88aab0 --- /dev/null +++ b/packages/components/src/external-link/stories/index.story.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import ExternalLink from '..'; + +const meta: Meta< typeof ExternalLink > = { + component: ExternalLink, + title: 'Components/ExternalLink', + argTypes: { + children: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ExternalLink > = ( { ...args } ) => { + return <ExternalLink { ...args } />; +}; + +export const Default: StoryFn< typeof ExternalLink > = Template.bind( {} ); +Default.args = { + children: 'WordPress', + href: 'https://wordpress.org', +}; diff --git a/packages/components/src/external-link/stories/index.tsx b/packages/components/src/external-link/stories/index.tsx deleted file mode 100644 index f782d962f3740f..00000000000000 --- a/packages/components/src/external-link/stories/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import ExternalLink from '..'; - -const meta: ComponentMeta< typeof ExternalLink > = { - component: ExternalLink, - title: 'Components/ExternalLink', - argTypes: { - children: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ExternalLink > = ( { ...args } ) => { - return <ExternalLink { ...args } />; -}; - -export const Default: ComponentStory< typeof ExternalLink > = Template.bind( - {} -); -Default.args = { - children: 'WordPress', - href: 'https://wordpress.org', -}; diff --git a/packages/components/src/flex/flex-block/component.tsx b/packages/components/src/flex/flex-block/component.tsx index f732bbe64896cf..4d01f16e3abd94 100644 --- a/packages/components/src/flex/flex-block/component.tsx +++ b/packages/components/src/flex/flex-block/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; import type { FlexBlockProps } from '../types'; import { useFlexBlock } from './hook'; diff --git a/packages/components/src/flex/flex-block/hook.ts b/packages/components/src/flex/flex-block/hook.ts index b9026e64f7c46b..1c0497ac56d0fd 100644 --- a/packages/components/src/flex/flex-block/hook.ts +++ b/packages/components/src/flex/flex-block/hook.ts @@ -1,7 +1,8 @@ /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useFlexItem } from '../flex-item'; import type { FlexBlockProps } from '../types'; diff --git a/packages/components/src/flex/flex-item/component.tsx b/packages/components/src/flex/flex-item/component.tsx index e4073a4b400f40..446e2b94839705 100644 --- a/packages/components/src/flex/flex-item/component.tsx +++ b/packages/components/src/flex/flex-item/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; import { useFlexItem } from './hook'; import type { FlexItemProps } from '../types'; diff --git a/packages/components/src/flex/flex-item/hook.ts b/packages/components/src/flex/flex-item/hook.ts index 6a7b259560d1f3..db130f0b62aa0a 100644 --- a/packages/components/src/flex/flex-item/hook.ts +++ b/packages/components/src/flex/flex-item/hook.ts @@ -1,12 +1,14 @@ /** * External dependencies */ -import { css, SerializedStyles } from '@emotion/react'; +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useFlexContext } from '../context'; import * as styles from '../styles'; import { useCx } from '../../utils/hooks/use-cx'; diff --git a/packages/components/src/flex/flex/component.tsx b/packages/components/src/flex/flex/component.tsx index 4302e9b16c7ada..8fce9ea144c704 100644 --- a/packages/components/src/flex/flex/component.tsx +++ b/packages/components/src/flex/flex/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useFlex } from './hook'; import { FlexContext } from './../context'; import { View } from '../../view'; diff --git a/packages/components/src/flex/flex/hook.ts b/packages/components/src/flex/flex/hook.ts index 552a4d99d295af..6ac032f75dc4c1 100644 --- a/packages/components/src/flex/flex/hook.ts +++ b/packages/components/src/flex/flex/hook.ts @@ -12,7 +12,8 @@ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useResponsiveValue } from '../../ui/utils/use-responsive-value'; import { space } from '../../ui/utils/space'; import * as styles from '../styles'; diff --git a/packages/components/src/flex/stories/index.story.tsx b/packages/components/src/flex/stories/index.story.tsx new file mode 100644 index 00000000000000..142f2796657219 --- /dev/null +++ b/packages/components/src/flex/stories/index.story.tsx @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Flex, FlexItem, FlexBlock } from '../'; +import { View } from '../../view'; + +const meta: Meta< typeof Flex > = { + component: Flex, + title: 'Components/Flex', + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { FlexBlock, FlexItem }, + argTypes: { + align: { control: { type: 'text' } }, + as: { control: { type: 'text' } }, + children: { control: { type: null } }, + gap: { control: { type: 'text' } }, + justify: { control: { type: 'text' } }, + // Disabled isReversed because it's deprecated. + isReversed: { + table: { + disable: true, + }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const GrayBox = ( { children }: { children: string } ) => ( + <View style={ { backgroundColor: '#eee', padding: 10 } }>{ children }</View> +); + +export const Default: StoryFn< typeof Flex > = ( { ...args } ) => { + return ( + <Flex { ...args }> + <FlexItem> + <GrayBox>Item 1</GrayBox> + </FlexItem> + <FlexItem> + <GrayBox>Item 2</GrayBox> + </FlexItem> + <FlexItem> + <GrayBox>Item 3</GrayBox> + </FlexItem> + </Flex> + ); +}; +Default.args = {}; + +export const ResponsiveDirection: StoryFn< typeof Flex > = ( { ...args } ) => { + return ( + <Flex { ...args }> + <FlexItem> + <GrayBox>Item 1</GrayBox> + </FlexItem> + <FlexBlock> + <GrayBox>Item 2</GrayBox> + </FlexBlock> + <FlexItem> + <GrayBox>Item 3</GrayBox> + </FlexItem> + <FlexItem> + <GrayBox>Item 4</GrayBox> + </FlexItem> + </Flex> + ); +}; +ResponsiveDirection.args = { + direction: [ 'column', 'row' ], +}; diff --git a/packages/components/src/flex/stories/index.tsx b/packages/components/src/flex/stories/index.tsx deleted file mode 100644 index 0b74ac783a68eb..00000000000000 --- a/packages/components/src/flex/stories/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Flex, FlexItem, FlexBlock } from '../'; -import { View } from '../../view'; - -const meta: ComponentMeta< typeof Flex > = { - component: Flex, - title: 'Components/Flex', - subcomponents: { FlexBlock, FlexItem }, - argTypes: { - align: { control: { type: 'text' } }, - as: { control: { type: 'text' } }, - children: { control: { type: null } }, - gap: { control: { type: 'text' } }, - justify: { control: { type: 'text' } }, - // Disabled isReversed because it's deprecated. - isReversed: { - table: { - disable: true, - }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const GrayBox = ( { children }: { children: string } ) => ( - <View style={ { backgroundColor: '#eee', padding: 10 } }>{ children }</View> -); - -export const Default: ComponentStory< typeof Flex > = ( { ...args } ) => { - return ( - <Flex { ...args }> - <FlexItem> - <GrayBox>Item 1</GrayBox> - </FlexItem> - <FlexItem> - <GrayBox>Item 2</GrayBox> - </FlexItem> - <FlexItem> - <GrayBox>Item 3</GrayBox> - </FlexItem> - </Flex> - ); -}; -Default.args = {}; - -export const ResponsiveDirection: ComponentStory< typeof Flex > = ( { - ...args -} ) => { - return ( - <Flex { ...args }> - <FlexItem> - <GrayBox>Item 1</GrayBox> - </FlexItem> - <FlexBlock> - <GrayBox>Item 2</GrayBox> - </FlexBlock> - <FlexItem> - <GrayBox>Item 3</GrayBox> - </FlexItem> - <FlexItem> - <GrayBox>Item 4</GrayBox> - </FlexItem> - </Flex> - ); -}; -ResponsiveDirection.args = { - direction: [ 'column', 'row' ], -}; diff --git a/packages/components/src/focal-point-picker/controls.tsx b/packages/components/src/focal-point-picker/controls.tsx index 3e6d33011da730..f204d5736779cb 100644 --- a/packages/components/src/focal-point-picker/controls.tsx +++ b/packages/components/src/focal-point-picker/controls.tsx @@ -54,6 +54,7 @@ export default function FocalPointPickerControls( { > <FocalPointUnitControl label={ __( 'Left' ) } + aria-label={ __( 'Focal point left position' ) } value={ [ valueX, '%' ].join( '' ) } onChange={ ( ( next ) => @@ -66,6 +67,7 @@ export default function FocalPointPickerControls( { /> <FocalPointUnitControl label={ __( 'Top' ) } + aria-label={ __( 'Focal point top position' ) } value={ [ valueY, '%' ].join( '' ) } onChange={ ( ( next ) => diff --git a/packages/components/src/focal-point-picker/index.native.js b/packages/components/src/focal-point-picker/index.native.js index 127f66ac65ab72..834c962eae7b1c 100644 --- a/packages/components/src/focal-point-picker/index.native.js +++ b/packages/components/src/focal-point-picker/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { Animated, PanResponder, View } from 'react-native'; +import { Animated, PanResponder, StyleSheet, View } from 'react-native'; import Video from 'react-native-video'; /** @@ -164,7 +164,7 @@ function FocalPointPicker( props ) { }, ]; const FOCAL_POINT_SIZE = 50; - const focalPointStyles = [ + const focalPointStyles = StyleSheet.flatten( [ styles.focalPoint, { height: FOCAL_POINT_SIZE, @@ -172,7 +172,7 @@ function FocalPointPicker( props ) { marginTop: -( FOCAL_POINT_SIZE / 2 ), width: FOCAL_POINT_SIZE, }, - ]; + ] ); const onTooltipPress = () => setTooltipVisible( false ); const onMediaLayout = ( event ) => { @@ -243,9 +243,10 @@ function FocalPointPicker( props ) { yOffset={ -( FOCAL_POINT_SIZE / 2 ) } /> <FocalPoint - height={ styles.focalPoint?.height } + height={ focalPointStyles.height } style={ focalPointStyles } - width={ styles.focalPoint?.width } + testID="focal-point-picker-handle" + width={ focalPointStyles.width } /> </Animated.View> ) } diff --git a/packages/components/src/focal-point-picker/stories/index.story.tsx b/packages/components/src/focal-point-picker/stories/index.story.tsx new file mode 100644 index 00000000000000..ce93d3557060c9 --- /dev/null +++ b/packages/components/src/focal-point-picker/stories/index.story.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +/** + * Internal dependencies + */ +import FocalPointPicker from '..'; + +const meta: Meta< typeof FocalPointPicker > = { + title: 'Components/FocalPointPicker', + component: FocalPointPicker, + argTypes: { + help: { control: 'text' }, + label: { control: 'text' }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof FocalPointPicker > = ( { + onChange, + ...props +} ) => { + const [ focalPoint, setFocalPoint ] = useState( { + x: 0.5, + y: 0.5, + } ); + + return ( + <FocalPointPicker + { ...props } + value={ focalPoint } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setFocalPoint( ...changeArgs ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); + +export const Image = Template.bind( {} ); +Image.args = { + ...Default.args, + url: 'https://i0.wp.com/themes.svn.wordpress.org/twentytwenty/1.3/screenshot.png?w=572&strip=al', +}; + +export const Video = Template.bind( {} ); +Video.args = { + ...Default.args, + url: 'data:video/mp4;base64,AAAAIGZ0eXBtcDQyAAACAG1wNDJpc28yYXZjMW1wNDEAAAScbW9vdgAAAGxtdmhkAAAAAN7yaaTe8mmkAAAD6AAAAzAAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAA7l0cmFrAAAAXHRraGQAAAAD3vJppN7yaaQAAAABAAAAAAAAAzAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAUAAAADwAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAMwAAAXcAABAAAAAAMxbWRpYQAAACBtZGhkAAAAAN7yaaTe8mmkAAFfkAABHqFVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAC3G1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAApxzdGJsAAAA0HN0c2QAAAAAAAAAAQAAAMBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAUAA8ABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAM2F2Y0MBTUAo/+EAG2dNQCjsoKD9gLUGAQalAAADAAEAAr8gDxgxlgEABWjq4TLIAAAAE2NvbHJuY2x4AAYAAQAGAAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAApRuAAKUbgAAABhzdHRzAAAAAAAAAAEAAAAYAAALuAAAABRzdHNzAAAAAAAAAAEAAAABAAAAJHNkdHAAAAAAIBAQGBgQEBgYEBAYGBAQGBgQEBgYEBAYAAAA0GN0dHMAAAAAAAAAGAAAAAEAABdwAAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAABdwAAAAAQAAKIkAAAABAAALuAAAABxzdHNjAAAAAAAAAAEAAAABAAAAGAAAAAEAAAB0c3RzegAAAAAAAAAAAAAAGAAADx0AAAUwAAADvQAAADgAAAA3AAAEuQAAA3IAAAApAAAAPAAABZ8AAANFAAAANAAAADoAAATdAAAE3gAAAC8AAAA2AAAEXgAAA1sAAAAlAAAANQAABU0AAAAfAAAAEQAAABRzdGNvAAAAAAAAAAEAAATMAAAAb3VkdGEAAABnbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAA6aWxzdAAAADKpdG9vAAAAKmRhdGEAAAABAAAAAEhhbmRCcmFrZSAxLjUuMSAyMDIyMDExMDAwAAAACGZyZWUAAEITbWRhdAAAAvQGBf//8NxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjQgcjMwNjUgYWUwM2Q5MiAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjEgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0yIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDE6MHgxMTEgbWU9aGV4IHN1Ym1lPTYgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTAgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2FkYXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0xIGtleWludD0zMDAga2V5aW50X21pbj0zMCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTMwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjIuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgdmJ2X21heHJhdGU9MjAwMDAgdmJ2X2J1ZnNpemU9MjUwMDAgY3JmX21heD0wLjAgbmFsX2hyZD1ub25lIGZpbGxlcj0wIGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAAwhZYiEAf/zA2GNkKfOFSsuTVloQEaXdzoHD7Vtw/suEginCwuvVdLAAIC4a8RRwZWlYBuDUCSPfB3B5lW9X50lraB4kEgmym6mAAImPPAiIv5IzQUREXNTlXLGDbZ6pr7DtSUl9HZ8/7xMuDpvacJMwO2ZrQABPwobl+UNw97XDlJhhlm1a9jM3/P/K1jvpDw63Bu9E5f4cavT3elDFhSIT6FQ+iTN+13wY9LeL+T20YNckYyNifbav1oJZN6ec1X+ai3nodpai1j2QnWNfq3lO8tGXVbG1DPN4S7Xm8qs0ba7GbbB3eagRurMx0TWeIRfxVmKgXizipPdX4zTTlG4C0U0+eeEcjWzes5UzxKEhLcaJRJ6P5LU4p24Puq9xTLEB1EjbhpdbrclA2Z8rSfiQxXPW+9hvNkCWMDRvjKpUVzTQP+HgnD3Th39tikUJZJYer5OCbzdwPK7Q/f47QynLISBoW5VZXmWwIOZG4T7KAYVOKrQuU0wW3bhUY4SPvo979r3FTfcf4MxGr2LR5+2BDLaI7EntpwCI1zj9Lk5WFGY+Rlb++hMvZ4DSJKzv6fE0ROmmXsJ0U1LPjkHe7bastf5R3a+x2QHuUSIxWmO7wnVWVn6mkvjU6BQUelrRTDKrwOGPrn0lNrZCLZE1nQXB5TxM82EX+hv1YG3eS5tSoQpWaLkvvnoMBxGt1bBo42UKVKmpzSfPfTapexYbjW0pQB+BfazvlIsbkWoUgdIPsmpq7+ca7FT+iIeD+TNU9TvEmvHyV1MKYax64hPCtNO34u5vwjlu5P2O2d0nL5mg++r4+Gl9ybNKGPWGXK/L5HjojDKSZBdCcv8rejatxmJxlUI9JdvZy0kAjLislEn8mWpf1I0ZXkeb+HuOWFMjYcJy1WwwEIN04jzhejmHpqdzduXUqMyzSKrbIX5zc0EcicHQpoGg0Tlf5yOfTKX3sqZgXsLUobfqUz8Xj5HdyWq2iW3MLytOMm/8Rbyb+RaVyWZQL6yBQcJNRFIvdkTT7K1N6amktfFiPJKaKpBaE07Y5sytd2C3KSZnvlrr2AZ2s/6u2HsMCRRr+qDzywVpCkXW3EWrdKb+3Sq7yh+lopglZGRb+BKkSWihgi00ZRL+F3mwUNV5VPibODPvr//skhJk7LLXI7r8/JEz4lnQqpwZJu8CpeJzFX4dlytL8bpoRhilI9Nwu3fkyJGu4d/esGQfXvg+OLzf63xMyu6HMwey7Z9aA21FHYFH+3rIum72Mu22IXYFSXVq1eN0yTUsMT3/lnrTuWt8zh9INPUDUgUi/uPuvnhjDr/MAO5Tb7h3nsfQy6WDWhHcRZBQQkLd7M1tZdEeWw+jz1ckNjcnCe5o3o6Ucd+Tb9uZKyvlzyzVkx+PZRFCAdQrKLXv2OGHCUR9eLFWNHogm2XZU/39A462LM0PkeM6Wn2Aurjpoxq9V4uzC7NW0P0f+LrypIunB8Cy8VYYy0i4eLJhjtGqXHLazJh6BxDRysBIxpqDp16Hm+sT8+LnPrH0yAl2TXf1KmkLnP/+2193RxJJ9roy85d96K9RZTG76ZxUFPGgmGILrhrY4maFWhGq9guBeokoIkUAUn9yhzTHr88D5MSvGlesrpy7CJI1vKwJPFX56c25ScAdNOXEB0aASly1xpzslZ65oQJrP5lh1OSgAzMaCzrWleHUOxmpcwMeFYys3lEgOtwQgBmoKvGQ6ld0BLI5JJPQrZTAs6M6cU9WxiOHiqSraNLEooqeepDNmg9AjKVKCUTh1pc26JOdg7YhrKqdc4jyq0YqUrguZkTo7pt2mHdWExgEfHshuEUvL2J0hW6y4wC1tfEdJoKIPnySv6peQMYGAD50HGsg6lix+bfYb+wYj1RQrDZyPMrib47nNjVSQq7feOrqdY5PX1ey7AzW0wFOg1ksN9uRszBUb1JELEEFBqTIDQz3DH0JALQ4WhunD7cpmlPuoaWLNwVztHeWLerXLE74iuziBx/SMZgzQOFMJ/XFrjWw8rmaJSIiA9uRyhaW9YDJ/smWG8Uf+7cRtpTu6ao/3Lh2sQQdWzQIRLONgxLzDlVyOavM/kDa5da0hknWv3PlLILv7vGQ+Zs4UktjycwP6duVkN/u1KQTf8X0HXCAPvq07AswtBQyNRVuWGdvKv9qVwzF0C8QueA69lL9j/dn5vVYW+qbUyF9z/ilUPHVl9LzV+Y4n+QGnxyVMFvOit87NtZphn+L4ZYLMztPRYIZHWmJHx59Ek8cThU/RyDEU2ZQ9GuXH/tAA0b0CqP0Opvb3iqgzeNDcvz7C6W4wUgm2G3vM0KdmNgADxF4iTE0cJswo2exOMt/uRYFORat+ffXU+4pXpPr0cZeBO5qHqk/w+21EctDB0Ky9PZSyT2L/CdLQC+6GIbRo871nJkDZId5POS8t8qf25O3DeUze17pUYvtK1rcYNGGv9kr5Noh/cvVxlZfZk+hAJZEsJqN+k6xG2469xaQq8wEKL6yev5Eiq+DzSDyO3xk9IL4UDosFTqgv+nLqSEQZf42/1vlI6r7PLBWiw7Grg2ITSfVRZT9OhEQCEEIrfA0BXFIjtJ9e9pDZZhpF6qIhJ+EZtB2UOCWU4zxwt0pDLkX8+qEaefg0s8XkxDrCbUInNY5+MS97YMrbAZDo11HpMbD6spXTj/sGEJEVvBhVJYZxT34+E8RzPjbn5PgSrUpM1lQZ+hbeJ4PV+kiDwC60BVriYI4xyd/DlHSe0CwGooOSUYzLb2x6ScpvbuGmkZ+FS48S/RL6BdZcC+tw9YJuj3k+ZRzQEygvLJGn/Ff3g35Mu5OniFh8iBs7klOQBR+DcH5LndXN+Mw4OcrCyfC3qOUXyUEB4KAMAF6x48I4Q3G2+/asthOmO/Lfue5ELh8fPTOeWGcNEEHPeQbacOs7YSHiT8eBfzXxUdYHjI5/RMqG7UJaIVf6C+DHGX1XF7AUtj4MaTJDu+AN20uk9Yokv8eeI4sc9v/+dc0z3GOsyu34kL9qFhGzPhC9a3Eki2vdkvrPNhgUsZmvjbhGGXHWFhPcywW+dUfpTJ8flX7/pnDj8t/1z4/VjkTqN13n+OYmQXKw2wfiDjT3WxFjNvkMywveho0ya3lNGmZ/7XQ4j2wCM53wBd2Vd4aLeXHhulxu1+aCxhe0/0VfjuGRERmY78odPjleOb03ulA+BzFmPC0D/OT91+LDXwicxGW4FYNpdddyyG+Jb250hXUK7mb/kpnLfQLcQ1E0lB/Nh7croXi6FF8uBMlwigktv6jntMUVx0aU7cpR1wIXDR17VCRiuejAbn8v4rVvJvyrI6xLUetS0vdq5L5FVtxcrb41eisp1gTXjO8u9QAXVPjS8r9cNE5yIHvCM6AjWLy5oBTGkoQ/BrzuTkdM8y8GZq8Q1Nd7wBbj407htDYJknbg32SWpCnZNkpNXVL2Nt4fU+kXZEqtJJa6zJTLk+VhXWT+s8oW4YfUESvkoB5mUaU18P7xPA4V5P1V3na4yyXzrLdWAHcaZBQnddBFad2AYUcoTEeYYckXoc9R+bCRQSo5rSNtVUj2jUnT/2YxLKRo3wSiAZGfDnhE0sZ2UaE2+W+LdifOHvwJ0j43ZdeSdtG6aod16qez0JLDvWR2hLjukO3yLRhr4uFN8ebV6vnZztxLCZVcPmax7UfjQ67AWxjcFlyMB/Rm800Oe++ft2Gxngrejdnt0lY6/MpSEIOl24LUp8YXgdR0tsNo0TqBQII0KiU6k8UQCat4MLmbGWKdLzMtZXfzS59+DuVImjG3UGOSrEu7VCPjAMIe3Z+WDNz5nIEaxwQrMtGhqv9oqhNOCG751fxfXb8zNKOC5r96Ti8zqm5P7O/G58xp7F+bbECR+Lf/IUXlxTdt6dyVsP8L9qfonB/9HuWIN5TInSOYkVF3CnRDe92FJTx7URcvk7ABg/akjc2++SqNFEW5tH1igCo4JWWqItaishaksBJnVHYO7xSWre29ik20xixB/j9IQBiYEJnn5UbtZAzUOdCLsbdjH1+aSuRIofXvLynenW4vT4/PpW+jJidXV1fX48ZklOyXf8FXn5UImACwO4rEtlMUZq9eJdO0VAt2w10t/+7PDtAO2wInrOo4V18nju21flg7HyhtrBAAAFLEGaJGxG//yk5V/bYk6AL69BRgDUlzKmvMlnKdcvSzcDMpFz33VkzeYT/fXe72/sEqXtalkzOHcwbKeaUFZK+YZJr4dCu/+aXe1udMCC4PwXR+kHnTiy1yoQ+xKIMjp/3uOW+M9hVvZPaOLpBEMM7WaqU9nGX4hJzj0EKQ2eQdjElT9GIqQDmoXuZ8jssWV+Ao/LqUYAK8ogdNsSMBSpxfEIY64qDQpN/1QnrST4ay5jwsBFt0PB7H6j/ep0pZlZB93ML9g4Oie/bv+cewzgXSL3iKsXDgY8Qc28yicYWQtI9ykEJgb+/Clj+3R13r2jNZtdAqUDrhn4P2qe1oHVddY2vdEKoukaDI1rza7Dh6efOEtd+FPLm49xxlf/uU3wqiN1//r00ibfbGE5dltNN7pdmaxEtpyaTdCCxbJ6u270FuCV5fnsYALRBgq9lLo1L4AwEQHg1uzdZgcIFUyw5pNvFtkfHO2cTagF/RrNYTXr8yobWkmprqgydk6Jn28cT8R6E5JQh8Q8VMCPJt91GBgoYk6DaTKVQ+GR8AQiofW6w2qgI6A69Fny3397qCEg9iEZpDO2N5BpALpZZmzAyaONjw/3Sz07iltmDs45X52rSlQXgoSFKkPg8vAzUgcJRmCDnK4tvr5yv2UXFRflRtnJeUasWD9P9UP7dEujcD237dOA9DDH2ClH2wyT5WmhZRNYbpEoZzHUf5SYG5bGZTBFdXoHCWZkqjMg8bOy7D8EZO+yRNXY9ZuadAQbFCR8uKfmVyAqrdUiATepgc/a58BCpkcQczP8nqIbjS5RfP4/hVWY8RAuuNOvtMmfg3o1et1cCT+pYI17EHgXr59vNEVIE78s6Jwi3uUjPHrnCvbPqrC2GBtyc/HG/epri8XwcPf1cDY3jcA6VwkIx6o/ZmEQ/mAlRIO9ScmJuOX/n6Z6yp97DFOcrLFTHgoL2eMP+V/GOU3TIuyzwj9/zHqzC+S42Tgnm1kfzAeKwuPOgpKIkOS9/SYxsTVHYqmKGkGBZi77VKp7BMJmIIOQe4fVYEOZ5YYAjR+QKduA+ReJMHhh7lDdpcRkiSIOJaYhnWQjwz4r+Mn8qQ2ZlA6B2k9OWV5A6Pzy8cKFWYZvMdWtCuFsFkAlBBY/hSVDlBvBabEJvqxOuls3KXxDSszejfPJOMkYIDbKmK74SmNMbFGYE6AHx8EWP1rTofvGf59sGbYZjoye+kyDH4q2lFuKwzdqX3BvADvwS/JqNiax4McmKbISxuQJacCRkD4Cqoi4FnWFKqA3xgbtwQSrlTV7Hw1beEvfU5x6Bt9e1LIn6DvtIBjwkM8v2hQjq6cWgVThoxwED4TviYxSmNLM9sNrBIQGzR7vzgBlRg+g6cT2fChebuOUfIQOvOW5jwHJWpZN5ddtgvsMmj7ZYUrITDCwY1CgCS8KijSN/Q4aUkGED3bgLe7GKFq/c8oPAgps3jwSuiRWlV7MwXAgpMfowGKM8n67EZMpylSYuApCH2xoOdHePAjYFHHM9hvTMV0Jv+5Q31C1h/UuQyXU1MyduXg9Mlx2uOpBVzP1Qc/hmlsZoHMGysUZ5feYp846DLu/X5xW+9jPexhJ9hLwiVWNHNFfmp5/kaKE7WC+5E9/6OAz/DuNLPOuyvBwJ9JySD8ov9JsK+maBiT7IzJrsaEKzzYS7erYult/p/SyasHUTbJ4BgQmH64Qb2Ydco02tZzGSrAAkZbvGasnvYee+5NkFbnlB1C5xHU7LcQ6t7IRKj4NVIAAAAO5QZ5CeL9DUtN8A/bTgCkRRrBvGKTL6Qq/3/0bv5L58FnEKi7CJwY7YYYePSK08qJjZtspBBfw3JQsZy8UYsAV7u+GxHzRLbeZNw13bJAT7nVcR83GmWeCzDwUEk9elfT45KJyhT/b0x27CWARXNNIcYUzEU2F9U5GsiiQ4clH1kFGf5dI7JVzQaZk5jhoTZY3LK3DOeKtm7Hh/BaHANbEz8ZdztVOrVDwB7QwsmkMoEUIKdX+wDzZWK6JKdEQynr1Rqc+qxGg/PaN5luC9yjEjCMzsYFhEahpiAADL67e6F0WBZSmvI3XZC/nF7iJj5G5ocWFY06hI+W4wQ89KkpOGcUASWRU2mGvbHq12VA+xAF3vVecHZVXmw/Qbu+J+uG1DXY7ktD8hKK1NQ9rShO4r+wlALcacAk2mPoC9KBTKYp31HnpJzJEQlwsBe0cFxBBnEPiW5HOky4eTdy6PruUkSsu3GExXxIf7frY/VqL5pa7RVXry4pdJNtirHiWCC+PdZiTvlX1m/aT2nyRHYEDKAbph896AsYOn+YnHVeENt580uPY91s0uM3vrB74BAq+PtChvY5/oCXngPquoWZ6SkTp3DHIZG+UHI3kRd5FZxg5cKzNuSLACxwL24xPMh9XzJH2z1Et6VzrfZrXQi1conE/TQlhjSd29S01Wc6EUxLXI6IkZToc5yVqNq5yM0DiXt9Os7/dXB/yrH/7+PWjgABHPnEZCJbQCeOjBXIX8egoyaSCKx2Q3AxEzDPsiGN5CpE3mQ+OKHmqpfnzROARN5Yt3LWNwLh+nlXUqKPWTGvfCWxMBzliCdowzMHziIhra4IeDF6cYjId+4/bh+M7aPWauIuKv42h58mkgTeOVYNlx8t2NJO2/1+9D7RMPsfkC4IBz7ZNdI7wws8T4+i8koCAFEukHGCOUeFl0bogUH1cRwuD34m3w4fdawaJU62HHYAbqmsln+7qoyFm/hNlmyVCP90iAhHsjfTMBPJVCcE+mwP7csDuuROcalLfAaMgFywed7ViQpJVfKJJv6PyXzCgWBt6GLHZJ9olmCzS7zykg/5Cueol7nHk0C1u3MwvPv1F6u2RzGlh5WSMATXnkILY+BeLcXTtK6g66EtLk6ZQeDX5PTpm7iMkcfmLN0Twjbn+cXouJ4Mfg4fgisobiGHMWQjY7KGUYMwCi7vmkVStoFQtSDaQqgEYP4A0Aguxk6D+OoRxNFI63oRXeZaTEFuH5HrKdeqW9UEd7TzFMhfudhMM34rXb98AAAA0AZ5hdH8jvpdGROI7bSMKAAADACMj2cV31f7qpkPemBOJNdf3vf36M6gN3zC4osWpqtDXeQAAADMBnmNH/0N1x6D8rbCTqAEImIXY/C6nT/gzHYvs+x7Ifmu0wbVuh7CyJslawMgBgP59Mb8AAAS1QZpoNKTBG//5vW8AXlZAzzbvQt6LccWVmbfXW5TOMS+Sa75eLgodS2aJ2PdBLFmx+Nl+iAxUrh+aH82vePF2kbxo3nx865/9LDGPSQab7QKOjp0GwGc6cPYijS6t8zNsZszcyLxZQU0HHGl2AWf1lX6aL2R6nsSFqa2Bac7e9NGJllBJjMr5mTh7kxVgXVaWf21lMkImZiY+JWbblMlqOMviNnZaO5hjOsFRAHrGqLhpdBz1JeXt6V9QFH0M/7O1o3h60TuF8Sr3R9ru7CZzQeQv/6qVoZ6uGAVPsm13kEbOlAvTjZpkYLL9krnU9a618CVK7xOUjEcQUeGxPEIT28zF9X1ewY5S+FpYHpUE03tbv9/G9Sziccrpj/YpskkdXC3OvrqfNTkAOH/Oe4YlcmOMQAw/v4u7jyIPDgSIn3dvl01QmBrm6VjGIi26w8zlaBCMl+DV4QpuPT0Gyhc3y0YZPBjIl1nP3Odik8EaE1rE5SMbTjAUYgVQa3+TF6yajDxqGNiWM9oo9HXh7x0N91HPXB6TzQak1rvRlWbYBT5S8HltVYdKqEUqrtwV5SgXfMIbJUKdAKLHvT4m4HdqWM92104+vFIIPtWT4COFVF5U8rtpJa8Huh0/HGqzvUTrRH6Q7szTn7b4vmMPDNRTNtyh+NUTGGBpE88FKCj1EWU5gSDHq4SvUrnk/hq7zQwJc3gSuyJADFgrPQ93e300gpzKotyofAuqXm38uY36/9Db2KRcC7U0TRdaVgi5rwdNfOlTzI6Kf/xiMs5rPOjtSS62OtnwvRAQtdIHQc/DPF9obIF6eIidsY0OUkp2gNjzIv06P3Au5C+9VlnEpwTeDlS1wywNSNR613xqSxUi45cFRB/MXk8We8QjIrhrrhKrIQQrMidMrN621RaZHielvsE5MGJIbSmpCwhg/mTyDdbhiXQahZbhAicynmqK66U0pyCcfy1ZxNYRErWt/R2KJWwnpxqenjYbbxrjyn3z3gg5ep/F7pR2b6X0u8JJRJohwLEZgs1Hd5htI3jjThXgLzesg5c1yDlo0nF6Nv0aBESNQ1k8GH3HFsEQ4av90nOpFyqyV0USDP1vSNagRzH4hSdCq9umKD8pITZtqByw2MYEJY5OKnzDd8uC/fe+jhhwGwDIDhCLkGSlalM+t5Wz26M9C+3Iu8D+Wjo/VIVNtOcnW0MVfoiCBV+FEwYueusOYDi8ecHp3xaN1FfFVeo/XKpUQmDcUadEiV0YUAh8Tr9hmpRWkGY7YjkT73zNMg+PkIzrFWFuIX5Ijs/4pzz4NbYKGEfsnbIDrUK6cOMonm/F6OMILnIX27jsarrFj+1DCk2ct88mqeBUhHAJy5XNrlXi3uOFCqUFnfV/UQjRICOgsWJgxqHGnCDQFpLtZRD5ZAdeWB4bSFTRH7pWhZAQpeRkzhLsWpPIoXHd8n1w7JLIssRnKaOA3vJn3cib8PNGUM+Id8foqBjALDYGlDVVD0/gwrk3Km3W0mQoCPqxNVCwaXnf1dOoQHFE8zQcXE+UcLGcm1zND/IsWMGI9TTBI6RzGEsIqzewpdrBM6lZWy8G7rKDQJe5m1+avNqi/2Ze10sfwcEAAANuQZ6GRREt/4drPKDmjD9SiP4GCKASnBhaH/4fPqahr+tNx8A3QJr6gDLqNCrU2q7Xzk0n6Le8xKgjYPjFtPACl9K7Ck9S3sqDyq0e16eLDlMIA3/YJxjCa8NlzJH5EE41PD9bW9VuA+iSHP7X44imUUfrm+K5wdCD5/qf07n7OnFgycrEiH0iCFv8314/5VRGLxoThSbAIV+ROFRdqBmO5kTdVbGCz7AtagWcuKpJYX1ZMDg+5FA2L06zSb8aLMiVmJTan5JssqxZHhqimr8xInMPwQkLErnNFHmMihKJy13HHS+6P4bdfQb7NWVUmEDEjC+lv7lpCVQ9TKG11yy+7eImtHTMKUCeDCvdpHhR/9ByAFWWbLqGhL53SbIvF9uITp5eoeAunyEIPaiE+f8nelSJzWSHUMiJ/nlsT11A7uTV2AtGbUZXyLhoUOo+beBYkqY/4XOVTXlatxjT8nVeZqwgDmy6H8iHeWctIeHPwthDz0Y8lOeDLqLrm/WM3NzbcaLw+SdOvMqzAbIBiRNG/OSqGnSzH4ymks6B4Ox7+RQTMJF+YUrz92144wveNJ+m7DDJcA2Ef+fL5eoK9Grdax5Uu+A3AccruVvICHB6b1jDAqDaU13yeoJ5GMeZpus6/RSeZJSjilUPykAVMvf1pwuixlZmts7+tBRYu5me23mvLPIVbLkX99mon1xnSLV2xE7pTubTikkMPzHHvD70vgR6KpTvu2J8xOZYstmTXHZC4v5LKgPu8mxqNfPSI0Crqulu9YFUNoS4Oqz8Gf2K/Nim8Bmqy4bpwOuFAfztRd++98f5zsbOA2liloQ7QvjwnG0Ugg+WbcwaCNF1EV/qjx//8IkvCvMy8irP0ck7C+mOsijbtTiU240X6Wvf4z3td34UrKx//9D072H+7vdTVn2GTupbSrFbD/OnZm9a2t+ED814PoiiIkZGzsn7rn7viElX01XaXEbVeQQ9W55VCAjvD6MVJ93AhFq5QErrXshPA8JvNekBNrXCnDXPrV+vmwSo5nMsssCSDA1rpUltz3yb1SmOipuSenvPxKoGII4hOV5mUva/kgyLYkfKKt1m+86Av7Vd0C4Kt2QVnHuXvpDefcUCI94gFT0MTxcdcLdo9n/l0wC8mHVI+8uz27Yxw9E5LrFqtWvEWZwvqVEAAAAlAZ6ldH9bMAXa2Fzy2uy5AAADAAAKZqjGMW5qDruI3pQ4rL2czQAAADgBnqdH/4LjU0waEknlET3d4qfUTHiOfuB/zYJFbl0E0ddeRHiNBBVsCJI+qipEq4wifvaehZYh7gAABZtBmqw0pMEX//lvAo5ICKiMCnWCbjZk0ARFeHd/8wpo4T/Er/7PtbIWW9SKqpretF9SLZao2mKHZJl6CSw0K+jJxtnXwZUt5/OyqqiR095Ajt2YqgJoX2pt3S5K2R1/lCurb8Dsd7N06ZeyfMh9lbsBSYym6srOBxAeVcpScHXHW/LWo6nYJg48sNcSIL3y2fcm25IEh3Nh8AJxLvt8FQGR3R2+dRMT8AJVNGyazFibo+vxZdMe8sSsrnxen2jr2CRdcxjmYuEt2gjiSX5VEZ5f6K5P7zlwTzYrkV1nKw7QPyVrwnupQISZcW+KgDk7lFhi1YszBsmC37X4lDWEXTwRugEAH+HpGWeW30P9VLLfLK2ogQ9WN0PagtN0sdh70Bz4zcnEU74hTKoe9bwcW3JbTTOnLA4xUcYWtbbGTwmYzFwiksGOgqFsKM0vebaCAJEgktRJyYxiFMgIUuAsFTcdy8V/aB9qw1yBM8t8UxTuaini6qf4oye30SseUwDlQFd2IwYMTbVF1lNZyWyWcnm4c9oHzta+OPcPBFN4zbG6aPdoYJFpguPf/dBAo9E8sIdfitBGHgN3u8TVqjj52XkyfvWUF2LoYifD+e2Pi7PH6QJ0vgaBBj0ErIMoNNyexJV4QPcBO/V7BU1O4a8b1NU/Uq5hZqkqbRtt/hmxE9/8tnLzffPgZ46q31Ln76tvmVdTCB8zG0NMRkXYlBHC9Jy7H9YA777reCZX/gSuHrDVU4jyAD3kvVITXa2Wks5O0qst/9qfjt1KkhwaCfvF1Ei7YQMvvJ8vnTt+Bhbfv5H29dReRd+J/RrIyMu8bKRUHe9GKoyqgpv+v1LYQn4enbuu0xpl8Da+d7LFi4J3NwiovnrgWKYzR7Pfy1LX/QiOpw9KSJNN/IuqWH1HG4A9jVRxIoMl0gk2v2cT6c4PxCTrbTNVrH9uMlwSc6tqes0c9YArHWeMloFSQV9tEHKYniTHq+3+lGQ7pTPpeBPicPJSHnx4s70sKZaKr5Tt2pwDUZvJWF4PADt5a5HLOluWKbi2k5t8IP5wG3FnUYc2LVCxKwQ4dMIIAH0RSbXlYfxI9ke13VBniXSuTsHgW/S2LRnYXAt9iFD9qufwEuJ0J8p9lcAIMLjTo8z5ezfpbguHut3xzaDwqFaHo5SZSzUpJ4lPDjsEGDUsxoUz15VdSh1nDvzInfZThX8cK19M0ASbSv/GrdFpuANrAT6SXBGeRJKuq+fBOJOr2tILJYACkvmPqkFHCgsNg0gSBqoy7e+olW28U0DSKzjmMkSA/pt3Ozk8dfRnNCYT14fpsw0uNhmSgyV6zWOVtueuVm+viPP/GkwE1eOHrQXHLnXVCEH8Q60jr04JfHpWwM8/RDK4yHFhzOIDvtUbX9KHowRkUdgvu3sJv3GwHRE622afLBtxvnJ51Z8aeKnxBqN0Ay4G9d/HzcmBLuH6c1ncaWVHgUbamtnJFpk9XMalXptaLndc4A19rXA3fpcRAkL7N3W2PWOU6igyHfwni3f4ZxiAOWcCGid3y1rRt2Fpt8/WTF6psz8YU+W3mMiTg8AzAn+e6YXwn5tZf3OFm0UtB4TEs5Z1JEjN5OzowLKmotQAIMvMDffXysNY5i7XLyB4vbJ0ymO/XAeApKkW4t5meC1NOMhy6TQAYJPnxA2wjevhX3V3nPItRxKXw/8qUb1VLG8Tv/yNDCfBVLmQIZ8E7ss8iM70dr+XRE9GS6hxfgX1Wrg4w1XZub7ZqLjVudc4nQ8DyF8+Eu+mGfnlmGK+nYfMu4BfIE+FfHdnznEL+nt5KX86bERWzoUmxijG/Bzw+Y8vtCNjzViqb/miI2Ov3d2meHwNLWpyCIN5R4llgC9QNae5xzPfPWseull987QbmrTdbpPNKzeF2OL7uGcv7noMAAADQUGeykUVLf+n72rcHlCgR7xSO5SNlWHKJaNZ2uvKGimyQk0aChNS9SgDeHNoBlRzm0WV2ain9iTZcN4RhlOPtuohVbV3n+m/PzBbkuwXx3HuirzyqjmtZPzqMpTmZ0nYVxvl/Z0lstl9p2+QSDfE6tBYACoHZbHNVcHgwPfIArlPUmlZB1etVVXpgsoAPleGxqdIfx1jBhzNE9v0EkE8NLRy3Xe1LbwmhgvIodFF+kshgY1+NyLvQnNj0BgRRlMlVgWzxgTE2npKrt89+WxoiSQh6rzgPIpbK4OXnvAIQm2zSIFeeHY8/rFfI9wG6cQoIRU1SEmFo7QozIYC0AMn71vnDCc61/YaqfT2I8R+rfrnuMLyvt8fUMfwQOz4jvCzdd+U3HWwVL1+CO5Juhp1mqSXabiqctSXzRDAKIuz7Aq+GhRwEuSVE+X+nQQLoDO1rC3WBwsUhsR2P7Q6Deb3EU6BsU9L9Eh3MNrDxCfinVld1e/QXBp60smnuzIjyeOCgTHQoHuyIytF6cdDTPK2ON4m35SbtB306pd/zzpV+wIwKlO78NZFHTZOxw+aHu83bzlBHUQnoyqUryQYyqI2Tf/iZTYX9AuTk+yvxHbuTzkQALbUUOTcVqe78SPHksy9f2FGhsZLwlPZMhI1HaqxJ4BUNGBHuF74P/z0zN5VRlltLqH8o4naR/+ZszLKKRayVjTxQoAp63WqEim/zCB+7JlLPv6D9cGskMKy4KnZ00yaiIHCOxedTKoB1KC02cltW379uf14hYIwxN4ac81hZf5oT7tcNUq4KsJjFtb0oQMBe9ngWj+EdksXhfWDL018GMTQy/FERfY7ZYnymC7OZltIG8uSgdCrfo4ZrPD8pmVbjUyahJ0U/TDhAvgvSgE0Cj/HMMxMZBrwpkO36JXwdOB/61cncUK8/euByhtxcs+FE6FjSTL3xktr/jWRaoY4RY5CJEJzt1eqdX5vYYAARY/uFm9mZc0/ekMbZL25sNC7dQ1/XxA7tY7svSawSu0k+rZgoGofT6F3cCn0YeanVHTuopIBDzBksrskbT5f1YB/BW4UKbGdyO5UX8lKygNfI4QEO1djtAv4vSOqksTfatvlAAAAMAGe6XR/qeJdJQyzgTW8TRTdAACQt4ei4m7Uex+ql1ViFkEYF6ozSQlCUs1nd7HOQAAAADYBnutH/6sUlfOR50DzQFTUfcTLetlJWs6X/8jYcTb94JjsaAAf3GzrPRIWgVL9PGpuGSwY0uUAAATZQZrwNKTBE//+DOAQNseATVIvvulM1zbNTm1kte2zdhlRzbLGjXY8Zu9C5cbwE2V3T2N3NU2MfQOoskso9vQLwmv2yjuyydzZI49SM33U8h6P3tlK05g344/9ooNiw1zXuh8k9WP1sf3yCyXlfcJvvW5GG6a5tZKgf9mvUtxG0vyB508xmHXcscwTso3hRUjkvTFxnW9aW0FbgqqXy+L4CRf5W1lxT2Pes6otdiQEBs8CHhvAeu+a3MhHY7a+hAD6kKFCCam09nAnczhYcCu0mpdoxdqyYPpA1SFEE3FDnyWi//iFsXqB7OFT0kT1oAjadoZtgNVOjL1NfFD7aELCwNGPnuWDfPEfGGXRZkKrLqEDZUzGU+KAuCCUkKC5qPWN4qhcJK/jMC94fGVVle7MzKtJ0za4+aXyVTIgIFvJftwMH+C3DOydSFvoZfxR5T27ZuDo09kLmmweBV+B2/wD9f10b8RvMTL4rMWkSw8vepCI+Em6obnzQ9bRctkLNUYWR6cIfUCJQ/eCe3yXiOF0phR/gh0J8Yi1CQtHqrveHMXVep2XwcFZSa55YitLU7Fv1BnWakQ1v8goIUOOmJ9xTMj0GlEheiDTGxzd6BDHjhKgq7oBggzVEsD4IvjsC8Y4dDTH6yXZHg+nLn5GfMhPj1bCbG8csQuGNRHlMcI/a7eW1eO7LdjAMDezLNuXNe4Wzt7RXEk/fTZwqcPJcUJiO1VTkXyJckJjIhiU+K3LW2VGZp2TPsJ3Ucu0c8yZUBxPxNGK9IY7J/fJWx2LxkYQFRNppiZfSROJ3bGz/u6aNgw5Sff/AnBu1jZHwPXxfGzSnnA2uKFCcysOxO+Nd9a/BcY2tT9awdrjS/nXtLAQ2cOf1BDLTZRTgPm2LGaf2u4/lBE3BjNWKL4550gSRFtiwQYjZrGik7RTQIzMEiT0u/Oajg47gYzMmQJNvw7T0u9JgvNCUYZoHVvN7rDPgrPub6or3TcmBZnEZL4xSbN1drgxD03BgNUPgtX+wEcnmBi5L3MZ1HAYJbO30ksBttEKYJxBDUdsiprLFps0q0WscpbStan4JeCgyCYth1WLz6N+c3HWieojQvRhRfJj+cLlhU/L72BRHGdFkXhoK3UlaUvxMdS3u0OX901JHwljUdlgi8rK6Fq2mQlEGU4+Vx63OTCQh0V2TKi+jL/X1bmonSMvHmQFAfVnbJ/H/9M4eRal8Va9jV0hhiznkDv+lHrLMfnIUUIVkRPQISczfY9DQjmcZtTUcM69H4TSpM0Waqe3KQO2pGOVcSHC1QUgQvKxIr4Y6KNLffRLgAI1Xp0xkxv482b9GRY9XzUxUxphu+5/gTMyeNmYip8u4Xa9iwrPIGUmWFxv28ppd/3OmYyq09aJXOYqIalqD7SeUD3lMU2VdR9tvO1IUGPT+FkXDTzq9sx1h4Dtr1YKiV0+ZQXIyNRxwxT3U6VC3/lFsdoTIjqfGbX+C8vlpHBHsNaRLFy/QS6+KWdBT/Fp5Fex05aqIgRYuSyL0tHyvv13bO+5vdlhsvvGWAP8jL5gK5exNS9/UtyFVbOVDhaw0Vn0OnrmhJ51L8NKUM+QiGOy49YT5o3lggfNS8wB4pcR/Jn7zGwDge6vLHEJGzlwyMtLmZxcMUzaJp2SSik+w9MAAATaQZ8ORRUt/3aCTJFiGKXks7idcOR6JHQvZkEhSgtc75eJYkqtyt+Le9nqbcMUA64gQMMJxrK7cKTDERSe7wZvyqJ2SgwVzjZGidDi2gK8tfkv8aMc6i084e4BYRi5RVb0vxRrZOGLvrc3FJXDIzi03pUNBuMVg41Yog7WxLETzIWBOdzmiv02xmrQBQSbe39slcc0tHTNtndNKbmn8Z3Vzmw9U+siJIo1q3Jg3v1+1VOEs59cu3Jpcw6++GEU1FeIRkHhFJr400BmLhjMqYoEwtLNJsDbh4bYnmil4aSU5zjPk1ByBPHAjLoesoU/zdqOAhLx8RLKavVoELf3KbZBzdtT5wtq4BWnKTMy13sLoIEynOqObrMZkgVplgFqkiF0cAdwUq0538+jdnJbHC49GBLS50cRiUglBvhKAEVTvsnZiA6WaScJmmBi9mG9KeH/4fohe/zmWg8XqOE1I0xT6QBIH8ujXi2xi2a2IEhtpIzHyQN4ShyC/NX4M556sv/MkVkqf6ZtTe5/dgOwHStK6ZkSR6D//WAuzItlGs2RmQffPWkY9j5C7ItFNxwEgYCTdxmWGOtWbedlEmCr5TiYxvqmm2szhg0TBYqAqyNoVcQXGn9mEXjm28N29IGs5HDfgjSbq9Vdn1CwfyW1g9gVqF3ter8Z2Qeyv9VhY0obQoQ83/0kehk91ISwqKUM2L5uI6YPg/QlDC9kkYVsXL38EM7B3wmMNhO06fjwcRvDh0nUwm83LUgpnqHsRYhmMqU24YBkUMWPzG9W4BYF5GIZYKbHqB2iTcA7raiuHg5P7004Y1XADNsoLc7vY2J4NOF/WYZAST/haWXJ22i49wBs6iu0KrGS/Q7I0fWaFzNH6PNCZxy1kQH5OVJp6P7Mb+1xqi1eeoMGoXfegHpmzkjmjmOFM4J7VbPO9fyl4TqkU5S7jzPyr4KcEsPF1N19zBQCZFAfCpq42/nfCxI517vqCPdmOi5xtEB4HAVJM61Pu+RmiSrlnnHXWOVOdELpJOW17f6Rj9ZTw5Nvspd2pd62JBX/UTP7MMhAjWIofKKjEwIwGALJ++v/HuCXezOuNJrEekkfLlM1sZGBfXxabJmi8p/R9nyMTs9kt5M6WxLVg1odgQXIim4tDGXC4oFBTgJxwIOwtgFK/mJ39CdSLRfP/ZmmmCWkjVuqPFp83NUPilJbJP6S6ZfduEWCV0LcabcytOn6WNqEhBbj13vkfNsxRcSNIFx4Cg85xbRMM86kGp9D1rMDTA/uIHjiXJHAEST9+j9wBLtoS4uMy2Prv3sMMtmcFca8SoYGWjYO2aEkOpG3rNeGQEAw/eUrNERGacYR0zkF1zibdjJHm28uOYYorqji9UkOlWtJkuRIdN/g18tDtCdB1KlbqmVFsP2+jV3SuCGgataGKAKEIU+Or3dWLtkzUZZNgUpEO694yqXsH9BcXNvX3YP5Yi2LuiP0X16LdYRlcEHGB6LszmLFzo3iCHWDTz1xo7BMBP0iTcLFlvaNTkxPngZIapY/XmqHHg4FUokFzUjZ+NTxyXKwwdvHtN0xqHP5GJvocQ528lgFg2xCUiZKlmIgkBw0SXlz1bp9ReA280JMqAH7pAhHllW0YAYrZHxdZfJQaqgtglQZ7ljSIFW8UYNFJgWxAAAAKwGfLXR/Wy6G0XgAAXTnwgAABZ3lcr4r9A2Up2nEWqgHlsPyx+XK80uYPOEAAAAyAZ8vR/9zj0IFPF5VQDMZ9QGGDL3mdQ35Wi/fw3WLQ6XPgpKAg0V7LQ9cUgoShCSoyQgAAARaQZs0NKTBL+nkrLWL14BgpvYwUI0QSlXD2m67rqJJ28+NTnaILhEiFtNTHVd6A0P1pq8u65+o7U62OjTL+qIvGEm+koebxxsvM8zbUr7697FtF8OnyP3WcgVIJxF9unxBWWGRlLtsbAH22NoH8XUvn2O+ZDIxmKDhMG08O0zsRayxFveTYnHH/YEbbxTEKxqJII2deeqAC8rsnaUnUot6DP2EY2sdnodLluPcquntESzyywrwIBRqMm5WxkyLRy79ld7UmLYU276tf/ZVwrwNdR9LeU9iquFKhSAZV8gcZZo9S8CtSJw+wi+V7HUOlQozyXeBkc2OHv2Ky8FTXCzxhCkrnHUUNaCSfN2mIj2vJBazw5cVVSTuhp/lkNxNmPXZKXiD4kIHSwKC7BpZjty4u8Vi1gUenMmt5QDfm3nVJ/V3s0QFGg1cIhREv3lMa0IOadTK4H3IeuYbbYxSzqQOvs/N+6bnzq0+lk5Smf0Wl038jxt8REJs55FE8L/RcRijvbzwMD3dxiD7/likYCdrncXst+xmp2HCAStX/z61kIIYzJvNd9gZxnYipettHIOYRKrdc670N6lRW2WpaoL9l/6vaXlGkJijR8O9ed9fe/vSCYW1PgkUMV/Q2DUNjTx8jB9kyw4xk/11D6l4YZKhRpQYzgjKR7vnhaozCTAy4ZWmtogw6GK32dLAFqfGYo52nYzhlxpE9wD8ip12tlNh5Pu7f/WskgfOG1/CJ0exyD9zdWmZT43SMIeLc4JfMQphlEMptD7B8hJYSn84pC+WH2UH8cBmmOdCyupNvBMu3HfqLiLoCscE/E/6yhqxuvmJictCNzhKBwbvEFMlLZlujFCLEi9QutZfySrdn0J0+zc+w9ue+3QBT2GUWnPo7gNn+zxILF6rY/xsmJ8Ib6jIIJtaB6T/g2rc1brOXZ6MTw8LqveaGEg9UAsOgslabRYnUfDD7V81ao05sSbkiBhDXXIizByeAPraO45DBDFiJewT6kFPoq9PwDoLw5QyAYkORO2ntVIDQB1OkMvogexjf/K1lnswXISXB5pycQ+F0/1R82xR8WCkIO3HcfIPqAIDPXv+XaqJxdQGHxcz/LgwvJAap7ppGASsGOjqBryJcPp0kEj5wMxAOSvb58yCQ1ZKq8VFOOP0UDsZuec1SVizLK+v0YmXNH0Wl97r9Co3kJklMgZkxYbI8DUBMbAp3CQQPxZC5AwiqgOkhJf5Wwr+f8FWoSX4ISzr3a3XB4pbkc7KT2jBJ0Vg0YEOBjFLwFlCO9bW/PYRn0AguiFumYpR3V1I0ikGbqgE6HBOauwcGNH/gpMP17Hs7pV7gWU4NZH3U9XFK+PDp7HwHWezbvfZYUeYleajAD9gh/rjzloQIM8YOiyCS9oON35edC0EEieGGCv5DB1IKBkWi0YHuK65Es13voLml/nvfnGFg4+IogBN3gCMzILwB9weYN2jDTdVt2FDjzhX+Hy5MAAAA1dBn1JFFSy/tejAKQVMjP6jHG4IE8lNyOEr0Yj6uzSAKM471vUmt9C3RJAL0RcyuTIc9MnT4jf0oyvkHPusajauDLMWLejnXAQr4A1Upb3PI6kqvqa02qnxpXLtL8UrViIpCevaS8/gYwR31mlpN1B1TQRziVFZsD9rlMsi0IPexOLBYIkxfR6JgR6cpjWkReACmU7Mwj2VeJzL9DEx52K0oGAxwHgDQ9oZmG0aH+x54FbTJBYOuAwShIdpprOX+q0nhAD50W1C86/A7BAFlnzwWM2OLejhLm/b0UNChH/ONp+nT7uu7MltgWWpGGtLc7d/oEYNhu2Ktre/FEfznjzC48tSeRzySD4KgjY4U69DvloDmWtOzQ19dpkXpSuPPz2kSbb8BxkKjVBz16B4T7xdYvX2SA0ueT+0gPn3GKBlRbB+rM6tyPzQebNdqqVWGVn3ggl8G3WM/9EJualCe/x/HglKGpIjV1iAZjvSE+p9r0WPJnMI7zV1sDiFK1dsWWgEju6OAbBPbwixUa2M0HBDTLFRIMERf114SXQ23aMm1WtBdaicCqIy1+EybI+guwt0rTpyXpYgOeA0jzk4qaGirYJud5PKCYTnUlRQ/AyjN987sr6XAKCTVrzNsvK89DmaNUNS8Gn08gA5dUVc7m98/TndI/yCjZ0Jyit8XR8uOxcyYHPMLwTOyKJ1bB04vO4Xb4HuOm0yGBpYha8c5lGJV1jjX6zfpAHby/AKlIGbio8w9ofyXUgv3qtJr8zVbO5M59pledUAvJG3MyIl2sHZdPj26hibO1I65ox4aQ/wy5DnDHI7Hg+V/RY7aRmBHqv27ModFwTsMVx1/v2agG5OjblzemSecIepH4eVH8igZ6MlQMAORp/EcovUYdrhmmJAv9qsNtcGPlUS47/+7hGOsHfi+trGNIs/VMGpT3T869Si9kKXOjqeb1DQYJ7imKB82VntfmjVNGmv59mYz9HERieEst1I/iWaizIQ/3X1rRhkw8L5q/J/UhYg48hPOAk0c8hNR0PIMk/GyiXocACfU6UeYxrAb0GVFkG9deuwVC/xt6/kJhMZbjSxt02PZFi/zxjICY27ua2U0N81W/vnYniG9lDNGeum+FrR7w6Qw4cTV016vYEAAAAhAZ9xdH+p4s2UH52lqJS2E9cEmAA11wMne3ub36jm2kZgAAAAMQGfc0f/qxbyhUA7THirxCHylQGNqodn3lDZu7jT/mCovBpzUqOKa1+blNIbAHSp9UkAAAVJQZt1NKTBL+RXFlOh+KjOVqTTfbdWmxm4pHHjfaTgdVRG9dXt6WOuYh+zWSkOX4/dCTB3U0Exmt2Ogu9zrm6ePGEJsXcN4xmMEh4Xog0VK1Ndga5IEemp4rOnmecJzRnqp1ZoHewYQhhj4qjHTTDCO5JOt0Ld2PHSJAc8s44DGDB/9tdUVtnuK57m4YDBqGNltuqoRU8x58q5krVZxVIU6p7XHGUZiGP6VWHD2OnqfNbUT9pOEBr1eBMi04BWAqaS8lPa8kxJDsT15kr7BKbBwHhyalynXbaJQFRYi/0IKiSZEhPbkQFchTvidC14OCCCKddQitI1UXWq9vpfVVkUMdK2efoCjjyohvIeiI8ON+6tJdSo/VW6ISG87RGa/DpqKsXGWK7O+J7B6VO0Cq2NM7gJBoltji8tI2iyRAOcoOTZRrtcoXD8WwS7lKFBoRxoh/AwY//YFpz0jjgjsHQy/K+Ez9eb9zmmWW9DCTHNa57paUFWqOL02f0Fxyy0sVVlYim4MWba2o4IjklbMSsUMM+FbKNhBr2MlmxBafJwcTYdfd9vw/y46NKCdiJp9y1J7On5yQGz13V6RYMwpWAmU662tBP7d1OXSckVUgbndIWyfkN0ZsICB39VT8G54t/PJDJ2h4JrKTkNiqEiT0Tjkl+jhqMOReREuCRd9qyeJBR44lid+EEd+n2itsYETL65wNCVKsuuhCnOKvLMq8N6j9lWsoGFfRkQuGipTLoxmbIUBAcYRBbFJNBrsvunXQsPrxYmOprEQTtTaAAqKNvnzbLF5it6iG8Wa6OKDD+msXDDXl6Ho96pwSKNrvFKUlEDJK71X2whkKeiQU7xgzy7HG9KhDcNqLQcHQ1D4aZ9DNKCt9hgiHqu6fJlaXtEYwxRKRnbTK2lSgLoo2VHM4NlLfOyc8Fm474gk64jEMAfNwQ4zluoV4iGjh0vMUC9buCqqO0wqhXZAXXwMteRdAEBTnyZ/McyHWiIpKbSYY8H1jU2RrVrg4/WGZeBoRUYXM8T9v2oa8rEfZdqF1/PXyX04qMWrLdu4LIuCGVnPGEwFicTbFZyxz6SLKWfF+PTM1YhswdZ1HPuWIqUMZ7Hd6ukQtFnWJWHLSJhj+8eZEP4ItJA0cu1oQ2xRJ2/uTDf3rundAUCDrBQEIfuw1uUQbtvvYmOMkp7YtscAd7ZXu5gq7/2tZW0M9/1BykLzMcf9NJ5jZXgrAyuY9Q3Pc0PfG7/hwLR2GNFnNYSEm84VYpyX5Ixr3Ast82qSd0qt0EdQOnXhA7dZmsyTBZbLLtaTcwjo7PiApjVt0PnhnmI27uZaFzKFa/SoP+BMsYK7PtpnaiR5O8wzqBzitkNmpgEuBUoz1MXVeWOU9cs0qM34aSosyQy2dRhX8pon1ZrNupnwe/poejl/D2FpEoPfyFnjqj4R9BLi4s6VEruViIwMQEqh09s+fwwaJg7hIKzfuxTsyat2UXyO+aSGqitKgSZyMt7S171xZs3KnYoPUppUWPoku+xoXNUyAMcddzYungGAJoEsnDSdbjQS0lVXJYPd47grc0vPFpEv6RCK7Fs5d/4BLZMUy7lR7L86lg0SRba+V8eKGn8Ce1cJ7/FysjyBRut6PUKq4NSmtAc5qyMe6/2cuJ/yYzFLsBif/Eiyf0Z4pBhaWEkOOR7bX/FgXl+EYzraGJivBv1tusEPOVoz/8OKofPlCgFiXsyoTP0TZ8r4mBTbsu+7U1+6WpcQN1aLHU7yWI43k+8MQN7i1c/5dY0SuSnwC/wFDZVJqeuzEDLhFoa75b5HK+HYaFhAAAAG0Gblz0TCipb/4cATgZovIQUgDcqUc47H33miAAAAA0Bn7ZH/wjcNKL6EPfZ', +}; + +export const Snapping = Template.bind( {} ); +Snapping.args = { + ...Default.args, + resolvePoint: ( value ) => { + const snapValues = { + x: [ 0, 0.33, 0.66, 1 ], + y: [ 0, 0.33, 0.66, 1 ], + }; + + const threshold = 0.05; + + let x = value.x; + let y = value.y; + + snapValues.x.forEach( ( snapValue ) => { + if ( snapValue - threshold < x && x < snapValue + threshold ) { + x = snapValue; + } + } ); + + snapValues.y.forEach( ( snapValue ) => { + if ( snapValue - threshold < y && y < snapValue + threshold ) { + y = snapValue; + } + } ); + + return { x, y }; + }, +}; diff --git a/packages/components/src/focal-point-picker/stories/index.tsx b/packages/components/src/focal-point-picker/stories/index.tsx deleted file mode 100644 index 709b864baf0214..00000000000000 --- a/packages/components/src/focal-point-picker/stories/index.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -/** - * Internal dependencies - */ -import FocalPointPicker from '..'; - -const meta: ComponentMeta< typeof FocalPointPicker > = { - title: 'Components/FocalPointPicker', - component: FocalPointPicker, - argTypes: { - help: { control: 'text' }, - label: { control: 'text' }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof FocalPointPicker > = ( { - onChange, - ...props -} ) => { - const [ focalPoint, setFocalPoint ] = useState( { - x: 0.5, - y: 0.5, - } ); - - return ( - <FocalPointPicker - { ...props } - value={ focalPoint } - onChange={ ( ...changeArgs ) => { - onChange( ...changeArgs ); - setFocalPoint( ...changeArgs ); - } } - /> - ); -}; - -export const Default = Template.bind( {} ); - -export const Image = Template.bind( {} ); -Image.args = { - ...Default.args, - url: 'https://i0.wp.com/themes.svn.wordpress.org/twentytwenty/1.3/screenshot.png?w=572&strip=al', -}; - -export const Video = Template.bind( {} ); -Video.args = { - ...Default.args, - url: 'data:video/mp4;base64,AAAAIGZ0eXBtcDQyAAACAG1wNDJpc28yYXZjMW1wNDEAAAScbW9vdgAAAGxtdmhkAAAAAN7yaaTe8mmkAAAD6AAAAzAAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAA7l0cmFrAAAAXHRraGQAAAAD3vJppN7yaaQAAAABAAAAAAAAAzAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAUAAAADwAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAMwAAAXcAABAAAAAAMxbWRpYQAAACBtZGhkAAAAAN7yaaTe8mmkAAFfkAABHqFVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAC3G1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAApxzdGJsAAAA0HN0c2QAAAAAAAAAAQAAAMBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAUAA8ABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAM2F2Y0MBTUAo/+EAG2dNQCjsoKD9gLUGAQalAAADAAEAAr8gDxgxlgEABWjq4TLIAAAAE2NvbHJuY2x4AAYAAQAGAAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAApRuAAKUbgAAABhzdHRzAAAAAAAAAAEAAAAYAAALuAAAABRzdHNzAAAAAAAAAAEAAAABAAAAJHNkdHAAAAAAIBAQGBgQEBgYEBAYGBAQGBgQEBgYEBAYAAAA0GN0dHMAAAAAAAAAGAAAAAEAABdwAAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAABdwAAAAAQAAKIkAAAABAAALuAAAABxzdHNjAAAAAAAAAAEAAAABAAAAGAAAAAEAAAB0c3RzegAAAAAAAAAAAAAAGAAADx0AAAUwAAADvQAAADgAAAA3AAAEuQAAA3IAAAApAAAAPAAABZ8AAANFAAAANAAAADoAAATdAAAE3gAAAC8AAAA2AAAEXgAAA1sAAAAlAAAANQAABU0AAAAfAAAAEQAAABRzdGNvAAAAAAAAAAEAAATMAAAAb3VkdGEAAABnbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAA6aWxzdAAAADKpdG9vAAAAKmRhdGEAAAABAAAAAEhhbmRCcmFrZSAxLjUuMSAyMDIyMDExMDAwAAAACGZyZWUAAEITbWRhdAAAAvQGBf//8NxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjQgcjMwNjUgYWUwM2Q5MiAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjEgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0yIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDE6MHgxMTEgbWU9aGV4IHN1Ym1lPTYgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTAgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2FkYXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0xIGtleWludD0zMDAga2V5aW50X21pbj0zMCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTMwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjIuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgdmJ2X21heHJhdGU9MjAwMDAgdmJ2X2J1ZnNpemU9MjUwMDAgY3JmX21heD0wLjAgbmFsX2hyZD1ub25lIGZpbGxlcj0wIGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAAwhZYiEAf/zA2GNkKfOFSsuTVloQEaXdzoHD7Vtw/suEginCwuvVdLAAIC4a8RRwZWlYBuDUCSPfB3B5lW9X50lraB4kEgmym6mAAImPPAiIv5IzQUREXNTlXLGDbZ6pr7DtSUl9HZ8/7xMuDpvacJMwO2ZrQABPwobl+UNw97XDlJhhlm1a9jM3/P/K1jvpDw63Bu9E5f4cavT3elDFhSIT6FQ+iTN+13wY9LeL+T20YNckYyNifbav1oJZN6ec1X+ai3nodpai1j2QnWNfq3lO8tGXVbG1DPN4S7Xm8qs0ba7GbbB3eagRurMx0TWeIRfxVmKgXizipPdX4zTTlG4C0U0+eeEcjWzes5UzxKEhLcaJRJ6P5LU4p24Puq9xTLEB1EjbhpdbrclA2Z8rSfiQxXPW+9hvNkCWMDRvjKpUVzTQP+HgnD3Th39tikUJZJYer5OCbzdwPK7Q/f47QynLISBoW5VZXmWwIOZG4T7KAYVOKrQuU0wW3bhUY4SPvo979r3FTfcf4MxGr2LR5+2BDLaI7EntpwCI1zj9Lk5WFGY+Rlb++hMvZ4DSJKzv6fE0ROmmXsJ0U1LPjkHe7bastf5R3a+x2QHuUSIxWmO7wnVWVn6mkvjU6BQUelrRTDKrwOGPrn0lNrZCLZE1nQXB5TxM82EX+hv1YG3eS5tSoQpWaLkvvnoMBxGt1bBo42UKVKmpzSfPfTapexYbjW0pQB+BfazvlIsbkWoUgdIPsmpq7+ca7FT+iIeD+TNU9TvEmvHyV1MKYax64hPCtNO34u5vwjlu5P2O2d0nL5mg++r4+Gl9ybNKGPWGXK/L5HjojDKSZBdCcv8rejatxmJxlUI9JdvZy0kAjLislEn8mWpf1I0ZXkeb+HuOWFMjYcJy1WwwEIN04jzhejmHpqdzduXUqMyzSKrbIX5zc0EcicHQpoGg0Tlf5yOfTKX3sqZgXsLUobfqUz8Xj5HdyWq2iW3MLytOMm/8Rbyb+RaVyWZQL6yBQcJNRFIvdkTT7K1N6amktfFiPJKaKpBaE07Y5sytd2C3KSZnvlrr2AZ2s/6u2HsMCRRr+qDzywVpCkXW3EWrdKb+3Sq7yh+lopglZGRb+BKkSWihgi00ZRL+F3mwUNV5VPibODPvr//skhJk7LLXI7r8/JEz4lnQqpwZJu8CpeJzFX4dlytL8bpoRhilI9Nwu3fkyJGu4d/esGQfXvg+OLzf63xMyu6HMwey7Z9aA21FHYFH+3rIum72Mu22IXYFSXVq1eN0yTUsMT3/lnrTuWt8zh9INPUDUgUi/uPuvnhjDr/MAO5Tb7h3nsfQy6WDWhHcRZBQQkLd7M1tZdEeWw+jz1ckNjcnCe5o3o6Ucd+Tb9uZKyvlzyzVkx+PZRFCAdQrKLXv2OGHCUR9eLFWNHogm2XZU/39A462LM0PkeM6Wn2Aurjpoxq9V4uzC7NW0P0f+LrypIunB8Cy8VYYy0i4eLJhjtGqXHLazJh6BxDRysBIxpqDp16Hm+sT8+LnPrH0yAl2TXf1KmkLnP/+2193RxJJ9roy85d96K9RZTG76ZxUFPGgmGILrhrY4maFWhGq9guBeokoIkUAUn9yhzTHr88D5MSvGlesrpy7CJI1vKwJPFX56c25ScAdNOXEB0aASly1xpzslZ65oQJrP5lh1OSgAzMaCzrWleHUOxmpcwMeFYys3lEgOtwQgBmoKvGQ6ld0BLI5JJPQrZTAs6M6cU9WxiOHiqSraNLEooqeepDNmg9AjKVKCUTh1pc26JOdg7YhrKqdc4jyq0YqUrguZkTo7pt2mHdWExgEfHshuEUvL2J0hW6y4wC1tfEdJoKIPnySv6peQMYGAD50HGsg6lix+bfYb+wYj1RQrDZyPMrib47nNjVSQq7feOrqdY5PX1ey7AzW0wFOg1ksN9uRszBUb1JELEEFBqTIDQz3DH0JALQ4WhunD7cpmlPuoaWLNwVztHeWLerXLE74iuziBx/SMZgzQOFMJ/XFrjWw8rmaJSIiA9uRyhaW9YDJ/smWG8Uf+7cRtpTu6ao/3Lh2sQQdWzQIRLONgxLzDlVyOavM/kDa5da0hknWv3PlLILv7vGQ+Zs4UktjycwP6duVkN/u1KQTf8X0HXCAPvq07AswtBQyNRVuWGdvKv9qVwzF0C8QueA69lL9j/dn5vVYW+qbUyF9z/ilUPHVl9LzV+Y4n+QGnxyVMFvOit87NtZphn+L4ZYLMztPRYIZHWmJHx59Ek8cThU/RyDEU2ZQ9GuXH/tAA0b0CqP0Opvb3iqgzeNDcvz7C6W4wUgm2G3vM0KdmNgADxF4iTE0cJswo2exOMt/uRYFORat+ffXU+4pXpPr0cZeBO5qHqk/w+21EctDB0Ky9PZSyT2L/CdLQC+6GIbRo871nJkDZId5POS8t8qf25O3DeUze17pUYvtK1rcYNGGv9kr5Noh/cvVxlZfZk+hAJZEsJqN+k6xG2469xaQq8wEKL6yev5Eiq+DzSDyO3xk9IL4UDosFTqgv+nLqSEQZf42/1vlI6r7PLBWiw7Grg2ITSfVRZT9OhEQCEEIrfA0BXFIjtJ9e9pDZZhpF6qIhJ+EZtB2UOCWU4zxwt0pDLkX8+qEaefg0s8XkxDrCbUInNY5+MS97YMrbAZDo11HpMbD6spXTj/sGEJEVvBhVJYZxT34+E8RzPjbn5PgSrUpM1lQZ+hbeJ4PV+kiDwC60BVriYI4xyd/DlHSe0CwGooOSUYzLb2x6ScpvbuGmkZ+FS48S/RL6BdZcC+tw9YJuj3k+ZRzQEygvLJGn/Ff3g35Mu5OniFh8iBs7klOQBR+DcH5LndXN+Mw4OcrCyfC3qOUXyUEB4KAMAF6x48I4Q3G2+/asthOmO/Lfue5ELh8fPTOeWGcNEEHPeQbacOs7YSHiT8eBfzXxUdYHjI5/RMqG7UJaIVf6C+DHGX1XF7AUtj4MaTJDu+AN20uk9Yokv8eeI4sc9v/+dc0z3GOsyu34kL9qFhGzPhC9a3Eki2vdkvrPNhgUsZmvjbhGGXHWFhPcywW+dUfpTJ8flX7/pnDj8t/1z4/VjkTqN13n+OYmQXKw2wfiDjT3WxFjNvkMywveho0ya3lNGmZ/7XQ4j2wCM53wBd2Vd4aLeXHhulxu1+aCxhe0/0VfjuGRERmY78odPjleOb03ulA+BzFmPC0D/OT91+LDXwicxGW4FYNpdddyyG+Jb250hXUK7mb/kpnLfQLcQ1E0lB/Nh7croXi6FF8uBMlwigktv6jntMUVx0aU7cpR1wIXDR17VCRiuejAbn8v4rVvJvyrI6xLUetS0vdq5L5FVtxcrb41eisp1gTXjO8u9QAXVPjS8r9cNE5yIHvCM6AjWLy5oBTGkoQ/BrzuTkdM8y8GZq8Q1Nd7wBbj407htDYJknbg32SWpCnZNkpNXVL2Nt4fU+kXZEqtJJa6zJTLk+VhXWT+s8oW4YfUESvkoB5mUaU18P7xPA4V5P1V3na4yyXzrLdWAHcaZBQnddBFad2AYUcoTEeYYckXoc9R+bCRQSo5rSNtVUj2jUnT/2YxLKRo3wSiAZGfDnhE0sZ2UaE2+W+LdifOHvwJ0j43ZdeSdtG6aod16qez0JLDvWR2hLjukO3yLRhr4uFN8ebV6vnZztxLCZVcPmax7UfjQ67AWxjcFlyMB/Rm800Oe++ft2Gxngrejdnt0lY6/MpSEIOl24LUp8YXgdR0tsNo0TqBQII0KiU6k8UQCat4MLmbGWKdLzMtZXfzS59+DuVImjG3UGOSrEu7VCPjAMIe3Z+WDNz5nIEaxwQrMtGhqv9oqhNOCG751fxfXb8zNKOC5r96Ti8zqm5P7O/G58xp7F+bbECR+Lf/IUXlxTdt6dyVsP8L9qfonB/9HuWIN5TInSOYkVF3CnRDe92FJTx7URcvk7ABg/akjc2++SqNFEW5tH1igCo4JWWqItaishaksBJnVHYO7xSWre29ik20xixB/j9IQBiYEJnn5UbtZAzUOdCLsbdjH1+aSuRIofXvLynenW4vT4/PpW+jJidXV1fX48ZklOyXf8FXn5UImACwO4rEtlMUZq9eJdO0VAt2w10t/+7PDtAO2wInrOo4V18nju21flg7HyhtrBAAAFLEGaJGxG//yk5V/bYk6AL69BRgDUlzKmvMlnKdcvSzcDMpFz33VkzeYT/fXe72/sEqXtalkzOHcwbKeaUFZK+YZJr4dCu/+aXe1udMCC4PwXR+kHnTiy1yoQ+xKIMjp/3uOW+M9hVvZPaOLpBEMM7WaqU9nGX4hJzj0EKQ2eQdjElT9GIqQDmoXuZ8jssWV+Ao/LqUYAK8ogdNsSMBSpxfEIY64qDQpN/1QnrST4ay5jwsBFt0PB7H6j/ep0pZlZB93ML9g4Oie/bv+cewzgXSL3iKsXDgY8Qc28yicYWQtI9ykEJgb+/Clj+3R13r2jNZtdAqUDrhn4P2qe1oHVddY2vdEKoukaDI1rza7Dh6efOEtd+FPLm49xxlf/uU3wqiN1//r00ibfbGE5dltNN7pdmaxEtpyaTdCCxbJ6u270FuCV5fnsYALRBgq9lLo1L4AwEQHg1uzdZgcIFUyw5pNvFtkfHO2cTagF/RrNYTXr8yobWkmprqgydk6Jn28cT8R6E5JQh8Q8VMCPJt91GBgoYk6DaTKVQ+GR8AQiofW6w2qgI6A69Fny3397qCEg9iEZpDO2N5BpALpZZmzAyaONjw/3Sz07iltmDs45X52rSlQXgoSFKkPg8vAzUgcJRmCDnK4tvr5yv2UXFRflRtnJeUasWD9P9UP7dEujcD237dOA9DDH2ClH2wyT5WmhZRNYbpEoZzHUf5SYG5bGZTBFdXoHCWZkqjMg8bOy7D8EZO+yRNXY9ZuadAQbFCR8uKfmVyAqrdUiATepgc/a58BCpkcQczP8nqIbjS5RfP4/hVWY8RAuuNOvtMmfg3o1et1cCT+pYI17EHgXr59vNEVIE78s6Jwi3uUjPHrnCvbPqrC2GBtyc/HG/epri8XwcPf1cDY3jcA6VwkIx6o/ZmEQ/mAlRIO9ScmJuOX/n6Z6yp97DFOcrLFTHgoL2eMP+V/GOU3TIuyzwj9/zHqzC+S42Tgnm1kfzAeKwuPOgpKIkOS9/SYxsTVHYqmKGkGBZi77VKp7BMJmIIOQe4fVYEOZ5YYAjR+QKduA+ReJMHhh7lDdpcRkiSIOJaYhnWQjwz4r+Mn8qQ2ZlA6B2k9OWV5A6Pzy8cKFWYZvMdWtCuFsFkAlBBY/hSVDlBvBabEJvqxOuls3KXxDSszejfPJOMkYIDbKmK74SmNMbFGYE6AHx8EWP1rTofvGf59sGbYZjoye+kyDH4q2lFuKwzdqX3BvADvwS/JqNiax4McmKbISxuQJacCRkD4Cqoi4FnWFKqA3xgbtwQSrlTV7Hw1beEvfU5x6Bt9e1LIn6DvtIBjwkM8v2hQjq6cWgVThoxwED4TviYxSmNLM9sNrBIQGzR7vzgBlRg+g6cT2fChebuOUfIQOvOW5jwHJWpZN5ddtgvsMmj7ZYUrITDCwY1CgCS8KijSN/Q4aUkGED3bgLe7GKFq/c8oPAgps3jwSuiRWlV7MwXAgpMfowGKM8n67EZMpylSYuApCH2xoOdHePAjYFHHM9hvTMV0Jv+5Q31C1h/UuQyXU1MyduXg9Mlx2uOpBVzP1Qc/hmlsZoHMGysUZ5feYp846DLu/X5xW+9jPexhJ9hLwiVWNHNFfmp5/kaKE7WC+5E9/6OAz/DuNLPOuyvBwJ9JySD8ov9JsK+maBiT7IzJrsaEKzzYS7erYult/p/SyasHUTbJ4BgQmH64Qb2Ydco02tZzGSrAAkZbvGasnvYee+5NkFbnlB1C5xHU7LcQ6t7IRKj4NVIAAAAO5QZ5CeL9DUtN8A/bTgCkRRrBvGKTL6Qq/3/0bv5L58FnEKi7CJwY7YYYePSK08qJjZtspBBfw3JQsZy8UYsAV7u+GxHzRLbeZNw13bJAT7nVcR83GmWeCzDwUEk9elfT45KJyhT/b0x27CWARXNNIcYUzEU2F9U5GsiiQ4clH1kFGf5dI7JVzQaZk5jhoTZY3LK3DOeKtm7Hh/BaHANbEz8ZdztVOrVDwB7QwsmkMoEUIKdX+wDzZWK6JKdEQynr1Rqc+qxGg/PaN5luC9yjEjCMzsYFhEahpiAADL67e6F0WBZSmvI3XZC/nF7iJj5G5ocWFY06hI+W4wQ89KkpOGcUASWRU2mGvbHq12VA+xAF3vVecHZVXmw/Qbu+J+uG1DXY7ktD8hKK1NQ9rShO4r+wlALcacAk2mPoC9KBTKYp31HnpJzJEQlwsBe0cFxBBnEPiW5HOky4eTdy6PruUkSsu3GExXxIf7frY/VqL5pa7RVXry4pdJNtirHiWCC+PdZiTvlX1m/aT2nyRHYEDKAbph896AsYOn+YnHVeENt580uPY91s0uM3vrB74BAq+PtChvY5/oCXngPquoWZ6SkTp3DHIZG+UHI3kRd5FZxg5cKzNuSLACxwL24xPMh9XzJH2z1Et6VzrfZrXQi1conE/TQlhjSd29S01Wc6EUxLXI6IkZToc5yVqNq5yM0DiXt9Os7/dXB/yrH/7+PWjgABHPnEZCJbQCeOjBXIX8egoyaSCKx2Q3AxEzDPsiGN5CpE3mQ+OKHmqpfnzROARN5Yt3LWNwLh+nlXUqKPWTGvfCWxMBzliCdowzMHziIhra4IeDF6cYjId+4/bh+M7aPWauIuKv42h58mkgTeOVYNlx8t2NJO2/1+9D7RMPsfkC4IBz7ZNdI7wws8T4+i8koCAFEukHGCOUeFl0bogUH1cRwuD34m3w4fdawaJU62HHYAbqmsln+7qoyFm/hNlmyVCP90iAhHsjfTMBPJVCcE+mwP7csDuuROcalLfAaMgFywed7ViQpJVfKJJv6PyXzCgWBt6GLHZJ9olmCzS7zykg/5Cueol7nHk0C1u3MwvPv1F6u2RzGlh5WSMATXnkILY+BeLcXTtK6g66EtLk6ZQeDX5PTpm7iMkcfmLN0Twjbn+cXouJ4Mfg4fgisobiGHMWQjY7KGUYMwCi7vmkVStoFQtSDaQqgEYP4A0Aguxk6D+OoRxNFI63oRXeZaTEFuH5HrKdeqW9UEd7TzFMhfudhMM34rXb98AAAA0AZ5hdH8jvpdGROI7bSMKAAADACMj2cV31f7qpkPemBOJNdf3vf36M6gN3zC4osWpqtDXeQAAADMBnmNH/0N1x6D8rbCTqAEImIXY/C6nT/gzHYvs+x7Ifmu0wbVuh7CyJslawMgBgP59Mb8AAAS1QZpoNKTBG//5vW8AXlZAzzbvQt6LccWVmbfXW5TOMS+Sa75eLgodS2aJ2PdBLFmx+Nl+iAxUrh+aH82vePF2kbxo3nx865/9LDGPSQab7QKOjp0GwGc6cPYijS6t8zNsZszcyLxZQU0HHGl2AWf1lX6aL2R6nsSFqa2Bac7e9NGJllBJjMr5mTh7kxVgXVaWf21lMkImZiY+JWbblMlqOMviNnZaO5hjOsFRAHrGqLhpdBz1JeXt6V9QFH0M/7O1o3h60TuF8Sr3R9ru7CZzQeQv/6qVoZ6uGAVPsm13kEbOlAvTjZpkYLL9krnU9a618CVK7xOUjEcQUeGxPEIT28zF9X1ewY5S+FpYHpUE03tbv9/G9Sziccrpj/YpskkdXC3OvrqfNTkAOH/Oe4YlcmOMQAw/v4u7jyIPDgSIn3dvl01QmBrm6VjGIi26w8zlaBCMl+DV4QpuPT0Gyhc3y0YZPBjIl1nP3Odik8EaE1rE5SMbTjAUYgVQa3+TF6yajDxqGNiWM9oo9HXh7x0N91HPXB6TzQak1rvRlWbYBT5S8HltVYdKqEUqrtwV5SgXfMIbJUKdAKLHvT4m4HdqWM92104+vFIIPtWT4COFVF5U8rtpJa8Huh0/HGqzvUTrRH6Q7szTn7b4vmMPDNRTNtyh+NUTGGBpE88FKCj1EWU5gSDHq4SvUrnk/hq7zQwJc3gSuyJADFgrPQ93e300gpzKotyofAuqXm38uY36/9Db2KRcC7U0TRdaVgi5rwdNfOlTzI6Kf/xiMs5rPOjtSS62OtnwvRAQtdIHQc/DPF9obIF6eIidsY0OUkp2gNjzIv06P3Au5C+9VlnEpwTeDlS1wywNSNR613xqSxUi45cFRB/MXk8We8QjIrhrrhKrIQQrMidMrN621RaZHielvsE5MGJIbSmpCwhg/mTyDdbhiXQahZbhAicynmqK66U0pyCcfy1ZxNYRErWt/R2KJWwnpxqenjYbbxrjyn3z3gg5ep/F7pR2b6X0u8JJRJohwLEZgs1Hd5htI3jjThXgLzesg5c1yDlo0nF6Nv0aBESNQ1k8GH3HFsEQ4av90nOpFyqyV0USDP1vSNagRzH4hSdCq9umKD8pITZtqByw2MYEJY5OKnzDd8uC/fe+jhhwGwDIDhCLkGSlalM+t5Wz26M9C+3Iu8D+Wjo/VIVNtOcnW0MVfoiCBV+FEwYueusOYDi8ecHp3xaN1FfFVeo/XKpUQmDcUadEiV0YUAh8Tr9hmpRWkGY7YjkT73zNMg+PkIzrFWFuIX5Ijs/4pzz4NbYKGEfsnbIDrUK6cOMonm/F6OMILnIX27jsarrFj+1DCk2ct88mqeBUhHAJy5XNrlXi3uOFCqUFnfV/UQjRICOgsWJgxqHGnCDQFpLtZRD5ZAdeWB4bSFTRH7pWhZAQpeRkzhLsWpPIoXHd8n1w7JLIssRnKaOA3vJn3cib8PNGUM+Id8foqBjALDYGlDVVD0/gwrk3Km3W0mQoCPqxNVCwaXnf1dOoQHFE8zQcXE+UcLGcm1zND/IsWMGI9TTBI6RzGEsIqzewpdrBM6lZWy8G7rKDQJe5m1+avNqi/2Ze10sfwcEAAANuQZ6GRREt/4drPKDmjD9SiP4GCKASnBhaH/4fPqahr+tNx8A3QJr6gDLqNCrU2q7Xzk0n6Le8xKgjYPjFtPACl9K7Ck9S3sqDyq0e16eLDlMIA3/YJxjCa8NlzJH5EE41PD9bW9VuA+iSHP7X44imUUfrm+K5wdCD5/qf07n7OnFgycrEiH0iCFv8314/5VRGLxoThSbAIV+ROFRdqBmO5kTdVbGCz7AtagWcuKpJYX1ZMDg+5FA2L06zSb8aLMiVmJTan5JssqxZHhqimr8xInMPwQkLErnNFHmMihKJy13HHS+6P4bdfQb7NWVUmEDEjC+lv7lpCVQ9TKG11yy+7eImtHTMKUCeDCvdpHhR/9ByAFWWbLqGhL53SbIvF9uITp5eoeAunyEIPaiE+f8nelSJzWSHUMiJ/nlsT11A7uTV2AtGbUZXyLhoUOo+beBYkqY/4XOVTXlatxjT8nVeZqwgDmy6H8iHeWctIeHPwthDz0Y8lOeDLqLrm/WM3NzbcaLw+SdOvMqzAbIBiRNG/OSqGnSzH4ymks6B4Ox7+RQTMJF+YUrz92144wveNJ+m7DDJcA2Ef+fL5eoK9Grdax5Uu+A3AccruVvICHB6b1jDAqDaU13yeoJ5GMeZpus6/RSeZJSjilUPykAVMvf1pwuixlZmts7+tBRYu5me23mvLPIVbLkX99mon1xnSLV2xE7pTubTikkMPzHHvD70vgR6KpTvu2J8xOZYstmTXHZC4v5LKgPu8mxqNfPSI0Crqulu9YFUNoS4Oqz8Gf2K/Nim8Bmqy4bpwOuFAfztRd++98f5zsbOA2liloQ7QvjwnG0Ugg+WbcwaCNF1EV/qjx//8IkvCvMy8irP0ck7C+mOsijbtTiU240X6Wvf4z3td34UrKx//9D072H+7vdTVn2GTupbSrFbD/OnZm9a2t+ED814PoiiIkZGzsn7rn7viElX01XaXEbVeQQ9W55VCAjvD6MVJ93AhFq5QErrXshPA8JvNekBNrXCnDXPrV+vmwSo5nMsssCSDA1rpUltz3yb1SmOipuSenvPxKoGII4hOV5mUva/kgyLYkfKKt1m+86Av7Vd0C4Kt2QVnHuXvpDefcUCI94gFT0MTxcdcLdo9n/l0wC8mHVI+8uz27Yxw9E5LrFqtWvEWZwvqVEAAAAlAZ6ldH9bMAXa2Fzy2uy5AAADAAAKZqjGMW5qDruI3pQ4rL2czQAAADgBnqdH/4LjU0waEknlET3d4qfUTHiOfuB/zYJFbl0E0ddeRHiNBBVsCJI+qipEq4wifvaehZYh7gAABZtBmqw0pMEX//lvAo5ICKiMCnWCbjZk0ARFeHd/8wpo4T/Er/7PtbIWW9SKqpretF9SLZao2mKHZJl6CSw0K+jJxtnXwZUt5/OyqqiR095Ajt2YqgJoX2pt3S5K2R1/lCurb8Dsd7N06ZeyfMh9lbsBSYym6srOBxAeVcpScHXHW/LWo6nYJg48sNcSIL3y2fcm25IEh3Nh8AJxLvt8FQGR3R2+dRMT8AJVNGyazFibo+vxZdMe8sSsrnxen2jr2CRdcxjmYuEt2gjiSX5VEZ5f6K5P7zlwTzYrkV1nKw7QPyVrwnupQISZcW+KgDk7lFhi1YszBsmC37X4lDWEXTwRugEAH+HpGWeW30P9VLLfLK2ogQ9WN0PagtN0sdh70Bz4zcnEU74hTKoe9bwcW3JbTTOnLA4xUcYWtbbGTwmYzFwiksGOgqFsKM0vebaCAJEgktRJyYxiFMgIUuAsFTcdy8V/aB9qw1yBM8t8UxTuaini6qf4oye30SseUwDlQFd2IwYMTbVF1lNZyWyWcnm4c9oHzta+OPcPBFN4zbG6aPdoYJFpguPf/dBAo9E8sIdfitBGHgN3u8TVqjj52XkyfvWUF2LoYifD+e2Pi7PH6QJ0vgaBBj0ErIMoNNyexJV4QPcBO/V7BU1O4a8b1NU/Uq5hZqkqbRtt/hmxE9/8tnLzffPgZ46q31Ln76tvmVdTCB8zG0NMRkXYlBHC9Jy7H9YA777reCZX/gSuHrDVU4jyAD3kvVITXa2Wks5O0qst/9qfjt1KkhwaCfvF1Ei7YQMvvJ8vnTt+Bhbfv5H29dReRd+J/RrIyMu8bKRUHe9GKoyqgpv+v1LYQn4enbuu0xpl8Da+d7LFi4J3NwiovnrgWKYzR7Pfy1LX/QiOpw9KSJNN/IuqWH1HG4A9jVRxIoMl0gk2v2cT6c4PxCTrbTNVrH9uMlwSc6tqes0c9YArHWeMloFSQV9tEHKYniTHq+3+lGQ7pTPpeBPicPJSHnx4s70sKZaKr5Tt2pwDUZvJWF4PADt5a5HLOluWKbi2k5t8IP5wG3FnUYc2LVCxKwQ4dMIIAH0RSbXlYfxI9ke13VBniXSuTsHgW/S2LRnYXAt9iFD9qufwEuJ0J8p9lcAIMLjTo8z5ezfpbguHut3xzaDwqFaHo5SZSzUpJ4lPDjsEGDUsxoUz15VdSh1nDvzInfZThX8cK19M0ASbSv/GrdFpuANrAT6SXBGeRJKuq+fBOJOr2tILJYACkvmPqkFHCgsNg0gSBqoy7e+olW28U0DSKzjmMkSA/pt3Ozk8dfRnNCYT14fpsw0uNhmSgyV6zWOVtueuVm+viPP/GkwE1eOHrQXHLnXVCEH8Q60jr04JfHpWwM8/RDK4yHFhzOIDvtUbX9KHowRkUdgvu3sJv3GwHRE622afLBtxvnJ51Z8aeKnxBqN0Ay4G9d/HzcmBLuH6c1ncaWVHgUbamtnJFpk9XMalXptaLndc4A19rXA3fpcRAkL7N3W2PWOU6igyHfwni3f4ZxiAOWcCGid3y1rRt2Fpt8/WTF6psz8YU+W3mMiTg8AzAn+e6YXwn5tZf3OFm0UtB4TEs5Z1JEjN5OzowLKmotQAIMvMDffXysNY5i7XLyB4vbJ0ymO/XAeApKkW4t5meC1NOMhy6TQAYJPnxA2wjevhX3V3nPItRxKXw/8qUb1VLG8Tv/yNDCfBVLmQIZ8E7ss8iM70dr+XRE9GS6hxfgX1Wrg4w1XZub7ZqLjVudc4nQ8DyF8+Eu+mGfnlmGK+nYfMu4BfIE+FfHdnznEL+nt5KX86bERWzoUmxijG/Bzw+Y8vtCNjzViqb/miI2Ov3d2meHwNLWpyCIN5R4llgC9QNae5xzPfPWseull987QbmrTdbpPNKzeF2OL7uGcv7noMAAADQUGeykUVLf+n72rcHlCgR7xSO5SNlWHKJaNZ2uvKGimyQk0aChNS9SgDeHNoBlRzm0WV2ain9iTZcN4RhlOPtuohVbV3n+m/PzBbkuwXx3HuirzyqjmtZPzqMpTmZ0nYVxvl/Z0lstl9p2+QSDfE6tBYACoHZbHNVcHgwPfIArlPUmlZB1etVVXpgsoAPleGxqdIfx1jBhzNE9v0EkE8NLRy3Xe1LbwmhgvIodFF+kshgY1+NyLvQnNj0BgRRlMlVgWzxgTE2npKrt89+WxoiSQh6rzgPIpbK4OXnvAIQm2zSIFeeHY8/rFfI9wG6cQoIRU1SEmFo7QozIYC0AMn71vnDCc61/YaqfT2I8R+rfrnuMLyvt8fUMfwQOz4jvCzdd+U3HWwVL1+CO5Juhp1mqSXabiqctSXzRDAKIuz7Aq+GhRwEuSVE+X+nQQLoDO1rC3WBwsUhsR2P7Q6Deb3EU6BsU9L9Eh3MNrDxCfinVld1e/QXBp60smnuzIjyeOCgTHQoHuyIytF6cdDTPK2ON4m35SbtB306pd/zzpV+wIwKlO78NZFHTZOxw+aHu83bzlBHUQnoyqUryQYyqI2Tf/iZTYX9AuTk+yvxHbuTzkQALbUUOTcVqe78SPHksy9f2FGhsZLwlPZMhI1HaqxJ4BUNGBHuF74P/z0zN5VRlltLqH8o4naR/+ZszLKKRayVjTxQoAp63WqEim/zCB+7JlLPv6D9cGskMKy4KnZ00yaiIHCOxedTKoB1KC02cltW379uf14hYIwxN4ac81hZf5oT7tcNUq4KsJjFtb0oQMBe9ngWj+EdksXhfWDL018GMTQy/FERfY7ZYnymC7OZltIG8uSgdCrfo4ZrPD8pmVbjUyahJ0U/TDhAvgvSgE0Cj/HMMxMZBrwpkO36JXwdOB/61cncUK8/euByhtxcs+FE6FjSTL3xktr/jWRaoY4RY5CJEJzt1eqdX5vYYAARY/uFm9mZc0/ekMbZL25sNC7dQ1/XxA7tY7svSawSu0k+rZgoGofT6F3cCn0YeanVHTuopIBDzBksrskbT5f1YB/BW4UKbGdyO5UX8lKygNfI4QEO1djtAv4vSOqksTfatvlAAAAMAGe6XR/qeJdJQyzgTW8TRTdAACQt4ei4m7Uex+ql1ViFkEYF6ozSQlCUs1nd7HOQAAAADYBnutH/6sUlfOR50DzQFTUfcTLetlJWs6X/8jYcTb94JjsaAAf3GzrPRIWgVL9PGpuGSwY0uUAAATZQZrwNKTBE//+DOAQNseATVIvvulM1zbNTm1kte2zdhlRzbLGjXY8Zu9C5cbwE2V3T2N3NU2MfQOoskso9vQLwmv2yjuyydzZI49SM33U8h6P3tlK05g344/9ooNiw1zXuh8k9WP1sf3yCyXlfcJvvW5GG6a5tZKgf9mvUtxG0vyB508xmHXcscwTso3hRUjkvTFxnW9aW0FbgqqXy+L4CRf5W1lxT2Pes6otdiQEBs8CHhvAeu+a3MhHY7a+hAD6kKFCCam09nAnczhYcCu0mpdoxdqyYPpA1SFEE3FDnyWi//iFsXqB7OFT0kT1oAjadoZtgNVOjL1NfFD7aELCwNGPnuWDfPEfGGXRZkKrLqEDZUzGU+KAuCCUkKC5qPWN4qhcJK/jMC94fGVVle7MzKtJ0za4+aXyVTIgIFvJftwMH+C3DOydSFvoZfxR5T27ZuDo09kLmmweBV+B2/wD9f10b8RvMTL4rMWkSw8vepCI+Em6obnzQ9bRctkLNUYWR6cIfUCJQ/eCe3yXiOF0phR/gh0J8Yi1CQtHqrveHMXVep2XwcFZSa55YitLU7Fv1BnWakQ1v8goIUOOmJ9xTMj0GlEheiDTGxzd6BDHjhKgq7oBggzVEsD4IvjsC8Y4dDTH6yXZHg+nLn5GfMhPj1bCbG8csQuGNRHlMcI/a7eW1eO7LdjAMDezLNuXNe4Wzt7RXEk/fTZwqcPJcUJiO1VTkXyJckJjIhiU+K3LW2VGZp2TPsJ3Ucu0c8yZUBxPxNGK9IY7J/fJWx2LxkYQFRNppiZfSROJ3bGz/u6aNgw5Sff/AnBu1jZHwPXxfGzSnnA2uKFCcysOxO+Nd9a/BcY2tT9awdrjS/nXtLAQ2cOf1BDLTZRTgPm2LGaf2u4/lBE3BjNWKL4550gSRFtiwQYjZrGik7RTQIzMEiT0u/Oajg47gYzMmQJNvw7T0u9JgvNCUYZoHVvN7rDPgrPub6or3TcmBZnEZL4xSbN1drgxD03BgNUPgtX+wEcnmBi5L3MZ1HAYJbO30ksBttEKYJxBDUdsiprLFps0q0WscpbStan4JeCgyCYth1WLz6N+c3HWieojQvRhRfJj+cLlhU/L72BRHGdFkXhoK3UlaUvxMdS3u0OX901JHwljUdlgi8rK6Fq2mQlEGU4+Vx63OTCQh0V2TKi+jL/X1bmonSMvHmQFAfVnbJ/H/9M4eRal8Va9jV0hhiznkDv+lHrLMfnIUUIVkRPQISczfY9DQjmcZtTUcM69H4TSpM0Waqe3KQO2pGOVcSHC1QUgQvKxIr4Y6KNLffRLgAI1Xp0xkxv482b9GRY9XzUxUxphu+5/gTMyeNmYip8u4Xa9iwrPIGUmWFxv28ppd/3OmYyq09aJXOYqIalqD7SeUD3lMU2VdR9tvO1IUGPT+FkXDTzq9sx1h4Dtr1YKiV0+ZQXIyNRxwxT3U6VC3/lFsdoTIjqfGbX+C8vlpHBHsNaRLFy/QS6+KWdBT/Fp5Fex05aqIgRYuSyL0tHyvv13bO+5vdlhsvvGWAP8jL5gK5exNS9/UtyFVbOVDhaw0Vn0OnrmhJ51L8NKUM+QiGOy49YT5o3lggfNS8wB4pcR/Jn7zGwDge6vLHEJGzlwyMtLmZxcMUzaJp2SSik+w9MAAATaQZ8ORRUt/3aCTJFiGKXks7idcOR6JHQvZkEhSgtc75eJYkqtyt+Le9nqbcMUA64gQMMJxrK7cKTDERSe7wZvyqJ2SgwVzjZGidDi2gK8tfkv8aMc6i084e4BYRi5RVb0vxRrZOGLvrc3FJXDIzi03pUNBuMVg41Yog7WxLETzIWBOdzmiv02xmrQBQSbe39slcc0tHTNtndNKbmn8Z3Vzmw9U+siJIo1q3Jg3v1+1VOEs59cu3Jpcw6++GEU1FeIRkHhFJr400BmLhjMqYoEwtLNJsDbh4bYnmil4aSU5zjPk1ByBPHAjLoesoU/zdqOAhLx8RLKavVoELf3KbZBzdtT5wtq4BWnKTMy13sLoIEynOqObrMZkgVplgFqkiF0cAdwUq0538+jdnJbHC49GBLS50cRiUglBvhKAEVTvsnZiA6WaScJmmBi9mG9KeH/4fohe/zmWg8XqOE1I0xT6QBIH8ujXi2xi2a2IEhtpIzHyQN4ShyC/NX4M556sv/MkVkqf6ZtTe5/dgOwHStK6ZkSR6D//WAuzItlGs2RmQffPWkY9j5C7ItFNxwEgYCTdxmWGOtWbedlEmCr5TiYxvqmm2szhg0TBYqAqyNoVcQXGn9mEXjm28N29IGs5HDfgjSbq9Vdn1CwfyW1g9gVqF3ter8Z2Qeyv9VhY0obQoQ83/0kehk91ISwqKUM2L5uI6YPg/QlDC9kkYVsXL38EM7B3wmMNhO06fjwcRvDh0nUwm83LUgpnqHsRYhmMqU24YBkUMWPzG9W4BYF5GIZYKbHqB2iTcA7raiuHg5P7004Y1XADNsoLc7vY2J4NOF/WYZAST/haWXJ22i49wBs6iu0KrGS/Q7I0fWaFzNH6PNCZxy1kQH5OVJp6P7Mb+1xqi1eeoMGoXfegHpmzkjmjmOFM4J7VbPO9fyl4TqkU5S7jzPyr4KcEsPF1N19zBQCZFAfCpq42/nfCxI517vqCPdmOi5xtEB4HAVJM61Pu+RmiSrlnnHXWOVOdELpJOW17f6Rj9ZTw5Nvspd2pd62JBX/UTP7MMhAjWIofKKjEwIwGALJ++v/HuCXezOuNJrEekkfLlM1sZGBfXxabJmi8p/R9nyMTs9kt5M6WxLVg1odgQXIim4tDGXC4oFBTgJxwIOwtgFK/mJ39CdSLRfP/ZmmmCWkjVuqPFp83NUPilJbJP6S6ZfduEWCV0LcabcytOn6WNqEhBbj13vkfNsxRcSNIFx4Cg85xbRMM86kGp9D1rMDTA/uIHjiXJHAEST9+j9wBLtoS4uMy2Prv3sMMtmcFca8SoYGWjYO2aEkOpG3rNeGQEAw/eUrNERGacYR0zkF1zibdjJHm28uOYYorqji9UkOlWtJkuRIdN/g18tDtCdB1KlbqmVFsP2+jV3SuCGgataGKAKEIU+Or3dWLtkzUZZNgUpEO694yqXsH9BcXNvX3YP5Yi2LuiP0X16LdYRlcEHGB6LszmLFzo3iCHWDTz1xo7BMBP0iTcLFlvaNTkxPngZIapY/XmqHHg4FUokFzUjZ+NTxyXKwwdvHtN0xqHP5GJvocQ528lgFg2xCUiZKlmIgkBw0SXlz1bp9ReA280JMqAH7pAhHllW0YAYrZHxdZfJQaqgtglQZ7ljSIFW8UYNFJgWxAAAAKwGfLXR/Wy6G0XgAAXTnwgAABZ3lcr4r9A2Up2nEWqgHlsPyx+XK80uYPOEAAAAyAZ8vR/9zj0IFPF5VQDMZ9QGGDL3mdQ35Wi/fw3WLQ6XPgpKAg0V7LQ9cUgoShCSoyQgAAARaQZs0NKTBL+nkrLWL14BgpvYwUI0QSlXD2m67rqJJ28+NTnaILhEiFtNTHVd6A0P1pq8u65+o7U62OjTL+qIvGEm+koebxxsvM8zbUr7697FtF8OnyP3WcgVIJxF9unxBWWGRlLtsbAH22NoH8XUvn2O+ZDIxmKDhMG08O0zsRayxFveTYnHH/YEbbxTEKxqJII2deeqAC8rsnaUnUot6DP2EY2sdnodLluPcquntESzyywrwIBRqMm5WxkyLRy79ld7UmLYU276tf/ZVwrwNdR9LeU9iquFKhSAZV8gcZZo9S8CtSJw+wi+V7HUOlQozyXeBkc2OHv2Ky8FTXCzxhCkrnHUUNaCSfN2mIj2vJBazw5cVVSTuhp/lkNxNmPXZKXiD4kIHSwKC7BpZjty4u8Vi1gUenMmt5QDfm3nVJ/V3s0QFGg1cIhREv3lMa0IOadTK4H3IeuYbbYxSzqQOvs/N+6bnzq0+lk5Smf0Wl038jxt8REJs55FE8L/RcRijvbzwMD3dxiD7/likYCdrncXst+xmp2HCAStX/z61kIIYzJvNd9gZxnYipettHIOYRKrdc670N6lRW2WpaoL9l/6vaXlGkJijR8O9ed9fe/vSCYW1PgkUMV/Q2DUNjTx8jB9kyw4xk/11D6l4YZKhRpQYzgjKR7vnhaozCTAy4ZWmtogw6GK32dLAFqfGYo52nYzhlxpE9wD8ip12tlNh5Pu7f/WskgfOG1/CJ0exyD9zdWmZT43SMIeLc4JfMQphlEMptD7B8hJYSn84pC+WH2UH8cBmmOdCyupNvBMu3HfqLiLoCscE/E/6yhqxuvmJictCNzhKBwbvEFMlLZlujFCLEi9QutZfySrdn0J0+zc+w9ue+3QBT2GUWnPo7gNn+zxILF6rY/xsmJ8Ib6jIIJtaB6T/g2rc1brOXZ6MTw8LqveaGEg9UAsOgslabRYnUfDD7V81ao05sSbkiBhDXXIizByeAPraO45DBDFiJewT6kFPoq9PwDoLw5QyAYkORO2ntVIDQB1OkMvogexjf/K1lnswXISXB5pycQ+F0/1R82xR8WCkIO3HcfIPqAIDPXv+XaqJxdQGHxcz/LgwvJAap7ppGASsGOjqBryJcPp0kEj5wMxAOSvb58yCQ1ZKq8VFOOP0UDsZuec1SVizLK+v0YmXNH0Wl97r9Co3kJklMgZkxYbI8DUBMbAp3CQQPxZC5AwiqgOkhJf5Wwr+f8FWoSX4ISzr3a3XB4pbkc7KT2jBJ0Vg0YEOBjFLwFlCO9bW/PYRn0AguiFumYpR3V1I0ikGbqgE6HBOauwcGNH/gpMP17Hs7pV7gWU4NZH3U9XFK+PDp7HwHWezbvfZYUeYleajAD9gh/rjzloQIM8YOiyCS9oON35edC0EEieGGCv5DB1IKBkWi0YHuK65Es13voLml/nvfnGFg4+IogBN3gCMzILwB9weYN2jDTdVt2FDjzhX+Hy5MAAAA1dBn1JFFSy/tejAKQVMjP6jHG4IE8lNyOEr0Yj6uzSAKM471vUmt9C3RJAL0RcyuTIc9MnT4jf0oyvkHPusajauDLMWLejnXAQr4A1Upb3PI6kqvqa02qnxpXLtL8UrViIpCevaS8/gYwR31mlpN1B1TQRziVFZsD9rlMsi0IPexOLBYIkxfR6JgR6cpjWkReACmU7Mwj2VeJzL9DEx52K0oGAxwHgDQ9oZmG0aH+x54FbTJBYOuAwShIdpprOX+q0nhAD50W1C86/A7BAFlnzwWM2OLejhLm/b0UNChH/ONp+nT7uu7MltgWWpGGtLc7d/oEYNhu2Ktre/FEfznjzC48tSeRzySD4KgjY4U69DvloDmWtOzQ19dpkXpSuPPz2kSbb8BxkKjVBz16B4T7xdYvX2SA0ueT+0gPn3GKBlRbB+rM6tyPzQebNdqqVWGVn3ggl8G3WM/9EJualCe/x/HglKGpIjV1iAZjvSE+p9r0WPJnMI7zV1sDiFK1dsWWgEju6OAbBPbwixUa2M0HBDTLFRIMERf114SXQ23aMm1WtBdaicCqIy1+EybI+guwt0rTpyXpYgOeA0jzk4qaGirYJud5PKCYTnUlRQ/AyjN987sr6XAKCTVrzNsvK89DmaNUNS8Gn08gA5dUVc7m98/TndI/yCjZ0Jyit8XR8uOxcyYHPMLwTOyKJ1bB04vO4Xb4HuOm0yGBpYha8c5lGJV1jjX6zfpAHby/AKlIGbio8w9ofyXUgv3qtJr8zVbO5M59pledUAvJG3MyIl2sHZdPj26hibO1I65ox4aQ/wy5DnDHI7Hg+V/RY7aRmBHqv27ModFwTsMVx1/v2agG5OjblzemSecIepH4eVH8igZ6MlQMAORp/EcovUYdrhmmJAv9qsNtcGPlUS47/+7hGOsHfi+trGNIs/VMGpT3T869Si9kKXOjqeb1DQYJ7imKB82VntfmjVNGmv59mYz9HERieEst1I/iWaizIQ/3X1rRhkw8L5q/J/UhYg48hPOAk0c8hNR0PIMk/GyiXocACfU6UeYxrAb0GVFkG9deuwVC/xt6/kJhMZbjSxt02PZFi/zxjICY27ua2U0N81W/vnYniG9lDNGeum+FrR7w6Qw4cTV016vYEAAAAhAZ9xdH+p4s2UH52lqJS2E9cEmAA11wMne3ub36jm2kZgAAAAMQGfc0f/qxbyhUA7THirxCHylQGNqodn3lDZu7jT/mCovBpzUqOKa1+blNIbAHSp9UkAAAVJQZt1NKTBL+RXFlOh+KjOVqTTfbdWmxm4pHHjfaTgdVRG9dXt6WOuYh+zWSkOX4/dCTB3U0Exmt2Ogu9zrm6ePGEJsXcN4xmMEh4Xog0VK1Ndga5IEemp4rOnmecJzRnqp1ZoHewYQhhj4qjHTTDCO5JOt0Ld2PHSJAc8s44DGDB/9tdUVtnuK57m4YDBqGNltuqoRU8x58q5krVZxVIU6p7XHGUZiGP6VWHD2OnqfNbUT9pOEBr1eBMi04BWAqaS8lPa8kxJDsT15kr7BKbBwHhyalynXbaJQFRYi/0IKiSZEhPbkQFchTvidC14OCCCKddQitI1UXWq9vpfVVkUMdK2efoCjjyohvIeiI8ON+6tJdSo/VW6ISG87RGa/DpqKsXGWK7O+J7B6VO0Cq2NM7gJBoltji8tI2iyRAOcoOTZRrtcoXD8WwS7lKFBoRxoh/AwY//YFpz0jjgjsHQy/K+Ez9eb9zmmWW9DCTHNa57paUFWqOL02f0Fxyy0sVVlYim4MWba2o4IjklbMSsUMM+FbKNhBr2MlmxBafJwcTYdfd9vw/y46NKCdiJp9y1J7On5yQGz13V6RYMwpWAmU662tBP7d1OXSckVUgbndIWyfkN0ZsICB39VT8G54t/PJDJ2h4JrKTkNiqEiT0Tjkl+jhqMOReREuCRd9qyeJBR44lid+EEd+n2itsYETL65wNCVKsuuhCnOKvLMq8N6j9lWsoGFfRkQuGipTLoxmbIUBAcYRBbFJNBrsvunXQsPrxYmOprEQTtTaAAqKNvnzbLF5it6iG8Wa6OKDD+msXDDXl6Ho96pwSKNrvFKUlEDJK71X2whkKeiQU7xgzy7HG9KhDcNqLQcHQ1D4aZ9DNKCt9hgiHqu6fJlaXtEYwxRKRnbTK2lSgLoo2VHM4NlLfOyc8Fm474gk64jEMAfNwQ4zluoV4iGjh0vMUC9buCqqO0wqhXZAXXwMteRdAEBTnyZ/McyHWiIpKbSYY8H1jU2RrVrg4/WGZeBoRUYXM8T9v2oa8rEfZdqF1/PXyX04qMWrLdu4LIuCGVnPGEwFicTbFZyxz6SLKWfF+PTM1YhswdZ1HPuWIqUMZ7Hd6ukQtFnWJWHLSJhj+8eZEP4ItJA0cu1oQ2xRJ2/uTDf3rundAUCDrBQEIfuw1uUQbtvvYmOMkp7YtscAd7ZXu5gq7/2tZW0M9/1BykLzMcf9NJ5jZXgrAyuY9Q3Pc0PfG7/hwLR2GNFnNYSEm84VYpyX5Ixr3Ast82qSd0qt0EdQOnXhA7dZmsyTBZbLLtaTcwjo7PiApjVt0PnhnmI27uZaFzKFa/SoP+BMsYK7PtpnaiR5O8wzqBzitkNmpgEuBUoz1MXVeWOU9cs0qM34aSosyQy2dRhX8pon1ZrNupnwe/poejl/D2FpEoPfyFnjqj4R9BLi4s6VEruViIwMQEqh09s+fwwaJg7hIKzfuxTsyat2UXyO+aSGqitKgSZyMt7S171xZs3KnYoPUppUWPoku+xoXNUyAMcddzYungGAJoEsnDSdbjQS0lVXJYPd47grc0vPFpEv6RCK7Fs5d/4BLZMUy7lR7L86lg0SRba+V8eKGn8Ce1cJ7/FysjyBRut6PUKq4NSmtAc5qyMe6/2cuJ/yYzFLsBif/Eiyf0Z4pBhaWEkOOR7bX/FgXl+EYzraGJivBv1tusEPOVoz/8OKofPlCgFiXsyoTP0TZ8r4mBTbsu+7U1+6WpcQN1aLHU7yWI43k+8MQN7i1c/5dY0SuSnwC/wFDZVJqeuzEDLhFoa75b5HK+HYaFhAAAAG0Gblz0TCipb/4cATgZovIQUgDcqUc47H33miAAAAA0Bn7ZH/wjcNKL6EPfZ', -}; - -export const Snapping = Template.bind( {} ); -Snapping.args = { - ...Default.args, - resolvePoint: ( value ) => { - const snapValues = { - x: [ 0, 0.33, 0.66, 1 ], - y: [ 0, 0.33, 0.66, 1 ], - }; - - const threshold = 0.05; - - let x = value.x; - let y = value.y; - - snapValues.x.forEach( ( snapValue ) => { - if ( snapValue - threshold < x && x < snapValue + threshold ) { - x = snapValue; - } - } ); - - snapValues.y.forEach( ( snapValue ) => { - if ( snapValue - threshold < y && y < snapValue + threshold ) { - y = snapValue; - } - } ); - - return { x, y }; - }, -}; diff --git a/packages/components/src/focal-point-picker/test/index.js b/packages/components/src/focal-point-picker/test/index.js index af295150f6cb87..d5c7946cffd860 100644 --- a/packages/components/src/focal-point-picker/test/index.js +++ b/packages/components/src/focal-point-picker/test/index.js @@ -120,7 +120,9 @@ describe( 'FocalPointPicker', () => { const { rerender } = render( <Picker value={ { x: 0.25, y: 0.5 } } /> ); - const xInput = screen.getByRole( 'spinbutton', { name: 'Left' } ); + const xInput = screen.getByRole( 'spinbutton', { + name: 'Focal point left position', + } ); rerender( <Picker value={ { x: 0.93, y: 0.5 } } /> ); expect( xInput.value ).toBe( '93' ); } ); @@ -155,10 +157,14 @@ describe( 'FocalPointPicker', () => { ); expect( - screen.getByRole( 'spinbutton', { name: 'Left' } ).value + screen.getByRole( 'spinbutton', { + name: 'Focal point left position', + } ).value ).toBe( '10' ); expect( - screen.getByRole( 'spinbutton', { name: 'Top' } ).value + screen.getByRole( 'spinbutton', { + name: 'Focal point top position', + } ).value ).toBe( '20' ); expect( onChangeSpy ).not.toHaveBeenCalled(); } ); diff --git a/packages/components/src/font-size-picker/index.tsx b/packages/components/src/font-size-picker/index.tsx index a06a6529b0dbce..e454b3093bf6a5 100644 --- a/packages/components/src/font-size-picker/index.tsx +++ b/packages/components/src/font-size-picker/index.tsx @@ -14,7 +14,7 @@ import { useState, useMemo, forwardRef } from '@wordpress/element'; /** * Internal dependencies */ -import Button from '../button'; +import { Button } from '../button'; import RangeControl from '../range-control'; import { Flex, FlexItem } from '../flex'; import { @@ -24,14 +24,14 @@ import { } from '../unit-control'; import { VisuallyHidden } from '../visually-hidden'; import { getCommonSizeUnit } from './utils'; -import { HStack } from '../h-stack'; import type { FontSizePickerProps } from './types'; import { Container, + Header, HeaderHint, HeaderLabel, + HeaderToggle, Controls, - ResetButton, } from './styles'; import { Spacer } from '../spacer'; import FontSizePickerSelect from './font-size-picker-select'; @@ -127,7 +127,7 @@ const UnforwardedFontSizePicker = ( <Container ref={ ref } className="components-font-size-picker"> <VisuallyHidden as="legend">{ __( 'Font size' ) }</VisuallyHidden> <Spacer> - <HStack className="components-font-size-picker__header"> + <Header className="components-font-size-picker__header"> <HeaderLabel aria-label={ `${ __( 'Size' ) } ${ headerHint || '' }` } > @@ -139,7 +139,7 @@ const UnforwardedFontSizePicker = ( ) } </HeaderLabel> { ! disableCustomFontSizes && ( - <Button + <HeaderToggle label={ showCustomValueControl ? __( 'Use size preset' ) @@ -155,7 +155,7 @@ const UnforwardedFontSizePicker = ( isSmall /> ) } - </HStack> + </Header> </Spacer> <Controls className="components-font-size-picker__controls" @@ -268,17 +268,21 @@ const UnforwardedFontSizePicker = ( ) } { withReset && ( <FlexItem> - <ResetButton + <Button disabled={ value === undefined } onClick={ () => { onChange?.( undefined ); } } - isSmall variant="secondary" - size={ size } + __next40pxDefaultSize + size={ + size !== '__unstable-large' + ? 'small' + : 'default' + } > { __( 'Reset' ) } - </ResetButton> + </Button> </FlexItem> ) } </Flex> diff --git a/packages/components/src/font-size-picker/stories/e2e/index.story.tsx b/packages/components/src/font-size-picker/stories/e2e/index.story.tsx new file mode 100644 index 00000000000000..cee44efcac8b34 --- /dev/null +++ b/packages/components/src/font-size-picker/stories/e2e/index.story.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import type { StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import FontSizePicker from '../..'; + +export default { + title: 'Components/FontSizePicker', + component: FontSizePicker, +}; + +const FontSizePickerWithState: StoryFn< typeof FontSizePicker > = ( { + value, + ...props +} ) => { + const [ fontSize, setFontSize ] = useState( value ); + return ( + <FontSizePicker + { ...props } + value={ fontSize } + onChange={ setFontSize } + /> + ); +}; + +export const Default: StoryFn< typeof FontSizePicker > = + FontSizePickerWithState.bind( {} ); +Default.args = { + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: 12, + }, + { + name: 'Normal', + slug: 'normal', + size: 16, + }, + { + name: 'Big', + slug: 'big', + size: 26, + }, + ], + value: 16, +}; diff --git a/packages/components/src/font-size-picker/stories/e2e/index.tsx b/packages/components/src/font-size-picker/stories/e2e/index.tsx deleted file mode 100644 index c2a038e656b55e..00000000000000 --- a/packages/components/src/font-size-picker/stories/e2e/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import FontSizePicker from '../..'; - -export default { - title: 'Components/FontSizePicker', - component: FontSizePicker, -}; - -const FontSizePickerWithState: ComponentStory< typeof FontSizePicker > = ( { - value, - ...props -} ) => { - const [ fontSize, setFontSize ] = useState( value ); - return ( - <FontSizePicker - { ...props } - value={ fontSize } - onChange={ setFontSize } - /> - ); -}; - -export const Default: ComponentStory< typeof FontSizePicker > = - FontSizePickerWithState.bind( {} ); -Default.args = { - fontSizes: [ - { - name: 'Small', - slug: 'small', - size: 12, - }, - { - name: 'Normal', - slug: 'normal', - size: 16, - }, - { - name: 'Big', - slug: 'big', - size: 26, - }, - ], - value: 16, -}; diff --git a/packages/components/src/font-size-picker/stories/index.story.tsx b/packages/components/src/font-size-picker/stories/index.story.tsx new file mode 100644 index 00000000000000..69043278389c75 --- /dev/null +++ b/packages/components/src/font-size-picker/stories/index.story.tsx @@ -0,0 +1,211 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import FontSizePicker from '../'; + +const meta: Meta< typeof FontSizePicker > = { + title: 'Components/FontSizePicker', + component: FontSizePicker, + argTypes: { + value: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const FontSizePickerWithState: StoryFn< typeof FontSizePicker > = ( { + value, + onChange, + ...props +} ) => { + const [ fontSize, setFontSize ] = useState( value ); + return ( + <FontSizePicker + { ...props } + value={ fontSize } + onChange={ ( nextValue ) => { + setFontSize( nextValue ); + onChange?.( nextValue ); + } } + /> + ); +}; + +const TwoFontSizePickersWithState: StoryFn< typeof FontSizePicker > = ( { + fontSizes, + ...props +} ) => { + return ( + <> + <h2>Fewer font sizes</h2> + <FontSizePickerWithState + { ...props } + fontSizes={ fontSizes?.slice( 0, 4 ) } + /> + + <h2>More font sizes</h2> + <FontSizePickerWithState { ...props } fontSizes={ fontSizes } /> + </> + ); +}; + +export const Default: StoryFn< typeof FontSizePicker > = + FontSizePickerWithState.bind( {} ); +Default.args = { + __nextHasNoMarginBottom: true, + disableCustomFontSizes: false, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: 12, + }, + { + name: 'Normal', + slug: 'normal', + size: 16, + }, + { + name: 'Big', + slug: 'big', + size: 26, + }, + ], + units: [ 'px', 'em', 'rem' ], + value: 16, + withSlider: false, +}; + +export const WithSlider: StoryFn< typeof FontSizePicker > = + FontSizePickerWithState.bind( {} ); +WithSlider.args = { + ...Default.args, + fallbackFontSize: 16, + value: undefined, + withSlider: true, +}; + +/** + * With custom font sizes disabled via the `disableCustomFontSizes` prop, the user will + * only be able to pick one of the predefined sizes passed in `fontSizes`. + */ +export const WithCustomSizesDisabled: StoryFn< typeof FontSizePicker > = + FontSizePickerWithState.bind( {} ); +WithCustomSizesDisabled.args = { + ...Default.args, + disableCustomFontSizes: true, +}; + +/** + * When there are more than 5 font size options, the UI is no longer a toggle group. + */ +export const WithMoreFontSizes: StoryFn< typeof FontSizePicker > = + FontSizePickerWithState.bind( {} ); +WithMoreFontSizes.args = { + ...Default.args, + fontSizes: [ + { + name: 'Tiny', + slug: 'tiny', + size: 8, + }, + { + name: 'Small', + slug: 'small', + size: 12, + }, + { + name: 'Normal', + slug: 'normal', + size: 16, + }, + { + name: 'Big', + slug: 'big', + size: 26, + }, + { + name: 'Bigger', + slug: 'bigger', + size: 30, + }, + { + name: 'Huge', + slug: 'huge', + size: 36, + }, + ], + value: 8, +}; + +/** + * When units like `px` are specified explicitly, it will be shown as a label hint. + */ +export const WithUnits: StoryFn< typeof FontSizePicker > = + TwoFontSizePickersWithState.bind( {} ); +WithUnits.args = { + ...WithMoreFontSizes.args, + fontSizes: WithMoreFontSizes.args.fontSizes?.map( ( option ) => ( { + ...option, + size: `${ option.size }px`, + } ) ), + value: '8px', +}; + +/** + * The label hint will not be shown if it is a complex CSS value. Some examples of complex CSS values + * in this context are CSS functions like `calc()`, `clamp()`, and `var()`. + */ +export const WithComplexCSSValues: StoryFn< typeof FontSizePicker > = + TwoFontSizePickersWithState.bind( {} ); +WithComplexCSSValues.args = { + ...Default.args, + fontSizes: [ + { + name: 'Small', + slug: 'small', + // Adding just one complex css value is enough + size: 'clamp(1.75rem, 3vw, 2.25rem)', + }, + { + name: 'Medium', + slug: 'medium', + size: '1.125rem', + }, + { + name: 'Large', + slug: 'large', + size: '1.7rem', + }, + { + name: 'Extra Large', + slug: 'extra-large', + size: '1.95rem', + }, + { + name: 'Extra Extra Large', + slug: 'extra-extra-large', + size: '2.5rem', + }, + { + name: 'Huge', + slug: 'huge', + size: '2.8rem', + }, + ], + value: '1.125rem', +}; diff --git a/packages/components/src/font-size-picker/stories/index.tsx b/packages/components/src/font-size-picker/stories/index.tsx deleted file mode 100644 index db7101ab0c4462..00000000000000 --- a/packages/components/src/font-size-picker/stories/index.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import FontSizePicker from '../'; - -const meta: ComponentMeta< typeof FontSizePicker > = { - title: 'Components/FontSizePicker', - component: FontSizePicker, - argTypes: { - value: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const FontSizePickerWithState: ComponentStory< typeof FontSizePicker > = ( { - value, - onChange, - ...props -} ) => { - const [ fontSize, setFontSize ] = useState( value ); - return ( - <FontSizePicker - { ...props } - value={ fontSize } - onChange={ ( nextValue ) => { - setFontSize( nextValue ); - onChange?.( nextValue ); - } } - /> - ); -}; - -const TwoFontSizePickersWithState: ComponentStory< typeof FontSizePicker > = ( { - fontSizes, - ...props -} ) => { - return ( - <> - <h2>Fewer font sizes</h2> - <FontSizePickerWithState - { ...props } - fontSizes={ fontSizes?.slice( 0, 4 ) } - /> - - <h2>More font sizes</h2> - <FontSizePickerWithState { ...props } fontSizes={ fontSizes } /> - </> - ); -}; - -export const Default: ComponentStory< typeof FontSizePicker > = - FontSizePickerWithState.bind( {} ); -Default.args = { - __nextHasNoMarginBottom: true, - disableCustomFontSizes: false, - fontSizes: [ - { - name: 'Small', - slug: 'small', - size: 12, - }, - { - name: 'Normal', - slug: 'normal', - size: 16, - }, - { - name: 'Big', - slug: 'big', - size: 26, - }, - ], - units: [ 'px', 'em', 'rem' ], - value: 16, - withSlider: false, -}; - -export const WithSlider: ComponentStory< typeof FontSizePicker > = - FontSizePickerWithState.bind( {} ); -WithSlider.args = { - ...Default.args, - fallbackFontSize: 16, - value: undefined, - withSlider: true, -}; - -/** - * With custom font sizes disabled via the `disableCustomFontSizes` prop, the user will - * only be able to pick one of the predefined sizes passed in `fontSizes`. - */ -export const WithCustomSizesDisabled: ComponentStory< typeof FontSizePicker > = - FontSizePickerWithState.bind( {} ); -WithCustomSizesDisabled.args = { - ...Default.args, - disableCustomFontSizes: true, -}; - -/** - * When there are more than 5 font size options, the UI is no longer a toggle group. - */ -export const WithMoreFontSizes: ComponentStory< typeof FontSizePicker > = - FontSizePickerWithState.bind( {} ); -WithMoreFontSizes.args = { - ...Default.args, - fontSizes: [ - { - name: 'Tiny', - slug: 'tiny', - size: 8, - }, - { - name: 'Small', - slug: 'small', - size: 12, - }, - { - name: 'Normal', - slug: 'normal', - size: 16, - }, - { - name: 'Big', - slug: 'big', - size: 26, - }, - { - name: 'Bigger', - slug: 'bigger', - size: 30, - }, - { - name: 'Huge', - slug: 'huge', - size: 36, - }, - ], - value: 8, -}; - -/** - * When units like `px` are specified explicitly, it will be shown as a label hint. - */ -export const WithUnits: ComponentStory< typeof FontSizePicker > = - TwoFontSizePickersWithState.bind( {} ); -WithUnits.args = { - ...WithMoreFontSizes.args, - fontSizes: WithMoreFontSizes.args.fontSizes?.map( ( option ) => ( { - ...option, - size: `${ option.size }px`, - } ) ), - value: '8px', -}; - -/** - * The label hint will not be shown if it is a complex CSS value. Some examples of complex CSS values - * in this context are CSS functions like `calc()`, `clamp()`, and `var()`. - */ -export const WithComplexCSSValues: ComponentStory< typeof FontSizePicker > = - TwoFontSizePickersWithState.bind( {} ); -WithComplexCSSValues.args = { - ...Default.args, - fontSizes: [ - { - name: 'Small', - slug: 'small', - // Adding just one complex css value is enough - size: 'clamp(1.75rem, 3vw, 2.25rem)', - }, - { - name: 'Medium', - slug: 'medium', - size: '1.125rem', - }, - { - name: 'Large', - slug: 'large', - size: '1.7rem', - }, - { - name: 'Extra Large', - slug: 'extra-large', - size: '1.95rem', - }, - { - name: 'Extra Extra Large', - slug: 'extra-extra-large', - size: '2.5rem', - }, - { - name: 'Huge', - slug: 'huge', - size: '2.8rem', - }, - ], - value: '1.125rem', -}; diff --git a/packages/components/src/font-size-picker/styles.ts b/packages/components/src/font-size-picker/styles.ts index 643db817442838..8ba0ce661c5eb7 100644 --- a/packages/components/src/font-size-picker/styles.ts +++ b/packages/components/src/font-size-picker/styles.ts @@ -8,9 +8,9 @@ import styled from '@emotion/styled'; */ import BaseControl from '../base-control'; import Button from '../button'; +import { HStack } from '../h-stack'; import { space } from '../ui/utils/space'; import { COLORS } from '../utils'; -import type { FontSizePickerProps } from './types'; export const Container = styled.fieldset` border: 0; @@ -18,6 +18,14 @@ export const Container = styled.fieldset` padding: 0; `; +export const Header = styled( HStack )` + height: ${ space( 4 ) }; +`; + +export const HeaderToggle = styled( Button )` + margin-top: ${ space( -1 ) }; +`; + export const HeaderLabel = styled( BaseControl.VisualLabel )` display: flex; gap: ${ space( 1 ) }; @@ -35,12 +43,3 @@ export const Controls = styled.div< { ${ ( props ) => ! props.__nextHasNoMarginBottom && `margin-bottom: ${ space( 6 ) };` } `; - -export const ResetButton = styled( Button )< { - size: FontSizePickerProps[ 'size' ]; -} >` - &&& { - height: ${ ( props ) => - props.size === '__unstable-large' ? '40px' : '30px' }; - } -`; diff --git a/packages/components/src/form-file-upload/stories/index.story.tsx b/packages/components/src/form-file-upload/stories/index.story.tsx new file mode 100644 index 00000000000000..a4cf3298da6b14 --- /dev/null +++ b/packages/components/src/form-file-upload/stories/index.story.tsx @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { upload as uploadIcon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import FormFileUpload from '..'; + +const meta: Meta< typeof FormFileUpload > = { + title: 'Components/FormFileUpload', + component: FormFileUpload, + argTypes: { + icon: { control: { type: null } }, + onChange: { action: 'onChange', control: { type: null } }, + onClick: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof FormFileUpload > = ( props ) => { + return <FormFileUpload { ...props } />; +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: 'Select file', +}; + +export const RestrictFileTypes = Template.bind( {} ); +RestrictFileTypes.args = { + ...Default.args, + accept: 'image/*', + children: 'Select image', +}; + +export const AllowMultipleFiles = Template.bind( {} ); +AllowMultipleFiles.args = { + ...Default.args, + children: 'Select files', + multiple: true, +}; + +export const WithIcon = Template.bind( {} ); +WithIcon.args = { + ...Default.args, + children: 'Upload', + icon: uploadIcon, +}; + +/** + * Render a custom trigger button by passing a render function to the `render` prop. + * + * ```jsx + * ( { openFileDialog } ) => <button onClick={ openFileDialog }>Custom Upload Button</button> + * ``` + */ +export const WithCustomRender = Template.bind( {} ); +WithCustomRender.args = { + ...Default.args, + render: ( { openFileDialog } ) => ( + <button onClick={ openFileDialog }>Custom Upload Button</button> + ), +}; diff --git a/packages/components/src/form-file-upload/stories/index.tsx b/packages/components/src/form-file-upload/stories/index.tsx deleted file mode 100644 index dfdaf0fb14d88a..00000000000000 --- a/packages/components/src/form-file-upload/stories/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { upload as uploadIcon } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import FormFileUpload from '..'; - -const meta: ComponentMeta< typeof FormFileUpload > = { - title: 'Components/FormFileUpload', - component: FormFileUpload, - argTypes: { - icon: { control: { type: null } }, - onChange: { action: 'onChange', control: { type: null } }, - onClick: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof FormFileUpload > = ( props ) => { - return <FormFileUpload { ...props } />; -}; - -export const Default = Template.bind( {} ); -Default.args = { - children: 'Select file', -}; - -export const RestrictFileTypes = Template.bind( {} ); -RestrictFileTypes.args = { - ...Default.args, - accept: 'image/*', - children: 'Select image', -}; - -export const AllowMultipleFiles = Template.bind( {} ); -AllowMultipleFiles.args = { - ...Default.args, - children: 'Select files', - multiple: true, -}; - -export const WithIcon = Template.bind( {} ); -WithIcon.args = { - ...Default.args, - children: 'Upload', - icon: uploadIcon, -}; - -/** - * Render a custom trigger button by passing a render function to the `render` prop. - * - * ```jsx - * ( { openFileDialog } ) => <button onClick={ openFileDialog }>Custom Upload Button</button> - * ``` - */ -export const WithCustomRender = Template.bind( {} ); -WithCustomRender.args = { - ...Default.args, - render: ( { openFileDialog } ) => ( - <button onClick={ openFileDialog }>Custom Upload Button</button> - ), -}; diff --git a/packages/components/src/form-toggle/stories/index.story.tsx b/packages/components/src/form-toggle/stories/index.story.tsx new file mode 100644 index 00000000000000..0958b94c342c4a --- /dev/null +++ b/packages/components/src/form-toggle/stories/index.story.tsx @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { FormToggle } from '..'; + +const meta: Meta< typeof FormToggle > = { + component: FormToggle, + title: 'Components/FormToggle', + argTypes: { + onChange: { + action: 'onChange', + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof FormToggle > = ( { onChange, ...args } ) => { + const [ isChecked, setChecked ] = useState( true ); + + return ( + <FormToggle + { ...args } + checked={ isChecked } + onChange={ ( e ) => { + setChecked( ( state ) => ! state ); + onChange( e ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof FormToggle > = Template.bind( {} ); +Default.args = {}; diff --git a/packages/components/src/form-toggle/stories/index.tsx b/packages/components/src/form-toggle/stories/index.tsx deleted file mode 100644 index eba2bf4c8580c8..00000000000000 --- a/packages/components/src/form-toggle/stories/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { FormToggle } from '..'; - -const meta: ComponentMeta< typeof FormToggle > = { - component: FormToggle, - title: 'Components/FormToggle', - argTypes: { - onChange: { - action: 'onChange', - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof FormToggle > = ( { - onChange, - ...args -} ) => { - const [ isChecked, setChecked ] = useState( true ); - - return ( - <FormToggle - { ...args } - checked={ isChecked } - onChange={ ( e ) => { - setChecked( ( state ) => ! state ); - onChange( e ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof FormToggle > = Template.bind( {} ); -Default.args = {}; diff --git a/packages/components/src/form-token-field/stories/index.story.tsx b/packages/components/src/form-token-field/stories/index.story.tsx new file mode 100644 index 00000000000000..da61a0b313bfe9 --- /dev/null +++ b/packages/components/src/form-token-field/stories/index.story.tsx @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { ComponentProps } from 'react'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import FormTokenField from '../'; + +const meta: Meta< typeof FormTokenField > = { + component: FormTokenField, + title: 'Components/FormTokenField', + argTypes: { + value: { + control: { type: null }, + }, + __experimentalValidateInput: { + control: { type: null }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const continents = [ + 'Africa', + 'America', + 'Antarctica', + 'Asia', + 'Europe', + 'Oceania', +]; + +const DefaultTemplate: StoryFn< typeof FormTokenField > = ( { ...args } ) => { + const [ selectedContinents, setSelectedContinents ] = useState< + ComponentProps< typeof FormTokenField >[ 'value' ] + >( [] ); + + return ( + <FormTokenField + { ...args } + value={ selectedContinents } + onChange={ ( tokens ) => setSelectedContinents( tokens ) } + /> + ); +}; + +export const Default: StoryFn< typeof FormTokenField > = DefaultTemplate.bind( + {} +); +Default.args = { + label: 'Type a continent', + suggestions: continents, +}; + +export const Async: StoryFn< typeof FormTokenField > = ( { + suggestions, + ...args +} ) => { + const [ selectedContinents, setSelectedContinents ] = useState< + ComponentProps< typeof FormTokenField >[ 'value' ] + >( [] ); + const [ availableContinents, setAvailableContinents ] = useState< + string[] + >( [] ); + + const searchContinents = ( input: string ) => { + const timeout = setTimeout( () => { + const available = ( suggestions || [] ).filter( ( continent ) => + continent.toLowerCase().includes( input.toLowerCase() ) + ); + setAvailableContinents( available ); + }, 1000 ); + + return () => clearTimeout( timeout ); + }; + + return ( + <FormTokenField + { ...args } + value={ selectedContinents } + suggestions={ availableContinents } + onChange={ ( tokens ) => setSelectedContinents( tokens ) } + onInputChange={ searchContinents } + /> + ); +}; +Async.args = { + label: 'Type a continent', + suggestions: continents, +}; + +export const DropdownSelector: StoryFn< typeof FormTokenField > = + DefaultTemplate.bind( {} ); +DropdownSelector.args = { + ...Default.args, + __experimentalExpandOnFocus: true, + __experimentalAutoSelectFirstMatch: true, +}; + +/** + * The rendered output of each suggestion can be customized by passing a + * render function to the `__experimentalRenderItem` prop. (This is still an experimental feature + * and is subject to change.) + */ +export const WithCustomRenderItem: StoryFn< typeof FormTokenField > = + DefaultTemplate.bind( {} ); +WithCustomRenderItem.args = { + ...Default.args, + __experimentalRenderItem: ( { item } ) => ( + <div>{ `${ item } — a nice place to visit` }</div> + ), +}; + +/** + * Only values for which the `__experimentalValidateInput` function returns + * `true` will be tokenized. (This is still an experimental feature and is + * subject to change.) + */ +export const WithValidatedInput: StoryFn< typeof FormTokenField > = + DefaultTemplate.bind( {} ); +WithValidatedInput.args = { + ...Default.args, + __experimentalValidateInput: ( input: string ) => + continents.includes( input ), +}; diff --git a/packages/components/src/form-token-field/stories/index.tsx b/packages/components/src/form-token-field/stories/index.tsx deleted file mode 100644 index f120c0dc52211e..00000000000000 --- a/packages/components/src/form-token-field/stories/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { ComponentProps } from 'react'; -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import FormTokenField from '../'; - -const meta: ComponentMeta< typeof FormTokenField > = { - component: FormTokenField, - title: 'Components/FormTokenField', - argTypes: { - value: { - control: { type: null }, - }, - __experimentalValidateInput: { - control: { type: null }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const continents = [ - 'Africa', - 'America', - 'Antarctica', - 'Asia', - 'Europe', - 'Oceania', -]; - -const DefaultTemplate: ComponentStory< typeof FormTokenField > = ( { - ...args -} ) => { - const [ selectedContinents, setSelectedContinents ] = useState< - ComponentProps< typeof FormTokenField >[ 'value' ] - >( [] ); - - return ( - <FormTokenField - { ...args } - value={ selectedContinents } - onChange={ ( tokens ) => setSelectedContinents( tokens ) } - /> - ); -}; - -export const Default: ComponentStory< typeof FormTokenField > = - DefaultTemplate.bind( {} ); -Default.args = { - label: 'Type a continent', - suggestions: continents, -}; - -export const Async: ComponentStory< typeof FormTokenField > = ( { - suggestions, - ...args -} ) => { - const [ selectedContinents, setSelectedContinents ] = useState< - ComponentProps< typeof FormTokenField >[ 'value' ] - >( [] ); - const [ availableContinents, setAvailableContinents ] = useState< - string[] - >( [] ); - - const searchContinents = ( input: string ) => { - const timeout = setTimeout( () => { - const available = ( suggestions || [] ).filter( ( continent ) => - continent.toLowerCase().includes( input.toLowerCase() ) - ); - setAvailableContinents( available ); - }, 1000 ); - - return () => clearTimeout( timeout ); - }; - - return ( - <FormTokenField - { ...args } - value={ selectedContinents } - suggestions={ availableContinents } - onChange={ ( tokens ) => setSelectedContinents( tokens ) } - onInputChange={ searchContinents } - /> - ); -}; -Async.args = { - label: 'Type a continent', - suggestions: continents, -}; - -export const DropdownSelector: ComponentStory< typeof FormTokenField > = - DefaultTemplate.bind( {} ); -DropdownSelector.args = { - ...Default.args, - __experimentalExpandOnFocus: true, - __experimentalAutoSelectFirstMatch: true, -}; - -/** - * The rendered output of each suggestion can be customized by passing a - * render function to the `__experimentalRenderItem` prop. (This is still an experimental feature - * and is subject to change.) - */ -export const WithCustomRenderItem: ComponentStory< typeof FormTokenField > = - DefaultTemplate.bind( {} ); -WithCustomRenderItem.args = { - ...Default.args, - __experimentalRenderItem: ( { item } ) => ( - <div>{ `${ item } — a nice place to visit` }</div> - ), -}; - -/** - * Only values for which the `__experimentalValidateInput` function returns - * `true` will be tokenized. (This is still an experimental feature and is - * subject to change.) - */ -export const WithValidatedInput: ComponentStory< typeof FormTokenField > = - DefaultTemplate.bind( {} ); -WithValidatedInput.args = { - ...Default.args, - __experimentalValidateInput: ( input: string ) => - continents.includes( input ), -}; diff --git a/packages/components/src/form-token-field/styles.ts b/packages/components/src/form-token-field/styles.ts index 55cfbe5e242f5b..ee67f44a5f3244 100644 --- a/packages/components/src/form-token-field/styles.ts +++ b/packages/components/src/form-token-field/styles.ts @@ -9,6 +9,7 @@ import { css } from '@emotion/react'; */ import { Flex } from '../flex'; import { space } from '../ui/utils/space'; +import { boxSizingReset } from '../utils'; type TokensAndInputWrapperProps = { __next40pxDefaultSize: boolean; @@ -27,6 +28,7 @@ const deprecatedPaddings = ( { export const TokensAndInputWrapperFlex = styled( Flex )` padding: 7px; + ${ boxSizingReset } ${ deprecatedPaddings } `; diff --git a/packages/components/src/gradient-picker/stories/index.story.tsx b/packages/components/src/gradient-picker/stories/index.story.tsx new file mode 100644 index 00000000000000..039a2f0da1729c --- /dev/null +++ b/packages/components/src/gradient-picker/stories/index.story.tsx @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import GradientPicker from '..'; + +const meta: Meta< typeof GradientPicker > = { + title: 'Components/GradientPicker', + component: GradientPicker, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + actions: { argTypesRegex: '^on.*' }, + }, + argTypes: { + value: { control: { type: null } }, + }, +}; +export default meta; + +const GRADIENTS = [ + { + name: 'Vivid cyan blue to vivid purple', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'vivid-cyan-blue-to-vivid-purple', + }, + { + name: 'Light green cyan to vivid green cyan', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'light-green-cyan-to-vivid-green-cyan', + }, + { + name: 'Luminous vivid amber to luminous vivid orange', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'luminous-vivid-amber-to-luminous-vivid-orange', + }, + { + name: 'Luminous vivid orange to vivid red', + gradient: + 'linear-gradient(135deg,rgba(255,105,0,1) 0%,rgb(207,46,46) 100%)', + slug: 'luminous-vivid-orange-to-vivid-red', + }, + { + name: 'Very light gray to cyan bluish gray', + gradient: + 'linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%)', + slug: 'very-light-gray-to-cyan-bluish-gray', + }, + { + name: 'Cool to warm spectrum', + gradient: + 'linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%)', + slug: 'cool-to-warm-spectrum', + }, +]; + +const Template: StoryFn< typeof GradientPicker > = ( { + onChange, + ...props +} ) => { + const [ gradient, setGradient ] = + useState< ( typeof props )[ 'value' ] >( null ); + return ( + <GradientPicker + { ...props } + value={ gradient } + onChange={ ( ...changeArgs ) => { + setGradient( ...changeArgs ); + onChange?.( ...changeArgs ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + __nextHasNoMargin: true, + gradients: GRADIENTS, +}; + +export const WithNoExistingGradients = Template.bind( {} ); +WithNoExistingGradients.args = { + ...Default.args, + gradients: [], +}; + +export const MultipleOrigins = Template.bind( {} ); +MultipleOrigins.args = { + ...Default.args, + gradients: [ + { name: 'Origin 1', gradients: GRADIENTS }, + { name: 'Origin 2', gradients: GRADIENTS }, + ], +}; diff --git a/packages/components/src/gradient-picker/stories/index.tsx b/packages/components/src/gradient-picker/stories/index.tsx deleted file mode 100644 index 0f34d65d5eb621..00000000000000 --- a/packages/components/src/gradient-picker/stories/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import GradientPicker from '..'; - -const meta: ComponentMeta< typeof GradientPicker > = { - title: 'Components/GradientPicker', - component: GradientPicker, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - actions: { argTypesRegex: '^on.*' }, - }, - argTypes: { - value: { control: { type: null } }, - }, -}; -export default meta; - -const GRADIENTS = [ - { - name: 'Vivid cyan blue to vivid purple', - gradient: - 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - slug: 'vivid-cyan-blue-to-vivid-purple', - }, - { - name: 'Light green cyan to vivid green cyan', - gradient: - 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', - slug: 'light-green-cyan-to-vivid-green-cyan', - }, - { - name: 'Luminous vivid amber to luminous vivid orange', - gradient: - 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', - slug: 'luminous-vivid-amber-to-luminous-vivid-orange', - }, - { - name: 'Luminous vivid orange to vivid red', - gradient: - 'linear-gradient(135deg,rgba(255,105,0,1) 0%,rgb(207,46,46) 100%)', - slug: 'luminous-vivid-orange-to-vivid-red', - }, - { - name: 'Very light gray to cyan bluish gray', - gradient: - 'linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%)', - slug: 'very-light-gray-to-cyan-bluish-gray', - }, - { - name: 'Cool to warm spectrum', - gradient: - 'linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%)', - slug: 'cool-to-warm-spectrum', - }, -]; - -const Template: ComponentStory< typeof GradientPicker > = ( { - onChange, - ...props -} ) => { - const [ gradient, setGradient ] = - useState< ( typeof props )[ 'value' ] >( null ); - return ( - <GradientPicker - { ...props } - value={ gradient } - onChange={ ( ...changeArgs ) => { - setGradient( ...changeArgs ); - onChange?.( ...changeArgs ); - } } - /> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - __nextHasNoMargin: true, - gradients: GRADIENTS, -}; - -export const WithNoExistingGradients = Template.bind( {} ); -WithNoExistingGradients.args = { - ...Default.args, - gradients: [], -}; - -export const MultipleOrigins = Template.bind( {} ); -MultipleOrigins.args = { - ...Default.args, - gradients: [ - { name: 'Origin 1', gradients: GRADIENTS }, - { name: 'Origin 2', gradients: GRADIENTS }, - ], -}; diff --git a/packages/components/src/grid/component.tsx b/packages/components/src/grid/component.tsx index 689e641774f726..3ea6db0ba85448 100644 --- a/packages/components/src/grid/component.tsx +++ b/packages/components/src/grid/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import useGrid from './hook'; import type { GridProps } from './types'; diff --git a/packages/components/src/grid/hook.ts b/packages/components/src/grid/hook.ts index be615a884fd1c1..7fd54d6c4f19eb 100644 --- a/packages/components/src/grid/hook.ts +++ b/packages/components/src/grid/hook.ts @@ -11,7 +11,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import { getAlignmentProps } from './utils'; import { useResponsiveValue } from '../ui/utils/use-responsive-value'; import CONFIG from '../utils/config-values'; diff --git a/packages/components/src/grid/stories/index.story.tsx b/packages/components/src/grid/stories/index.story.tsx new file mode 100644 index 00000000000000..171b324e033c04 --- /dev/null +++ b/packages/components/src/grid/stories/index.story.tsx @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '../../view'; +import { Grid } from '..'; + +const meta: Meta< typeof Grid > = { + component: Grid, + title: 'Components (Experimental)/Grid', + argTypes: { + as: { control: { type: 'text' } }, + align: { control: { type: 'text' } }, + children: { control: { type: null } }, + columnGap: { control: { type: 'text' } }, + columns: { + table: { type: { summary: 'number' } }, + control: { type: 'number' }, + }, + justify: { control: { type: 'text' } }, + rowGap: { control: { type: 'text' } }, + rows: { + table: { type: { summary: 'number' } }, + control: { type: 'number' }, + }, + templateColumns: { control: { type: 'text' } }, + templateRows: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Item = ( props: { children: string } ) => ( + <View + style={ { + borderRadius: 8, + background: '#eee', + padding: 8, + textAlign: 'center', + } } + { ...props } + /> +); + +const Template: StoryFn< typeof Grid > = ( props ) => ( + <Grid { ...props }> + <Item>One</Item> + <Item>Two</Item> + <Item>Three</Item> + <Item>Four</Item> + <Item>Five</Item> + <Item>Six</Item> + <Item>Seven</Item> + <Item>Eight</Item> + </Grid> +); + +export const Default: StoryFn< typeof Grid > = Template.bind( {} ); +Default.args = { + alignment: 'bottom', + columns: 4, + gap: 2, +}; diff --git a/packages/components/src/grid/stories/index.tsx b/packages/components/src/grid/stories/index.tsx deleted file mode 100644 index 44451c54fd6c95..00000000000000 --- a/packages/components/src/grid/stories/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { View } from '../../view'; -import { Grid } from '..'; - -const meta: ComponentMeta< typeof Grid > = { - component: Grid, - title: 'Components (Experimental)/Grid', - argTypes: { - as: { control: { type: 'text' } }, - align: { control: { type: 'text' } }, - children: { control: { type: null } }, - columnGap: { control: { type: 'text' } }, - columns: { - table: { type: { summary: 'number' } }, - control: { type: 'number' }, - }, - justify: { control: { type: 'text' } }, - rowGap: { control: { type: 'text' } }, - rows: { - table: { type: { summary: 'number' } }, - control: { type: 'number' }, - }, - templateColumns: { control: { type: 'text' } }, - templateRows: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Item = ( props: { children: string } ) => ( - <View - style={ { - borderRadius: 8, - background: '#eee', - padding: 8, - textAlign: 'center', - } } - { ...props } - /> -); - -const Template: ComponentStory< typeof Grid > = ( props ) => ( - <Grid { ...props }> - <Item>One</Item> - <Item>Two</Item> - <Item>Three</Item> - <Item>Four</Item> - <Item>Five</Item> - <Item>Six</Item> - <Item>Seven</Item> - <Item>Eight</Item> - </Grid> -); - -export const Default: ComponentStory< typeof Grid > = Template.bind( {} ); -Default.args = { - alignment: 'bottom', - columns: 4, - gap: 2, -}; diff --git a/packages/components/src/guide/icons.tsx b/packages/components/src/guide/icons.tsx index 1c47180ae0b11a..36146611dc299a 100644 --- a/packages/components/src/guide/icons.tsx +++ b/packages/components/src/guide/icons.tsx @@ -3,13 +3,8 @@ */ import { SVG, Circle } from '@wordpress/primitives'; -export const PageControlIcon = ( { isSelected }: { isSelected: boolean } ) => ( +export const PageControlIcon = () => ( <SVG width="8" height="8" fill="none" xmlns="http://www.w3.org/2000/svg"> - <Circle - cx="4" - cy="4" - r="4" - fill={ isSelected ? '#419ECD' : '#E1E3E6' } - /> + <Circle cx="4" cy="4" r="4" /> </SVG> ); diff --git a/packages/components/src/guide/index.tsx b/packages/components/src/guide/index.tsx index 38f8d07d8c0efc..c5655847d99e5e 100644 --- a/packages/components/src/guide/index.tsx +++ b/packages/components/src/guide/index.tsx @@ -9,7 +9,6 @@ import classnames from 'classnames'; import { useState, useEffect, Children, useRef } from '@wordpress/element'; import deprecated from '@wordpress/deprecated'; import { __ } from '@wordpress/i18n'; -import { focus } from '@wordpress/dom'; /** * Internal dependencies @@ -59,9 +58,17 @@ function Guide( { onFinish, pages = [], }: GuideProps ) { - const guideContainer = useRef< HTMLDivElement >( null ); + const ref = useRef< HTMLDivElement >( null ); const [ currentPage, setCurrentPage ] = useState( 0 ); + useEffect( () => { + // Place focus at the top of the guide on mount and when the page changes. + const frame = ref.current?.querySelector( '.components-guide' ); + if ( frame instanceof HTMLElement ) { + frame.focus(); + } + }, [ currentPage ] ); + useEffect( () => { if ( Children.count( children ) ) { deprecated( 'Passing children to <Guide>', { @@ -71,16 +78,6 @@ function Guide( { } }, [ children ] ); - useEffect( () => { - // Each time we change the current page, start from the first element of the page. - // This also solves any focus loss that can happen. - if ( guideContainer.current ) { - ( - focus.tabbable.find( guideContainer.current ) as HTMLElement[] - )[ 0 ]?.focus(); - } - }, [ currentPage ] ); - if ( Children.count( children ) ) { pages = Children.map( children, ( child ) => ( { @@ -111,6 +108,7 @@ function Guide( { <Modal className={ classnames( 'components-guide', className ) } contentLabel={ contentLabel } + isDismissible={ pages.length > 1 } onRequestClose={ onFinish } onKeyDown={ ( event ) => { if ( event.code === 'ArrowLeft' ) { @@ -123,7 +121,7 @@ function Guide( { event.preventDefault(); } } } - ref={ guideContainer } + ref={ ref } > <div className="components-guide__container"> <div className="components-guide__page"> @@ -144,6 +142,7 @@ function Guide( { { canGoBack && ( <Button className="components-guide__back-button" + variant="tertiary" onClick={ goBack } > { __( 'Previous' ) } @@ -152,6 +151,7 @@ function Guide( { { canGoForward && ( <Button className="components-guide__forward-button" + variant="primary" onClick={ goForward } > { __( 'Next' ) } @@ -160,6 +160,7 @@ function Guide( { { ! canGoForward && ( <Button className="components-guide__finish-button" + variant="primary" onClick={ onFinish } > { finishButtonText } diff --git a/packages/components/src/guide/page-control.tsx b/packages/components/src/guide/page-control.tsx index 6e5951923ab387..468670c6ad9b9b 100644 --- a/packages/components/src/guide/page-control.tsx +++ b/packages/components/src/guide/page-control.tsx @@ -28,11 +28,7 @@ export default function PageControl( { > <Button key={ page } - icon={ - <PageControlIcon - isSelected={ page === currentPage } - /> - } + icon={ <PageControlIcon /> } aria-label={ sprintf( /* translators: 1: current page number 2: total number of pages */ __( 'Page %1$d of %2$d' ), diff --git a/packages/components/src/guide/stories/index.story.tsx b/packages/components/src/guide/stories/index.story.tsx new file mode 100644 index 00000000000000..d9154a02097e71 --- /dev/null +++ b/packages/components/src/guide/stories/index.story.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import Guide from '..'; + +const meta: Meta< typeof Guide > = { + title: 'Components/Guide', + component: Guide, + argTypes: { + contentLabel: { control: 'text' }, + finishButtonText: { control: 'text' }, + onFinish: { action: 'onFinish' }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Guide > = ( { onFinish, ...props } ) => { + const [ isOpen, setOpen ] = useState( false ); + + const openGuide = () => setOpen( true ); + const closeGuide = () => setOpen( false ); + + return ( + <> + <Button variant="secondary" onClick={ openGuide }> + Open Guide + </Button> + { isOpen && ( + <Guide + { ...props } + onFinish={ ( ...finishArgs ) => { + closeGuide(); + onFinish?.( ...finishArgs ); + } } + /> + ) } + </> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + pages: Array.from( { length: 3 } ).map( ( _, page ) => ( { + content: <p>{ `Page ${ page + 1 }` }</p>, + } ) ), +}; diff --git a/packages/components/src/guide/stories/index.tsx b/packages/components/src/guide/stories/index.tsx deleted file mode 100644 index d07fa02100e626..00000000000000 --- a/packages/components/src/guide/stories/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import Guide from '..'; - -const meta: ComponentMeta< typeof Guide > = { - title: 'Components/Guide', - component: Guide, - argTypes: { - contentLabel: { control: 'text' }, - finishButtonText: { control: 'text' }, - onFinish: { action: 'onFinish' }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Guide > = ( { onFinish, ...props } ) => { - const [ isOpen, setOpen ] = useState( false ); - - const openGuide = () => setOpen( true ); - const closeGuide = () => setOpen( false ); - - return ( - <> - <Button variant="secondary" onClick={ openGuide }> - Open Guide - </Button> - { isOpen && ( - <Guide - { ...props } - onFinish={ ( ...finishArgs ) => { - closeGuide(); - onFinish?.( ...finishArgs ); - } } - /> - ) } - </> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - pages: Array.from( { length: 3 } ).map( ( _, page ) => ( { - content: <p>{ `Page ${ page + 1 }` }</p>, - } ) ), -}; diff --git a/packages/components/src/guide/style.scss b/packages/components/src/guide/style.scss index 5731f81de10ed1..b2516edeff1354 100644 --- a/packages/components/src/guide/style.scss +++ b/packages/components/src/guide/style.scss @@ -29,7 +29,7 @@ &:hover { svg { - fill: #fff; + fill: $white; } } } @@ -57,7 +57,7 @@ &__footer { align-content: center; display: flex; - height: 30px; + height: $button-size; justify-content: center; margin: 0 0 $grid-unit-30 0; padding: 0 $grid-unit-40; @@ -78,6 +78,11 @@ height: 30px; min-width: 20px; margin: -6px 0; + color: $gray-200; + } + + li[aria-current="step"] .components-button { + color: var(--wp-components-color-accent, var(--wp-admin-theme-color)); } } } @@ -85,7 +90,6 @@ .components-modal__frame.components-guide { border: none; min-width: 312px; - height: 80vh; max-height: 575px; @media ( max-width: $break-small ) { @@ -98,34 +102,14 @@ &.components-guide__back-button, &.components-guide__forward-button, &.components-guide__finish-button { - height: 30px; position: absolute; } - &.components-guide__back-button, - &.components-guide__forward-button { - font-size: $default-font-size; - padding: 4px 2px; - - &.has-text svg { - margin: 0; - } - - &:hover { - text-decoration: underline; - } - } - &.components-guide__back-button { left: $grid-unit-40; } - &.components-guide__forward-button { - right: $grid-unit-40; - color: #1386bf; - font-weight: bold; - } - + &.components-guide__forward-button, &.components-guide__finish-button { right: $grid-unit-40; } diff --git a/packages/components/src/h-stack/component.tsx b/packages/components/src/h-stack/component.tsx index a6989905825b70..51b25486f0b389 100644 --- a/packages/components/src/h-stack/component.tsx +++ b/packages/components/src/h-stack/component.tsx @@ -1,7 +1,8 @@ /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import { useHStack } from './hook'; import type { Props } from './types'; diff --git a/packages/components/src/h-stack/hook.tsx b/packages/components/src/h-stack/hook.tsx index 2c281757c0fecd..cab7df1cad79c5 100644 --- a/packages/components/src/h-stack/hook.tsx +++ b/packages/components/src/h-stack/hook.tsx @@ -6,11 +6,8 @@ import type { ReactElement } from 'react'; /** * Internal dependencies */ -import { - hasConnectNamespace, - useContextSystem, - WordPressComponentProps, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { hasConnectNamespace, useContextSystem } from '../ui/context'; import { FlexItem, useFlex } from '../flex'; import { getAlignmentProps } from './utils'; import { getValidChildren } from '../ui/utils/get-valid-children'; diff --git a/packages/components/src/h-stack/stories/e2e/index.story.tsx b/packages/components/src/h-stack/stories/e2e/index.story.tsx new file mode 100644 index 00000000000000..db965b5ac18987 --- /dev/null +++ b/packages/components/src/h-stack/stories/e2e/index.story.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '../../../view'; +import { HStack } from '../..'; + +const meta: Meta< typeof HStack > = { + component: HStack, + title: 'Components (Experimental)/HStack', +}; +export default meta; + +const Template: StoryFn< typeof HStack > = ( props ) => { + return ( + <HStack + style={ { background: '#eee', minHeight: '3rem' } } + { ...props } + > + { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( + <View key={ text } style={ { background: '#b9f9ff' } }> + { text } + </View> + ) ) } + </HStack> + ); +}; + +export const Default: StoryFn< typeof HStack > = Template.bind( {} ); +Default.args = { + spacing: 3, +}; diff --git a/packages/components/src/h-stack/stories/e2e/index.tsx b/packages/components/src/h-stack/stories/e2e/index.tsx deleted file mode 100644 index 38bbf7174e4714..00000000000000 --- a/packages/components/src/h-stack/stories/e2e/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory, ComponentMeta } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { View } from '../../../view'; -import { HStack } from '../..'; - -const meta: ComponentMeta< typeof HStack > = { - component: HStack, - title: 'Components (Experimental)/HStack', -}; -export default meta; - -const Template: ComponentStory< typeof HStack > = ( props ) => { - return ( - <HStack - style={ { background: '#eee', minHeight: '3rem' } } - { ...props } - > - { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( - <View key={ text } style={ { background: '#b9f9ff' } }> - { text } - </View> - ) ) } - </HStack> - ); -}; - -export const Default: ComponentStory< typeof HStack > = Template.bind( {} ); -Default.args = { - spacing: 3, -}; diff --git a/packages/components/src/h-stack/stories/index.story.tsx b/packages/components/src/h-stack/stories/index.story.tsx new file mode 100644 index 00000000000000..88aebb7682e476 --- /dev/null +++ b/packages/components/src/h-stack/stories/index.story.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; +/** + * Internal dependencies + */ +import { View } from '../../view'; +import { HStack } from '..'; + +const ALIGNMENTS = { + top: 'top', + topLeft: 'topLeft', + topRight: 'topRight', + left: 'left', + center: 'center', + right: 'right', + bottom: 'bottom', + bottomLeft: 'bottomLeft', + bottomRight: 'bottomRight', + edge: 'edge', + stretch: 'stretch', +}; + +const DIRECTIONS = { + row: 'row', + column: 'column', + responsive: [ 'column', 'row' ], +}; + +const JUSTIFICATIONS = { + 'space-around': 'space-around', + 'space-between': 'space-between', + 'space-evenly': 'space-evenly', + stretch: 'stretch', + center: 'center', + end: 'end', + 'flex-end': 'flex-end', + 'flex-start': 'flex-start', + start: 'start', +}; + +const meta: Meta< typeof HStack > = { + component: HStack, + title: 'Components (Experimental)/HStack', + argTypes: { + as: { + control: { type: null }, + }, + children: { + control: { type: null }, + }, + alignment: { + control: { type: 'select' }, + options: Object.keys( ALIGNMENTS ), + mapping: ALIGNMENTS, + }, + direction: { + control: { type: 'select' }, + options: Object.keys( DIRECTIONS ), + mapping: DIRECTIONS, + }, + justify: { + control: { type: 'select' }, + options: Object.keys( JUSTIFICATIONS ), + mapping: JUSTIFICATIONS, + }, + spacing: { + control: { type: 'text' }, + }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof HStack > = ( args ) => ( + <HStack { ...args } style={ { background: '#eee', minHeight: '3rem' } }> + { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( + <View key={ text } style={ { background: '#b9f9ff' } }> + { text } + </View> + ) ) } + </HStack> +); + +export const Default: StoryFn< typeof HStack > = Template.bind( {} ); +Default.args = { + spacing: '3', +}; diff --git a/packages/components/src/h-stack/stories/index.tsx b/packages/components/src/h-stack/stories/index.tsx deleted file mode 100644 index 479ac04c8acce2..00000000000000 --- a/packages/components/src/h-stack/stories/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory, ComponentMeta } from '@storybook/react'; -/** - * Internal dependencies - */ -import { View } from '../../view'; -import { HStack } from '..'; - -const ALIGNMENTS = { - top: 'top', - topLeft: 'topLeft', - topRight: 'topRight', - left: 'left', - center: 'center', - right: 'right', - bottom: 'bottom', - bottomLeft: 'bottomLeft', - bottomRight: 'bottomRight', - edge: 'edge', - stretch: 'stretch', -}; - -const DIRECTIONS = { - row: 'row', - column: 'column', - responsive: [ 'column', 'row' ], -}; - -const JUSTIFICATIONS = { - 'space-around': 'space-around', - 'space-between': 'space-between', - 'space-evenly': 'space-evenly', - stretch: 'stretch', - center: 'center', - end: 'end', - 'flex-end': 'flex-end', - 'flex-start': 'flex-start', - start: 'start', -}; - -const meta: ComponentMeta< typeof HStack > = { - component: HStack, - title: 'Components (Experimental)/HStack', - argTypes: { - as: { - control: { type: null }, - }, - children: { - control: { type: null }, - }, - alignment: { - control: { type: 'select' }, - options: Object.keys( ALIGNMENTS ), - mapping: ALIGNMENTS, - }, - direction: { - control: { type: 'select' }, - options: Object.keys( DIRECTIONS ), - mapping: DIRECTIONS, - }, - justify: { - control: { type: 'select' }, - options: Object.keys( JUSTIFICATIONS ), - mapping: JUSTIFICATIONS, - }, - spacing: { - control: { type: 'text' }, - }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof HStack > = ( args ) => ( - <HStack { ...args } style={ { background: '#eee', minHeight: '3rem' } }> - { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( - <View key={ text } style={ { background: '#b9f9ff' } }> - { text } - </View> - ) ) } - </HStack> -); - -export const Default: ComponentStory< typeof HStack > = Template.bind( {} ); -Default.args = { - spacing: '3', -}; diff --git a/packages/components/src/heading/component.tsx b/packages/components/src/heading/component.tsx index 7eb48be16b8a7f..b15739f9c17b87 100644 --- a/packages/components/src/heading/component.tsx +++ b/packages/components/src/heading/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import { useHeading } from './hook'; import type { HeadingProps } from './types'; diff --git a/packages/components/src/heading/hook.ts b/packages/components/src/heading/hook.ts index 3a92fffca7500d..13153bc8530381 100644 --- a/packages/components/src/heading/hook.ts +++ b/packages/components/src/heading/hook.ts @@ -1,7 +1,8 @@ /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import { useText } from '../text'; import { getHeadingFontSize } from '../ui/utils/font-size'; import { CONFIG, COLORS } from '../utils'; diff --git a/packages/components/src/heading/stories/index.story.tsx b/packages/components/src/heading/stories/index.story.tsx new file mode 100644 index 00000000000000..e774fd53312732 --- /dev/null +++ b/packages/components/src/heading/stories/index.story.tsx @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Heading } from '..'; + +const meta: Meta< typeof Heading > = { + component: Heading, + title: 'Components (Experimental)/Heading', + argTypes: { + adjustLineHeightForInnerControls: { control: { type: 'text' } }, + as: { control: { type: 'text' } }, + color: { control: { type: 'color' } }, + display: { control: { type: 'text' } }, + letterSpacing: { control: { type: 'text' } }, + lineHeight: { control: { type: 'text' } }, + optimizeReadabilityFor: { control: { type: 'color' } }, + variant: { + control: { type: 'radio' }, + options: [ 'undefined', 'muted' ], + mapping: { undefined, muted: 'muted' }, + }, + weight: { control: { type: 'text' } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof Heading > = ( props ) => ( + <Heading { ...props } /> +); +Default.args = { + children: 'Heading', +}; diff --git a/packages/components/src/heading/stories/index.tsx b/packages/components/src/heading/stories/index.tsx deleted file mode 100644 index 8382504d05b604..00000000000000 --- a/packages/components/src/heading/stories/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Heading } from '..'; - -const meta: ComponentMeta< typeof Heading > = { - component: Heading, - title: 'Components (Experimental)/Heading', - argTypes: { - adjustLineHeightForInnerControls: { control: { type: 'text' } }, - as: { control: { type: 'text' } }, - color: { control: { type: 'color' } }, - display: { control: { type: 'text' } }, - letterSpacing: { control: { type: 'text' } }, - lineHeight: { control: { type: 'text' } }, - optimizeReadabilityFor: { control: { type: 'color' } }, - variant: { - control: { type: 'radio' }, - options: [ 'undefined', 'muted' ], - mapping: { undefined, muted: 'muted' }, - }, - weight: { control: { type: 'text' } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof Heading > = ( props ) => ( - <Heading { ...props } /> -); -Default.args = { - children: 'Heading', -}; diff --git a/packages/components/src/icon/stories/index.story.tsx b/packages/components/src/icon/stories/index.story.tsx new file mode 100644 index 00000000000000..7d61be8df7f3ce --- /dev/null +++ b/packages/components/src/icon/stories/index.story.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; +import { wordpress } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Icon from '..'; +import { VStack } from '../../v-stack'; + +const meta: Meta< typeof Icon > = { + title: 'Components/Icon', + component: Icon, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Icon > = ( args ) => <Icon { ...args } />; + +export const Default = Template.bind( {} ); +Default.args = { + icon: wordpress, +}; + +export const FillColor: StoryFn< typeof Icon > = ( args ) => { + return ( + <div + style={ { + fill: 'blue', + } } + > + <Icon { ...args } /> + </div> + ); +}; +FillColor.args = { + ...Default.args, +}; + +export const WithAFunction = Template.bind( {} ); +WithAFunction.args = { + ...Default.args, + icon: () => ( + <SVG> + <Path d="M5 4v3h5.5v12h3V7H19V4z" /> + </SVG> + ), +}; + +const MyIconComponent = () => ( + <SVG> + <Path d="M5 4v3h5.5v12h3V7H19V4z" /> + </SVG> +); + +export const WithAComponent = Template.bind( {} ); +WithAComponent.args = { + ...Default.args, + icon: MyIconComponent, +}; + +export const WithAnSVG = Template.bind( {} ); +WithAnSVG.args = { + ...Default.args, + icon: ( + <SVG> + <Path d="M5 4v3h5.5v12h3V7H19V4z" /> + </SVG> + ), +}; + +/** + * Although it's preferred to use icons from the `@wordpress/icons` package, Dashicons are still supported, + * as long as you are in a context where the Dashicons stylesheet is loaded. To simulate that here, + * use the Global CSS Injector in the Storybook toolbar at the top and select the "WordPress" preset. + */ +export const WithADashicon: StoryFn< typeof Icon > = ( args ) => { + return ( + <VStack> + <Icon { ...args } /> + <small> + This won’t show an icon if the Dashicons stylesheet isn’t + loaded. + </small> + </VStack> + ); +}; +WithADashicon.args = { + ...Default.args, + icon: 'wordpress', +}; diff --git a/packages/components/src/icon/stories/index.tsx b/packages/components/src/icon/stories/index.tsx deleted file mode 100644 index 8c26d2d2d56138..00000000000000 --- a/packages/components/src/icon/stories/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { SVG, Path } from '@wordpress/primitives'; -import { wordpress } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import Icon from '..'; -import { VStack } from '../../v-stack'; - -const meta: ComponentMeta< typeof Icon > = { - title: 'Components/Icon', - component: Icon, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Icon > = ( args ) => ( - <Icon { ...args } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - icon: wordpress, -}; - -export const FillColor: ComponentStory< typeof Icon > = ( args ) => { - return ( - <div - style={ { - fill: 'blue', - } } - > - <Icon { ...args } /> - </div> - ); -}; -FillColor.args = { - ...Default.args, -}; - -export const WithAFunction = Template.bind( {} ); -WithAFunction.args = { - ...Default.args, - icon: () => ( - <SVG> - <Path d="M5 4v3h5.5v12h3V7H19V4z" /> - </SVG> - ), -}; - -const MyIconComponent = () => ( - <SVG> - <Path d="M5 4v3h5.5v12h3V7H19V4z" /> - </SVG> -); - -export const WithAComponent = Template.bind( {} ); -WithAComponent.args = { - ...Default.args, - icon: MyIconComponent, -}; - -export const WithAnSVG = Template.bind( {} ); -WithAnSVG.args = { - ...Default.args, - icon: ( - <SVG> - <Path d="M5 4v3h5.5v12h3V7H19V4z" /> - </SVG> - ), -}; - -/** - * Although it's preferred to use icons from the `@wordpress/icons` package, Dashicons are still supported, - * as long as you are in a context where the Dashicons stylesheet is loaded. To simulate that here, - * use the Global CSS Injector in the Storybook toolbar at the top and select the "WordPress" preset. - */ -export const WithADashicon: ComponentStory< typeof Icon > = ( args ) => { - return ( - <VStack> - <Icon { ...args } /> - <small> - This won’t show an icon if the Dashicons stylesheet isn’t - loaded. - </small> - </VStack> - ); -}; -WithADashicon.args = { - ...Default.args, - icon: 'wordpress', -}; diff --git a/packages/components/src/input-control/input-base.tsx b/packages/components/src/input-control/input-base.tsx index bd88f72be23d76..f0a89b92772a5b 100644 --- a/packages/components/src/input-control/input-base.tsx +++ b/packages/components/src/input-control/input-base.tsx @@ -22,7 +22,8 @@ import { getSizeConfig, } from './styles/input-control-styles'; import type { InputBaseProps, LabelPosition } from './types'; -import { ContextSystemProvider, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { ContextSystemProvider } from '../ui/context'; function useUniqueId( idProp?: string ) { const instanceId = useInstanceId( InputBase ); diff --git a/packages/components/src/input-control/input-prefix-wrapper.tsx b/packages/components/src/input-control/input-prefix-wrapper.tsx index 310d51886e93dd..7f888bd9b74440 100644 --- a/packages/components/src/input-control/input-prefix-wrapper.tsx +++ b/packages/components/src/input-control/input-prefix-wrapper.tsx @@ -7,11 +7,8 @@ import type { ForwardedRef } from 'react'; * Internal dependencies */ import { Spacer } from '../spacer'; -import { - WordPressComponentProps, - contextConnect, - useContextSystem, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect, useContextSystem } from '../ui/context'; import type { InputControlPrefixWrapperProps } from './types'; function UnconnectedInputControlPrefixWrapper( diff --git a/packages/components/src/input-control/input-suffix-wrapper.tsx b/packages/components/src/input-control/input-suffix-wrapper.tsx index 16e7848f9c12aa..000457aefc3149 100644 --- a/packages/components/src/input-control/input-suffix-wrapper.tsx +++ b/packages/components/src/input-control/input-suffix-wrapper.tsx @@ -7,11 +7,8 @@ import type { ForwardedRef } from 'react'; * Internal dependencies */ import { Spacer } from '../spacer'; -import { - WordPressComponentProps, - contextConnect, - useContextSystem, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect, useContextSystem } from '../ui/context'; import type { InputControlSuffixWrapperProps } from './types'; function UnconnectedInputControlSuffixWrapper( diff --git a/packages/components/src/input-control/reducer/reducer.ts b/packages/components/src/input-control/reducer/reducer.ts index ec23db6cc281f5..8e3584d3910d74 100644 --- a/packages/components/src/input-control/reducer/reducer.ts +++ b/packages/components/src/input-control/reducer/reducer.ts @@ -11,12 +11,8 @@ import { useReducer, useLayoutEffect, useRef } from '@wordpress/element'; /** * Internal dependencies */ -import { - InputState, - StateReducer, - initialInputControlState, - initialStateReducer, -} from './state'; +import type { InputState, StateReducer } from './state'; +import { initialInputControlState, initialStateReducer } from './state'; import * as actions from './actions'; import type { InputChangeCallback } from '../types'; diff --git a/packages/components/src/input-control/stories/index.story.tsx b/packages/components/src/input-control/stories/index.story.tsx new file mode 100644 index 00000000000000..edfb43950d3a8a --- /dev/null +++ b/packages/components/src/input-control/stories/index.story.tsx @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import InputControl from '..'; +import { InputControlPrefixWrapper } from '../input-prefix-wrapper'; +import { InputControlSuffixWrapper } from '../input-suffix-wrapper'; + +const meta: Meta< typeof InputControl > = { + title: 'Components (Experimental)/InputControl', + component: InputControl, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { InputControlPrefixWrapper, InputControlSuffixWrapper }, + argTypes: { + __unstableInputWidth: { control: { type: 'text' } }, + __unstableStateReducer: { control: { type: null } }, + onChange: { control: { type: null } }, + prefix: { control: { type: null } }, + suffix: { control: { type: null } }, + type: { control: { type: 'text' } }, + value: { control: { disable: true } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof InputControl > = ( args ) => ( + <InputControl { ...args } /> +); + +export const Default = Template.bind( {} ); +Default.args = { + label: 'Value', + placeholder: 'Placeholder', +}; + +export const WithHelpText = Template.bind( {} ); +WithHelpText.args = { + ...Default.args, + help: 'Help text to describe the control.', +}; + +/** + * A `prefix` can be inserted before the input. By default, the prefix is aligned with the edge of the input border, + * with no padding. If you want to apply standard padding in accordance with the size variant, use the provided + * `<InputControlPrefixWrapper>` convenience wrapper. + */ +export const WithPrefix = Template.bind( {} ); +WithPrefix.args = { + ...Default.args, + prefix: <InputControlPrefixWrapper>@</InputControlPrefixWrapper>, +}; + +/** + * A `suffix` can be inserted after the input. By default, the suffix is aligned with the edge of the input border, + * with no padding. If you want to apply standard padding in accordance with the size variant, use the provided + * `<InputControlSuffixWrapper>` convenience wrapper. + */ +export const WithSuffix = Template.bind( {} ); +WithSuffix.args = { + ...Default.args, + suffix: <InputControlSuffixWrapper>%</InputControlSuffixWrapper>, +}; + +export const WithSideLabel = Template.bind( {} ); +WithSideLabel.args = { + ...Default.args, + labelPosition: 'side', +}; + +export const WithEdgeLabel = Template.bind( {} ); +WithEdgeLabel.args = { + ...Default.args, + __unstableInputWidth: '20em', + labelPosition: 'edge', +}; diff --git a/packages/components/src/input-control/stories/index.tsx b/packages/components/src/input-control/stories/index.tsx deleted file mode 100644 index 031711a173c18e..00000000000000 --- a/packages/components/src/input-control/stories/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import InputControl from '..'; -import { InputControlPrefixWrapper } from '../input-prefix-wrapper'; -import { InputControlSuffixWrapper } from '../input-suffix-wrapper'; - -const meta: ComponentMeta< typeof InputControl > = { - title: 'Components (Experimental)/InputControl', - component: InputControl, - subcomponents: { InputControlPrefixWrapper, InputControlSuffixWrapper }, - argTypes: { - __unstableInputWidth: { control: { type: 'text' } }, - __unstableStateReducer: { control: { type: null } }, - onChange: { control: { type: null } }, - prefix: { control: { type: null } }, - suffix: { control: { type: null } }, - type: { control: { type: 'text' } }, - value: { control: { disable: true } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof InputControl > = ( args ) => ( - <InputControl { ...args } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - label: 'Value', - placeholder: 'Placeholder', -}; - -export const WithHelpText = Template.bind( {} ); -WithHelpText.args = { - ...Default.args, - help: 'Help text to describe the control.', -}; - -/** - * A `prefix` can be inserted before the input. By default, the prefix is aligned with the edge of the input border, - * with no padding. If you want to apply standard padding in accordance with the size variant, use the provided - * `<InputControlPrefixWrapper>` convenience wrapper. - */ -export const WithPrefix = Template.bind( {} ); -WithPrefix.args = { - ...Default.args, - prefix: <InputControlPrefixWrapper>@</InputControlPrefixWrapper>, -}; - -/** - * A `suffix` can be inserted after the input. By default, the suffix is aligned with the edge of the input border, - * with no padding. If you want to apply standard padding in accordance with the size variant, use the provided - * `<InputControlSuffixWrapper>` convenience wrapper. - */ -export const WithSuffix = Template.bind( {} ); -WithSuffix.args = { - ...Default.args, - suffix: <InputControlSuffixWrapper>%</InputControlSuffixWrapper>, -}; - -export const WithSideLabel = Template.bind( {} ); -WithSideLabel.args = { - ...Default.args, - labelPosition: 'side', -}; - -export const WithEdgeLabel = Template.bind( {} ); -WithEdgeLabel.args = { - ...Default.args, - __unstableInputWidth: '20em', - labelPosition: 'edge', -}; diff --git a/packages/components/src/input-control/styles/input-control-styles.tsx b/packages/components/src/input-control/styles/input-control-styles.tsx index 3548be6f7260c5..65359be033a191 100644 --- a/packages/components/src/input-control/styles/input-control-styles.tsx +++ b/packages/components/src/input-control/styles/input-control-styles.tsx @@ -1,7 +1,8 @@ /** * External dependencies */ -import { css, SerializedStyles } from '@emotion/react'; +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; import styled from '@emotion/styled'; import type { CSSProperties, ReactNode } from 'react'; diff --git a/packages/components/src/item-group/item-group/component.tsx b/packages/components/src/item-group/item-group/component.tsx index ec6847161f7e27..34f73c38e2b57a 100644 --- a/packages/components/src/item-group/item-group/component.tsx +++ b/packages/components/src/item-group/item-group/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { useItemGroup } from './hook'; import { ItemGroupContext, useItemGroupContext } from '../context'; import { View } from '../../view'; @@ -43,7 +44,6 @@ function UnconnectedItemGroup( /** * `ItemGroup` displays a list of `Item`s grouped and styled together. * - * @example * ```jsx * import { * __experimentalItemGroup as ItemGroup, diff --git a/packages/components/src/item-group/item-group/hook.ts b/packages/components/src/item-group/item-group/hook.ts index f8ec3740e721ca..77327b91df5a6c 100644 --- a/packages/components/src/item-group/item-group/hook.ts +++ b/packages/components/src/item-group/item-group/hook.ts @@ -1,7 +1,8 @@ /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; /** * Internal dependencies diff --git a/packages/components/src/item-group/item/component.tsx b/packages/components/src/item-group/item/component.tsx index 18472782989cff..9104f5340377bd 100644 --- a/packages/components/src/item-group/item/component.tsx +++ b/packages/components/src/item-group/item/component.tsx @@ -8,7 +8,8 @@ import type { ForwardedRef } from 'react'; */ import type { ItemProps } from '../types'; import { useItem } from './hook'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; function UnconnectedItem( @@ -28,7 +29,6 @@ function UnconnectedItem( * `Item` is used in combination with `ItemGroup` to display a list of items * grouped and styled together. * - * @example * ```jsx * import { * __experimentalItemGroup as ItemGroup, diff --git a/packages/components/src/item-group/item/hook.ts b/packages/components/src/item-group/item/hook.ts index bd3be96c618b89..d1bc632ddb7f3e 100644 --- a/packages/components/src/item-group/item/hook.ts +++ b/packages/components/src/item-group/item/hook.ts @@ -11,7 +11,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import * as styles from '../styles'; import { useItemGroupContext } from '../context'; import { useCx } from '../../utils/hooks/use-cx'; @@ -42,7 +43,8 @@ export function useItem( props: WordPressComponentProps< ItemProps, 'div' > ) { const classes = useMemo( () => cx( - as === 'button' && styles.unstyledButton, + ( as === 'button' || as === 'a' ) && + styles.unstyledButton( as ), styles.itemSizes[ size ] || styles.itemSizes.medium, styles.item, spacedAround && styles.spacedAround, diff --git a/packages/components/src/item-group/stories/index.story.tsx b/packages/components/src/item-group/stories/index.story.tsx new file mode 100644 index 00000000000000..473cdfedbf4f12 --- /dev/null +++ b/packages/components/src/item-group/stories/index.story.tsx @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { ItemGroup } from '../item-group/component'; +import { Item } from '../item/component'; + +type ItemProps = React.ComponentPropsWithoutRef< typeof Item >; + +const meta: Meta< typeof ItemGroup > = { + component: ItemGroup, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { Item }, + title: 'Components (Experimental)/ItemGroup', + argTypes: { + as: { control: { type: null } }, + children: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const mapPropsToItem = ( props: ItemProps, index: number ) => ( + <Item { ...props } key={ index } /> +); + +const Template: StoryFn< typeof ItemGroup > = ( props ) => ( + <ItemGroup { ...props } /> +); + +export const Default: StoryFn< typeof ItemGroup > = Template.bind( {} ); +Default.args = { + children: ( + [ + { + children: 'First button item', + // eslint-disable-next-line no-alert + onClick: () => alert( 'First item clicked' ), + }, + { + children: 'Second button item', + // eslint-disable-next-line no-alert + onClick: () => alert( 'Second item clicked' ), + }, + { + children: 'Third button item', + // eslint-disable-next-line no-alert + onClick: () => alert( 'Third item clicked' ), + }, + { + children: 'Anchor item', + as: 'a', + href: 'https://wordpress.org', + }, + ] as ItemProps[] + ).map( mapPropsToItem ), +}; + +export const NonClickableItems: StoryFn< typeof ItemGroup > = Template.bind( + {} +); +NonClickableItems.args = { + children: ( + [ + { + children: + "This <Item /> is not click-able because it doesn't have an `onClick` prop", + }, + { + children: + "This <Item /> is also not click-able because it doesn't have an `onClick` prop", + }, + ] as ItemProps[] + ).map( mapPropsToItem ), +}; + +export const CustomItemSize: StoryFn< typeof ItemGroup > = Template.bind( {} ); +CustomItemSize.args = { + children: ( + [ + { + children: + 'This <Item /> will inherit the size from <ItemGroup /> (try changing the size prop)', + }, + { + children: + 'This <Item /> has a hardcoded size="large", regardless of <ItemGroup />\'s size', + size: 'large', + }, + ] as ItemProps[] + ).map( mapPropsToItem ), +}; + +export const WithBorder: StoryFn< typeof ItemGroup > = Template.bind( {} ); +WithBorder.args = { + ...Default.args, + isBordered: true, + isSeparated: true, +}; diff --git a/packages/components/src/item-group/stories/index.tsx b/packages/components/src/item-group/stories/index.tsx deleted file mode 100644 index 3fa35c67cfb533..00000000000000 --- a/packages/components/src/item-group/stories/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { ItemGroup } from '../item-group/component'; -import { Item } from '../item/component'; - -type ItemProps = React.ComponentPropsWithoutRef< typeof Item >; - -const meta: ComponentMeta< typeof ItemGroup > = { - component: ItemGroup, - title: 'Components (Experimental)/ItemGroup', - subcomponents: { Item }, - argTypes: { - as: { control: { type: null } }, - children: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const mapPropsToItem = ( props: ItemProps, index: number ) => ( - <Item { ...props } key={ index } /> -); - -const Template: ComponentStory< typeof ItemGroup > = ( props ) => ( - <ItemGroup { ...props } /> -); - -export const Default: ComponentStory< typeof ItemGroup > = Template.bind( {} ); -Default.args = { - children: ( - [ - { - children: 'First item', - // eslint-disable-next-line no-alert - onClick: () => alert( 'First item clicked' ), - }, - { - children: 'Second item', - // eslint-disable-next-line no-alert - onClick: () => alert( 'Second item clicked' ), - }, - { - children: 'Third item', - // eslint-disable-next-line no-alert - onClick: () => alert( 'Third item clicked' ), - }, - ] as ItemProps[] - ).map( mapPropsToItem ), -}; - -export const NonClickableItems: ComponentStory< typeof ItemGroup > = - Template.bind( {} ); -NonClickableItems.args = { - children: ( - [ - { - children: - "This <Item /> is not click-able because it doesn't have an `onClick` prop", - }, - { - children: - "This <Item /> is also not click-able because it doesn't have an `onClick` prop", - }, - ] as ItemProps[] - ).map( mapPropsToItem ), -}; - -export const CustomItemSize: ComponentStory< typeof ItemGroup > = Template.bind( - {} -); -CustomItemSize.args = { - children: ( - [ - { - children: - 'This <Item /> will inherit the size from <ItemGroup /> (try changing the size prop)', - }, - { - children: - 'This <Item /> has a hardcoded size="large", regardless of <ItemGroup />\'s size', - size: 'large', - }, - ] as ItemProps[] - ).map( mapPropsToItem ), -}; - -export const WithBorder: ComponentStory< typeof ItemGroup > = Template.bind( - {} -); -WithBorder.args = { - ...Default.args, - isBordered: true, - isSeparated: true, -}; diff --git a/packages/components/src/item-group/styles.ts b/packages/components/src/item-group/styles.ts index 9c2b66cd07a9b3..7019d61ff1ce51 100644 --- a/packages/components/src/item-group/styles.ts +++ b/packages/components/src/item-group/styles.ts @@ -6,26 +6,45 @@ import { css } from '@emotion/react'; /** * Internal dependencies */ -import { CONFIG, COLORS } from '../utils'; - -export const unstyledButton = css` - appearance: none; - border: 1px solid transparent; - cursor: pointer; - background: none; - text-align: start; - - &:hover { - color: ${ COLORS.ui.theme }; - } - - &:focus { - background-color: transparent; - color: ${ COLORS.ui.theme }; - border-color: ${ COLORS.ui.theme }; - outline: 3px solid transparent; - } -`; +import { CONFIG, COLORS, font } from '../utils'; + +export const unstyledButton = ( as: 'a' | 'button' ) => { + return css` + font-size: ${ font( 'default.fontSize' ) }; + font-family: inherit; + appearance: none; + border: 1px solid transparent; + cursor: pointer; + background: none; + text-align: start; + text-decoration: ${ as === 'a' ? 'none' : undefined }; + + svg, + path { + fill: currentColor; + } + + &:hover { + color: ${ COLORS.theme.accent }; + } + + &:focus { + box-shadow: none; + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) + var( + --wp-components-color-accent, + var( --wp-admin-theme-color, ${ COLORS.theme.accent } ) + ); + // Windows high contrast mode. + outline: 2px solid transparent; + outline-offset: 0; + } + `; +}; export const itemWrapper = css` width: 100%; diff --git a/packages/components/src/keyboard-shortcuts/stories/index.story.tsx b/packages/components/src/keyboard-shortcuts/stories/index.story.tsx new file mode 100644 index 00000000000000..d181be737353cd --- /dev/null +++ b/packages/components/src/keyboard-shortcuts/stories/index.story.tsx @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import KeyboardShortcuts from '..'; + +const meta: Meta< typeof KeyboardShortcuts > = { + component: KeyboardShortcuts, + title: 'Components/KeyboardShortcuts', + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof KeyboardShortcuts > = ( props ) => ( + <KeyboardShortcuts { ...props } /> +); + +export const Default = Template.bind( {} ); +Default.args = { + shortcuts: { + // eslint-disable-next-line no-alert + a: () => window.alert( 'You hit "a"!' ), + // eslint-disable-next-line no-alert + b: () => window.alert( 'You hit "b"!' ), + }, + children: ( + <div> + <p>{ `Hit the "a" or "b" key in this textarea:` }</p> + <textarea /> + </div> + ), +}; +Default.parameters = { + docs: { + source: { + code: ` +<KeyboardShortcuts + shortcuts={{ + a: () => window.alert('You hit "a"!'), + b: () => window.alert('You hit "b"!'), + }} +> + <div> + <p> + Hit the "a" or "b" key in this textarea: + </p> + <textarea /> + </div> +</KeyboardShortcuts> + `, + }, + }, +}; diff --git a/packages/components/src/keyboard-shortcuts/stories/index.tsx b/packages/components/src/keyboard-shortcuts/stories/index.tsx deleted file mode 100644 index 9a66bacce91087..00000000000000 --- a/packages/components/src/keyboard-shortcuts/stories/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import KeyboardShortcuts from '..'; - -const meta: ComponentMeta< typeof KeyboardShortcuts > = { - component: KeyboardShortcuts, - title: 'Components/KeyboardShortcuts', - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof KeyboardShortcuts > = ( props ) => ( - <KeyboardShortcuts { ...props } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - shortcuts: { - // eslint-disable-next-line no-alert - a: () => window.alert( 'You hit "a"!' ), - // eslint-disable-next-line no-alert - b: () => window.alert( 'You hit "b"!' ), - }, - children: ( - <div> - <p>{ `Hit the "a" or "b" key in this textarea:` }</p> - <textarea /> - </div> - ), -}; -Default.parameters = { - docs: { - source: { - code: ` -<KeyboardShortcuts - shortcuts={{ - a: () => window.alert('You hit "a"!'), - b: () => window.alert('You hit "b"!'), - }} -> - <div> - <p> - Hit the "a" or "b" key in this textarea: - </p> - <textarea /> - </div> -</KeyboardShortcuts> - `, - }, - }, -}; diff --git a/packages/components/src/menu-group/stories/index.story.tsx b/packages/components/src/menu-group/stories/index.story.tsx new file mode 100644 index 00000000000000..80cdae3dab600a --- /dev/null +++ b/packages/components/src/menu-group/stories/index.story.tsx @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import MenuGroup from '..'; +import MenuItem from '../../menu-item'; +import MenuItemsChoice from '../../menu-items-choice'; + +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +const meta: Meta< typeof MenuGroup > = { + title: 'Components/MenuGroup', + component: MenuGroup, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof MenuGroup > = ( args ) => { + return ( + <MenuGroup { ...args }> + <MenuItem>Menu Item 1</MenuItem> + <MenuItem>Menu Item 2</MenuItem> + </MenuGroup> + ); +}; + +export const Default: StoryFn< typeof MenuGroup > = Template.bind( {} ); + +const MultiGroupsTemplate: StoryFn< typeof MenuGroup > = ( args ) => { + const [ mode, setMode ] = useState( 'visual' ); + const choices = [ + { + value: 'visual', + label: 'Visual editor', + }, + { + value: 'text', + label: 'Code editor', + }, + ]; + + return ( + <> + <MenuGroup label={ 'View' }> + <MenuItem>Top Toolbar</MenuItem> + <MenuItem>Spotlight Mode</MenuItem> + <MenuItem>Distraction Free</MenuItem> + </MenuGroup> + <MenuGroup { ...args }> + <MenuItemsChoice + choices={ choices } + value={ mode } + onSelect={ ( newMode: string ) => setMode( newMode ) } + onHover={ () => {} } + /> + </MenuGroup> + </> + ); +}; + +/** + * When other menu items exist above or below a MenuGroup, the group + * should have a divider line between it and the adjacent item. + */ +export const WithSeperator = MultiGroupsTemplate.bind( {} ); +WithSeperator.args = { + ...Default.args, + hideSeparator: false, + label: 'Editor', +}; diff --git a/packages/components/src/menu-item/README.md b/packages/components/src/menu-item/README.md index 68affcd63b8bfd..a13d8dbd5eabed 100644 --- a/packages/components/src/menu-item/README.md +++ b/packages/components/src/menu-item/README.md @@ -34,6 +34,13 @@ MenuItem supports the following props. Any additional props are passed through t Element to render as child of button. +### `disabled` + +- Type: `boolean` +- Required: No + +Refer to documentation for [Button's `disabled` prop](/packages/components/src/button/README.md#disabled-boolean). + ### `info` - Type: `string` @@ -63,7 +70,7 @@ Determines where to display the provided `icon`. - Type: `boolean` - Required: No -Whether or not the menu item is currently selected. +Whether or not the menu item is currently selected. `isSelected` is only taken into account when the `role` prop is either `"menuitemcheckbox"` or `"menuitemradio"`. ### `shortcut` diff --git a/packages/components/src/menu-item/index.js b/packages/components/src/menu-item/index.js deleted file mode 100644 index 907d54fb680541..00000000000000 --- a/packages/components/src/menu-item/index.js +++ /dev/null @@ -1,81 +0,0 @@ -// @ts-nocheck -/** - * External dependencies - */ -import classnames from 'classnames'; - -/** - * WordPress dependencies - */ -import { cloneElement, forwardRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Shortcut from '../shortcut'; -import Button from '../button'; -import Icon from '../icon'; - -export function MenuItem( props, ref ) { - let { - children, - info, - className, - icon, - iconPosition = 'right', - shortcut, - isSelected, - role = 'menuitem', - suffix, - ...buttonProps - } = props; - - className = classnames( 'components-menu-item__button', className ); - - if ( info ) { - children = ( - <span className="components-menu-item__info-wrapper"> - <span className="components-menu-item__item">{ children }</span> - <span className="components-menu-item__info">{ info }</span> - </span> - ); - } - - if ( icon && typeof icon !== 'string' ) { - icon = cloneElement( icon, { - className: classnames( 'components-menu-items__item-icon', { - 'has-icon-right': iconPosition === 'right', - } ), - } ); - } - - return ( - <Button - ref={ ref } - // Make sure aria-checked matches spec https://www.w3.org/TR/wai-aria-1.1/#aria-checked - aria-checked={ - role === 'menuitemcheckbox' || role === 'menuitemradio' - ? isSelected - : undefined - } - role={ role } - icon={ iconPosition === 'left' ? icon : undefined } - className={ className } - { ...buttonProps } - > - <span className="components-menu-item__item">{ children }</span> - { ! suffix && ( - <Shortcut - className="components-menu-item__shortcut" - shortcut={ shortcut } - /> - ) } - { ! suffix && icon && iconPosition === 'right' && ( - <Icon icon={ icon } /> - ) } - { suffix } - </Button> - ); -} - -export default forwardRef( MenuItem ); diff --git a/packages/components/src/menu-item/index.tsx b/packages/components/src/menu-item/index.tsx new file mode 100644 index 00000000000000..2904763cc6c58b --- /dev/null +++ b/packages/components/src/menu-item/index.tsx @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { cloneElement, forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Shortcut from '../shortcut'; +import Button from '../button'; +import Icon from '../icon'; +import type { WordPressComponentProps } from '../ui/context'; +import type { MenuItemProps } from './types'; + +function UnforwardedMenuItem( + props: WordPressComponentProps< MenuItemProps, 'button', false >, + ref: ForwardedRef< HTMLButtonElement > +) { + let { + children, + info, + className, + icon, + iconPosition = 'right', + shortcut, + isSelected, + role = 'menuitem', + suffix, + ...buttonProps + } = props; + + className = classnames( 'components-menu-item__button', className ); + + if ( info ) { + children = ( + <span className="components-menu-item__info-wrapper"> + <span className="components-menu-item__item">{ children }</span> + <span className="components-menu-item__info">{ info }</span> + </span> + ); + } + + if ( icon && typeof icon !== 'string' ) { + icon = cloneElement( icon, { + className: classnames( 'components-menu-items__item-icon', { + 'has-icon-right': iconPosition === 'right', + } ), + } ); + } + + return ( + <Button + ref={ ref } + // Make sure aria-checked matches spec https://www.w3.org/TR/wai-aria-1.1/#aria-checked + aria-checked={ + role === 'menuitemcheckbox' || role === 'menuitemradio' + ? isSelected + : undefined + } + role={ role } + icon={ iconPosition === 'left' ? icon : undefined } + className={ className } + { ...buttonProps } + > + <span className="components-menu-item__item">{ children }</span> + { ! suffix && ( + <Shortcut + className="components-menu-item__shortcut" + shortcut={ shortcut } + /> + ) } + { ! suffix && icon && iconPosition === 'right' && ( + <Icon icon={ icon } /> + ) } + { suffix } + </Button> + ); +} + +/** + * MenuItem is a component which renders a button intended to be used in combination with the `DropdownMenu` component. + * + * ```jsx + * import { MenuItem } from '@wordpress/components'; + * import { useState } from '@wordpress/element'; + * + * const MyMenuItem = () => { + * const [ isActive, setIsActive ] = useState( true ); + * + * return ( + * <MenuItem + * icon={ isActive ? 'yes' : 'no' } + * isSelected={ isActive } + * role="menuitemcheckbox" + * onClick={ () => setIsActive( ( state ) => ! state ) } + * > + * Toggle + * </MenuItem> + * ); + * }; + * ``` + */ +export const MenuItem = forwardRef( UnforwardedMenuItem ); + +export default MenuItem; diff --git a/packages/components/src/menu-item/stories/index.story.tsx b/packages/components/src/menu-item/stories/index.story.tsx new file mode 100644 index 00000000000000..763ee6e96be922 --- /dev/null +++ b/packages/components/src/menu-item/stories/index.story.tsx @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { link, more, check } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import MenuGroup from '../../menu-group'; +import MenuItem from '..'; +import Shortcut from '../../shortcut'; + +const meta: Meta< typeof MenuItem > = { + component: MenuItem, + title: 'Components/MenuItem', + argTypes: { + children: { control: { type: null } }, + icon: { + control: { type: 'select' }, + options: [ 'check', 'link', 'more' ], + mapping: { + check, + link, + more, + }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof MenuItem > = ( props ) => { + return ( + <MenuGroup> + <MenuItem { ...props }>Menu Item 1</MenuItem> + </MenuGroup> + ); +}; + +export const Default: StoryFn< typeof MenuItem > = Template.bind( {} ); + +/** + * When the `role` prop is either `"menuitemcheckbox"` or `"menuitemradio"`, the + * `isSelected` prop should be used so screen readers can tell which item is currently selected. + */ +export const IsSelected = Template.bind( {} ); +IsSelected.args = { + ...Default.args, + isSelected: true, + role: 'menuitemcheckbox', +}; + +export const WithIcon = Template.bind( {} ); +WithIcon.args = { + ...Default.args, + icon: link, + iconPosition: 'left', +}; + +export const WithInfo = Template.bind( {} ); +WithInfo.args = { + ...Default.args, + info: 'Menu Item description', +}; + +export const WithSuffix = Template.bind( {} ); +WithSuffix.args = { + ...Default.args, + suffix: <Shortcut shortcut="Ctrl+M"></Shortcut>, +}; diff --git a/packages/components/src/menu-item/types.ts b/packages/components/src/menu-item/types.ts new file mode 100644 index 00000000000000..d213af6153accc --- /dev/null +++ b/packages/components/src/menu-item/types.ts @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * Internal dependencies + */ +import type { ButtonAsButtonProps } from '../button/types'; + +export type MenuItemProps = Pick< ButtonAsButtonProps, 'isDestructive' > & { + /** + * A CSS `class` to give to the container element. + */ + className?: string; + /** + * The children elements. + */ + children?: ReactNode; + /** + * Text to use as description for button text. + */ + info?: string; + /** + * The icon to render. Supported values are: Dashicons (specified as + * strings), functions, Component instances and `null`. + * + * @default null + */ + icon?: JSX.Element | null; + /** + * Determines where to display the provided `icon`. + */ + iconPosition?: ButtonAsButtonProps[ 'iconPosition' ]; + /** + * Whether or not the menu item is currently selected, `isSelected` is only taken into + * account when the `role` prop is either `"menuitemcheckbox"` or `"menuitemradio"`. + */ + isSelected?: boolean; + /** + * If shortcut is a string, it is expecting the display text. If shortcut is an object, + * it will accept the properties of `display` (string) and `ariaLabel` (string). + */ + shortcut?: string | { display: string; ariaLabel: string }; + /** + * If you need to have selectable menu items use menuitemradio for single select, + * and menuitemcheckbox for multiselect. + * + * @default 'menuitem' + */ + role?: string; + /** + * Allows for markup other than icons or shortcuts to be added to the menu item. + * + */ + suffix?: ReactNode; + /** + * Human-readable label for item. + */ + label?: string; +}; diff --git a/packages/components/src/menu-items-choice/index.tsx b/packages/components/src/menu-items-choice/index.tsx index 171e5fae72a60f..ecdf9ba47242d7 100644 --- a/packages/components/src/menu-items-choice/index.tsx +++ b/packages/components/src/menu-items-choice/index.tsx @@ -58,7 +58,8 @@ function MenuItemsChoice( { <MenuItem key={ item.value } role="menuitemradio" - icon={ isSelected && check } + disabled={ item.disabled } + icon={ isSelected ? check : null } info={ item.info } isSelected={ isSelected } shortcut={ item.shortcut } diff --git a/packages/components/src/menu-items-choice/stories/index.story.tsx b/packages/components/src/menu-items-choice/stories/index.story.tsx new file mode 100644 index 00000000000000..02e76158981e8e --- /dev/null +++ b/packages/components/src/menu-items-choice/stories/index.story.tsx @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import MenuItemsChoice from '..'; +import MenuGroup from '../../menu-group'; + +const meta: Meta< typeof MenuItemsChoice > = { + component: MenuItemsChoice, + title: 'Components/MenuItemsChoice', + argTypes: { + onHover: { action: 'onHover' }, + onSelect: { action: 'onSelect' }, + value: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof MenuItemsChoice > = ( { + onHover, + onSelect, + choices, +} ) => { + const [ choice, setChoice ] = useState( choices[ 0 ]?.value ?? '' ); + + return ( + <MenuGroup label="Editor"> + <MenuItemsChoice + choices={ choices } + value={ choice } + onSelect={ ( ...selectArgs ) => { + onSelect( ...selectArgs ); + setChoice( ...selectArgs ); + } } + onHover={ onHover } + /> + </MenuGroup> + ); +}; + +export const Default: StoryFn< typeof MenuItemsChoice > = Template.bind( {} ); + +Default.args = { + choices: [ + { + value: 'arbitrary-choice-1', + label: 'Arbitrary Label #1', + info: 'Arbitrary Explanatory 1', + }, + { + value: 'arbitrary-choice-2', + label: 'Arbitrary Label #2', + info: 'Arbitrary Explanatory 2', + }, + { + value: 'arbitrary-choice-3', + label: 'Arbitrary Label #3', + info: 'Arbitrary Explanatory 3', + }, + ], + value: 'arbitrary-choice-1', +}; diff --git a/packages/components/src/menu-items-choice/stories/index.tsx b/packages/components/src/menu-items-choice/stories/index.tsx deleted file mode 100644 index 1b374b4a92b8dc..00000000000000 --- a/packages/components/src/menu-items-choice/stories/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import MenuItemsChoice from '..'; -import MenuGroup from '../../menu-group'; - -const meta: ComponentMeta< typeof MenuItemsChoice > = { - component: MenuItemsChoice, - title: 'Components/MenuItemsChoice', - argTypes: { - onHover: { action: 'onHover' }, - onSelect: { action: 'onSelect' }, - value: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof MenuItemsChoice > = ( { - onHover, - onSelect, - choices, -} ) => { - const [ choice, setChoice ] = useState( choices[ 0 ]?.value ?? '' ); - - return ( - <MenuGroup label="Editor"> - <MenuItemsChoice - choices={ choices } - value={ choice } - onSelect={ ( ...selectArgs ) => { - onSelect( ...selectArgs ); - setChoice( ...selectArgs ); - } } - onHover={ onHover } - /> - </MenuGroup> - ); -}; - -export const Default: ComponentStory< typeof MenuItemsChoice > = Template.bind( - {} -); - -Default.args = { - choices: [ - { - value: 'arbitrary-choice-1', - label: 'Arbitrary Label #1', - info: 'Arbitrary Explanatory 1', - }, - { - value: 'arbitrary-choice-2', - label: 'Arbitrary Label #2', - info: 'Arbitrary Explanatory 2', - }, - { - value: 'arbitrary-choice-3', - label: 'Arbitrary Label #3', - info: 'Arbitrary Explanatory 3', - }, - ], - value: 'arbitrary-choice-1', -}; diff --git a/packages/components/src/menu-items-choice/types.ts b/packages/components/src/menu-items-choice/types.ts index 9695c58e459838..87d8e7687a4fbd 100644 --- a/packages/components/src/menu-items-choice/types.ts +++ b/packages/components/src/menu-items-choice/types.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import type { ShortcutProps } from '../shortcut/types'; +import type { ButtonAsButtonProps } from '../button/types'; export type MenuItemsChoiceProps = { /** @@ -38,6 +39,10 @@ export type MenuItemChoice = { * Unique value for choice. */ value: string; + /** + * Whether the menu item is disabled. + */ + disabled?: ButtonAsButtonProps[ 'disabled' ]; /** * Additional information which will be rendered below the given label. */ diff --git a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/bottom-sheet-navigation-context.native.js b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/bottom-sheet-navigation-context.native.js index 9e89c285b25347..5995490239d5d5 100644 --- a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/bottom-sheet-navigation-context.native.js +++ b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/bottom-sheet-navigation-context.native.js @@ -6,7 +6,7 @@ import { createContext } from '@wordpress/element'; // Navigation context in BottomSheet is necessary for controlling the // height of navigation container. export const BottomSheetNavigationContext = createContext( { - currentHeight: 1, + currentHeight: { value: 0 }, setHeight: () => {}, } ); diff --git a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-container.native.js b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-container.native.js index 69534681dfa051..5f87ff80194b81 100644 --- a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-container.native.js +++ b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-container.native.js @@ -1,15 +1,19 @@ /** * External dependencies */ -import { View, Easing } from 'react-native'; import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; /** * WordPress dependencies */ import { - useState, useContext, useMemo, useCallback, @@ -23,11 +27,11 @@ import { usePreferredColorSchemeStyle } from '@wordpress/compose'; /** * Internal dependencies */ -import { performLayoutAnimation } from '../../layout-animation'; import { BottomSheetNavigationContext, BottomSheetNavigationProvider, } from './bottom-sheet-navigation-context'; +import { BottomSheetContext } from '../bottom-sheet-context'; import styles from './styles.scss'; @@ -55,9 +59,11 @@ const options = { headerShown: false, gestureEnabled: false, cardStyleInterpolator: fadeConfig, + keyboardHandlingEnabled: false, }; -const ANIMATION_DURATION = 190; +const HEIGHT_ANIMATION_DURATION = 300; +const DEFAULT_HEIGHT = 1; function BottomSheetNavigationContainer( { children, @@ -65,11 +71,14 @@ function BottomSheetNavigationContainer( { main, theme, style, + testID, } ) { const Stack = useRef( createStackNavigator() ).current; - const context = useContext( BottomSheetNavigationContext ); - const [ currentHeight, setCurrentHeight ] = useState( - context.currentHeight || 1 + const navigationContext = useContext( BottomSheetNavigationContext ); + const { maxHeight: sheetMaxHeight, isMaxHeightSet: isSheetMaxHeightSet } = + useContext( BottomSheetContext ); + const currentHeight = useSharedValue( + navigationContext.currentHeight?.value || DEFAULT_HEIGHT ); const backgroundStyle = usePreferredColorSchemeStyle( @@ -77,47 +86,49 @@ function BottomSheetNavigationContainer( { styles.backgroundDark ); - const _theme = theme || { - ...DefaultTheme, - colors: { - ...DefaultTheme.colors, - background: backgroundStyle.backgroundColor, - }, - }; + const defaultTheme = useMemo( + () => ( { + ...DefaultTheme, + colors: { + ...DefaultTheme.colors, + background: backgroundStyle.backgroundColor, + }, + } ), + [ backgroundStyle.backgroundColor ] + ); + const _theme = theme || defaultTheme; const setHeight = useCallback( ( height ) => { - // The screen is fullHeight. if ( - typeof height === 'string' && - typeof height !== typeof currentHeight + height > DEFAULT_HEIGHT && + Math.round( height ) !== Math.round( currentHeight.value ) ) { - performLayoutAnimation( ANIMATION_DURATION ); - setCurrentHeight( height ); - - return; - } - - if ( - height > 1 && - Math.round( height ) !== Math.round( currentHeight ) - ) { - if ( currentHeight === 1 ) { - setCurrentHeight( height ); - } else if ( animate ) { - performLayoutAnimation( ANIMATION_DURATION ); - setCurrentHeight( height ); + // If max height is set in the bottom sheet, we clamp + // the new height using that value. + const newHeight = isSheetMaxHeightSet + ? Math.min( sheetMaxHeight, height ) + : height; + const shouldAnimate = + animate && currentHeight.value !== DEFAULT_HEIGHT; + + if ( shouldAnimate ) { + currentHeight.value = withTiming( newHeight, { + duration: HEIGHT_ANIMATION_DURATION, + easing: Easing.out( Easing.cubic ), + } ); } else { - setCurrentHeight( height ); + currentHeight.value = newHeight; } } }, - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps - [ currentHeight ] + [ animate, currentHeight, isSheetMaxHeightSet, sheetMaxHeight ] ); + const animatedStyles = useAnimatedStyle( () => ( { + height: currentHeight.value, + } ) ); + const screens = useMemo( () => { return Children.map( children, ( child ) => { let screen = child; @@ -136,38 +147,47 @@ function BottomSheetNavigationContainer( { /> ); } ); - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ children ] ); + }, [ children, main ] ); return useMemo( () => { return ( - <View style={ [ style, { height: currentHeight } ] }> + <Animated.View + style={ [ style, animatedStyles ] } + testID={ testID } + > <BottomSheetNavigationProvider - value={ { - setHeight, - currentHeight, - } } + value={ { setHeight, currentHeight } } > { main ? ( <NavigationContainer theme={ _theme }> - <Stack.Navigator screenOptions={ options }> + <Stack.Navigator + screenOptions={ options } + detachInactiveScreens={ false } + > { screens } </Stack.Navigator> </NavigationContainer> ) : ( - <Stack.Navigator screenOptions={ options }> + <Stack.Navigator + screenOptions={ options } + detachInactiveScreens={ false } + > { screens } </Stack.Navigator> ) } </BottomSheetNavigationProvider> - </View> + </Animated.View> ); - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ currentHeight, _theme ] ); + }, [ + _theme, + animatedStyles, + currentHeight, + main, + screens, + setHeight, + style, + testID, + ] ); } export default BottomSheetNavigationContainer; diff --git a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-screen.native.js b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-screen.native.js index 059adb42c7089a..bde7c2c67ee52c 100644 --- a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-screen.native.js +++ b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/navigation-screen.native.js @@ -6,13 +6,17 @@ import { useNavigation, useFocusEffect, } from '@react-navigation/native'; -import { View, ScrollView, TouchableHighlight } from 'react-native'; +import { + ScrollView, + TouchableHighlight, + useWindowDimensions, + View, +} from 'react-native'; /** * WordPress dependencies */ import { BottomSheetContext } from '@wordpress/components'; -import { debounce } from '@wordpress/compose'; import { useRef, useCallback, useContext, useMemo } from '@wordpress/element'; /** @@ -29,7 +33,7 @@ const BottomSheetNavigationScreen = ( { name, } ) => { const navigation = useNavigation(); - const heightRef = useRef( { maxHeight: 0 } ); + const maxHeight = useRef( 0 ); const isFocused = useIsFocused(); const { onHandleHardwareButtonPress, @@ -38,16 +42,10 @@ const BottomSheetNavigationScreen = ( { listProps, safeAreaBottomInset, } = useContext( BottomSheetContext ); + const { height: windowHeight } = useWindowDimensions(); const { setHeight } = useContext( BottomSheetNavigationContext ); - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps - const setHeightDebounce = useCallback( debounce( setHeight, 10 ), [ - setHeight, - ] ); - useFocusEffect( useCallback( () => { onHandleHardwareButtonPress( () => { @@ -81,17 +79,14 @@ const BottomSheetNavigationScreen = ( { useFocusEffect( useCallback( () => { if ( fullScreen ) { - setHeight( '100%' ); + setHeight( windowHeight ); setIsFullScreen( true ); - } else if ( heightRef.current.maxHeight !== 0 ) { + } else if ( maxHeight.current !== 0 ) { setIsFullScreen( false ); - setHeight( heightRef.current.maxHeight ); + setHeight( maxHeight.current ); } return () => {}; - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ setHeight ] ) + }, [ fullScreen, setHeight, setIsFullScreen, windowHeight ] ) ); const onLayout = ( { nativeEvent } ) => { @@ -99,10 +94,9 @@ const BottomSheetNavigationScreen = ( { return; } const { height } = nativeEvent.layout; - - if ( heightRef.current.maxHeight !== height && isFocused ) { - heightRef.current.maxHeight = height; - setHeightDebounce( height ); + if ( maxHeight.current !== height && isFocused ) { + maxHeight.current = height; + setHeight( height ); } }; diff --git a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/test/navigation-container.native.js b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/test/navigation-container.native.js index ced540e66d42a0..9ff6a115f0a6f9 100644 --- a/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/test/navigation-container.native.js +++ b/packages/components/src/mobile/bottom-sheet/bottom-sheet-navigation/test/navigation-container.native.js @@ -2,7 +2,12 @@ * External dependencies */ import { Text } from 'react-native'; -import { render, fireEvent, waitFor, act } from 'test/helpers'; +import { + render, + fireEvent, + withReanimatedTimer, + advanceAnimationByTime, +} from 'test/helpers'; import { useNavigation } from '@react-navigation/native'; /** @@ -10,11 +15,8 @@ import { useNavigation } from '@react-navigation/native'; */ import NavigationContainer from '../navigation-container'; import NavigationScreen from '../navigation-screen'; -import { performLayoutAnimation } from '../../../layout-animation'; -jest.mock( '../../../layout-animation', () => ( { - performLayoutAnimation: jest.fn(), -} ) ); +const WINDOW_HEIGHT = 1000; const TestScreen = ( { fullScreen, name, navigateTo } ) => { const navigation = useNavigation(); @@ -27,143 +29,174 @@ const TestScreen = ( { fullScreen, name, navigateTo } ) => { ); }; +const fireLayoutEvent = ( element, layout ) => + fireEvent( element, 'layout', { + nativeEvent: { layout }, + } ); + beforeAll( () => { - jest.useFakeTimers( { legacyFakeTimers: true } ); + jest.spyOn( + require( 'react-native' ), + 'useWindowDimensions' + ).mockReturnValue( { width: 900, height: WINDOW_HEIGHT } ); } ); -afterAll( () => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); -} ); +it( 'animates height transitioning from non-full-screen to non-full-screen', async () => + withReanimatedTimer( async () => { + const screen = render( + <NavigationContainer testID="navigation-container" main animate> + <TestScreen name="test-screen-1" navigateTo="test-screen-2" /> + <TestScreen name="test-screen-2" navigateTo="test-screen-1" /> + </NavigationContainer> + ); -it( 'animates height transitioning from non-full-screen to full-screen', async () => { - const screen = render( - <NavigationContainer main animate> - <TestScreen name="test-screen-1" navigateTo="test-screen-2" /> - <TestScreen - name="test-screen-2" - navigateTo="test-screen-1" - fullScreen - /> - </NavigationContainer> - ); + const navigationContainer = await screen.findByTestId( + 'navigation-container' + ); - // Await navigation screen to allow async state updates to complete - const navigationScreen = await waitFor( () => - screen.getByTestId( 'navigation-screen-test-screen-1' ) - ); - // Trigger non-full-screen layout event - act( () => { - fireEvent( navigationScreen, 'layout', { - nativeEvent: { - layout: { - height: 123, - }, - }, + expect( navigationContainer ).toHaveAnimatedStyle( { height: 1 } ); + + // First height value should be set without animation, but we need + // to wait for a frame to let animated styles be updated. + const screen1Layout = { height: 100 }; + fireLayoutEvent( + screen.getByTestId( 'navigation-screen-test-screen-1' ), + screen1Layout + ); + advanceAnimationByTime( 1 ); + expect( navigationContainer ).toHaveAnimatedStyle( screen1Layout ); + + // Navigate to screen 2 + fireEvent.press( screen.getByText( /test-screen-1/ ) ); + const screen2Layout = { height: 200 }; + fireLayoutEvent( + screen.getByTestId( 'navigation-screen-test-screen-2' ), + screen2Layout + ); + // The animation takes 300 ms, so we wait that time plus 1 ms + // to the completion. + advanceAnimationByTime( 301 ); + expect( navigationContainer ).toHaveAnimatedStyle( screen2Layout ); + } ) ); + +it( 'animates height transitioning from non-full-screen to full-screen', async () => + withReanimatedTimer( async () => { + const screen = render( + <NavigationContainer testID="navigation-container" main animate> + <TestScreen name="test-screen-1" navigateTo="test-screen-2" /> + <TestScreen + name="test-screen-2" + navigateTo="test-screen-1" + fullScreen + /> + </NavigationContainer> + ); + + const navigationContainer = await screen.findByTestId( + 'navigation-container' + ); + + expect( navigationContainer ).toHaveAnimatedStyle( { height: 1 } ); + + // First height value should be set without animation, but we need + // to wait for a frame to let animated styles be updated. + const screen1Layout = { height: 100 }; + fireLayoutEvent( + screen.getByTestId( 'navigation-screen-test-screen-1' ), + screen1Layout + ); + advanceAnimationByTime( 1 ); + expect( navigationContainer ).toHaveAnimatedStyle( screen1Layout ); + + // Navigate to screen 2 + fireEvent.press( screen.getByText( /test-screen-1/ ) ); + // The animation takes 300 ms, so we wait that time plus 1 ms + // to the completion. + advanceAnimationByTime( 301 ); + expect( navigationContainer ).toHaveAnimatedStyle( { + height: WINDOW_HEIGHT, } ); - // Trigger debounced setting of height after layout event - jest.advanceTimersByTime( 10 ); - } ); - // Navigate to screen 2 - fireEvent.press( - await waitFor( () => screen.getByText( /test-screen-1/ ) ) - ); - // Await navigation screen to allow async state updates to complete - await waitFor( () => screen.getByText( /test-screen-2/ ) ); + } ) ); - expect( performLayoutAnimation ).toHaveBeenCalledTimes( 1 ); -} ); +it( 'animates height transitioning from full-screen to non-full-screen', async () => + withReanimatedTimer( async () => { + const screen = render( + <NavigationContainer testID="navigation-container" main animate> + <TestScreen name="test-screen-1" navigateTo="test-screen-2" /> + <TestScreen + name="test-screen-2" + navigateTo="test-screen-1" + fullScreen + /> + </NavigationContainer> + ); -it( 'animates height transitioning from full-screen to non-full-screen', async () => { - const screen = render( - <NavigationContainer main animate> - <TestScreen name="test-screen-1" navigateTo="test-screen-2" /> - <TestScreen - name="test-screen-2" - navigateTo="test-screen-1" - fullScreen - /> - </NavigationContainer> - ); + const navigationContainer = await screen.findByTestId( + 'navigation-container' + ); - // Await navigation screen to allow async state updates to complete - const navigationScreen = await waitFor( () => - screen.getByTestId( 'navigation-screen-test-screen-1' ) - ); - // Trigger non-full-screen layout event - act( () => { - fireEvent( navigationScreen, 'layout', { - nativeEvent: { - layout: { - height: 123, - }, - }, + expect( navigationContainer ).toHaveAnimatedStyle( { height: 1 } ); + + // First height value should be set without animation, but we need + // to wait for a frame to let animated styles be updated. + const screen1Layout = { height: 100 }; + fireLayoutEvent( + screen.getByTestId( 'navigation-screen-test-screen-1' ), + screen1Layout + ); + advanceAnimationByTime( 1 ); + expect( navigationContainer ).toHaveAnimatedStyle( screen1Layout ); + + // Navigate to screen 2 + fireEvent.press( screen.getByText( /test-screen-1/ ) ); + // The animation takes 300 ms, so we wait that time plus 1 ms + // to the completion. + advanceAnimationByTime( 301 ); + expect( navigationContainer ).toHaveAnimatedStyle( { + height: WINDOW_HEIGHT, } ); - // Trigger debounced setting of height after layout event - jest.advanceTimersByTime( 10 ); - } ); - // Navigate to screen 2 - fireEvent.press( - await waitFor( () => screen.getByText( /test-screen-1/ ) ) - ); - // Navigate to screen 1 - fireEvent.press( - // Use custom waitFor due to https://github.com/callstack/react-native-testing-library/issues/379 - await waitFor( () => screen.getByText( /test-screen-2/ ) ) - ); - // Await navigation screen to allow async state updates to complete - await waitFor( () => screen.getByText( /test-screen-1/ ) ); - expect( performLayoutAnimation ).toHaveBeenCalledTimes( 2 ); -} ); + // Navigate to screen 1 + fireEvent.press( await screen.findByText( /test-screen-2/ ) ); + // The animation takes 300 ms, so we wait that time plus 1 ms + // to the completion. + advanceAnimationByTime( 301 ); + expect( navigationContainer ).toHaveAnimatedStyle( screen1Layout ); + } ) ); -it( 'does not animate height transitioning from full-screen to full-screen', async () => { - const screen = render( - <NavigationContainer main animate> - <TestScreen name="test-screen-1" navigateTo="test-screen-2" /> - <TestScreen - name="test-screen-2" - navigateTo="test-screen-3" - fullScreen - /> - <TestScreen - name="test-screen-3" - navigateTo="test-screen-2" - fullScreen - /> - </NavigationContainer> - ); +it( 'does not animate height transitioning from full-screen to full-screen', async () => + withReanimatedTimer( async () => { + const screen = render( + <NavigationContainer testID="navigation-container" main animate> + <TestScreen + name="test-screen-1" + navigateTo="test-screen-2" + fullScreen + /> + <TestScreen + name="test-screen-2" + navigateTo="test-screen-1" + fullScreen + /> + </NavigationContainer> + ); - // Await navigation screen to allow async state updates to complete - const navigationScreen = await waitFor( () => - screen.getByTestId( 'navigation-screen-test-screen-1' ) - ); - // Trigger non-full-screen layout event - act( () => { - fireEvent( navigationScreen, 'layout', { - nativeEvent: { - layout: { - height: 123, - }, - }, + const navigationContainer = await screen.findByTestId( + 'navigation-container' + ); + + // First height value should be set without animation, but we need + // to wait for a frame to let animated styles be updated. + advanceAnimationByTime( 1 ); + expect( navigationContainer ).toHaveAnimatedStyle( { + height: WINDOW_HEIGHT, } ); - // Trigger debounced setting of height after layout event - jest.advanceTimersByTime( 10 ); - } ); - // Navigate to screen 2 - fireEvent.press( - await waitFor( () => screen.getByText( /test-screen-1/ ) ) - ); - // Navigate to screen 3 - fireEvent.press( - await waitFor( () => screen.getByText( /test-screen-2/ ) ) - ); - // Navigate to screen 2 - fireEvent.press( - await waitFor( () => screen.getByText( /test-screen-3/ ) ) - ); - // Await navigation screen to allow async state updates to complete - await waitFor( () => screen.getByText( /test-screen-2/ ) ); - expect( performLayoutAnimation ).toHaveBeenCalledTimes( 1 ); -} ); + // Navigate to screen 2 + fireEvent.press( screen.getByText( /test-screen-1/ ) ); + // We wait some milliseconds to check if height has changed. + advanceAnimationByTime( 10 ); + expect( navigationContainer ).toHaveAnimatedStyle( { + height: WINDOW_HEIGHT, + } ); + } ) ); diff --git a/packages/components/src/mobile/bottom-sheet/cell.native.js b/packages/components/src/mobile/bottom-sheet/cell.native.js index 47c288b1311594..30f061dc5c14b3 100644 --- a/packages/components/src/mobile/bottom-sheet/cell.native.js +++ b/packages/components/src/mobile/bottom-sheet/cell.native.js @@ -257,7 +257,7 @@ class BottomSheetCell extends Component { placeholder={ valuePlaceholder } placeholderTextColor={ placeholderTextColor } onChangeText={ onChangeValue } - editable={ isValueEditable } + editable={ isValueEditable && ! disabled } pointerEvents={ this.state.isEditingValue ? 'auto' : 'none' } @@ -431,6 +431,7 @@ class BottomSheetCell extends Component { <Icon icon={ check } fill={ platformStyles.isSelected.color } + testID="bottom-sheet-cell-selected-icon" /> ) } { showValue && getValueComponent() } diff --git a/packages/components/src/mobile/bottom-sheet/index.native.js b/packages/components/src/mobile/bottom-sheet/index.native.js index 27eaf1b462aca1..4ef8614279404a 100644 --- a/packages/components/src/mobile/bottom-sheet/index.native.js +++ b/packages/components/src/mobile/bottom-sheet/index.native.js @@ -575,6 +575,8 @@ class BottomSheet extends Component { listProps, setIsFullScreen: this.setIsFullScreen, safeAreaBottomInset, + maxHeight, + isMaxHeightSet, } } > { hasNavigation ? ( diff --git a/packages/components/src/mobile/html-text-input/test/__snapshots__/index.native.js.snap b/packages/components/src/mobile/html-text-input/test/__snapshots__/index.native.js.snap new file mode 100644 index 00000000000000..ace4108a9968af --- /dev/null +++ b/packages/components/src/mobile/html-text-input/test/__snapshots__/index.native.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HTMLTextInput HTMLTextInput renders and matches snapshot 1`] = ` +<View + onLayout={[Function]} +> + <RCTScrollView> + <View> + <TextInput + accessibilityLabel="html-view-title" + autoCorrect={false} + numberOfLines={1} + placeholder="Add title" + placeholderTextColor="white" + style={ + [ + undefined, + undefined, + ] + } + textAlignVertical="center" + /> + <TextInput + accessibilityLabel="html-view-content" + autoCorrect={false} + multiline={true} + onBlur={[Function]} + onChangeText={[Function]} + placeholder="Start writing…" + placeholderTextColor="white" + rejectResponderTermination={false} + scrollEnabled={false} + style={ + [ + { + "color": "white", + }, + undefined, + ] + } + textAlignVertical="top" + /> + </View> + </RCTScrollView> +</View> +`; diff --git a/packages/components/src/mobile/html-text-input/test/index.native.js b/packages/components/src/mobile/html-text-input/test/index.native.js index 5ddffe3d81d0d5..1f900fc5ee1e9a 100644 --- a/packages/components/src/mobile/html-text-input/test/index.native.js +++ b/packages/components/src/mobile/html-text-input/test/index.native.js @@ -23,13 +23,13 @@ const getStylesFromColorScheme = () => { }; describe( 'HTMLTextInput', () => { - it( 'HTMLTextInput renders', () => { + it( 'HTMLTextInput renders and matches snapshot', () => { const screen = render( <HTMLTextInput getStylesFromColorScheme={ getStylesFromColorScheme } /> ); - expect( screen.container ).toBeTruthy(); + expect( screen.toJSON() ).toMatchSnapshot(); } ); it( 'HTMLTextInput updates state on HTML text change', () => { diff --git a/packages/components/src/mobile/image/index.native.js b/packages/components/src/mobile/image/index.native.js index 9d365763e027dd..064150ec5f728d 100644 --- a/packages/components/src/mobile/image/index.native.js +++ b/packages/components/src/mobile/image/index.native.js @@ -178,13 +178,20 @@ const ImageComponent = ( { imageData && containerSize && { height: - imageData?.width > containerSize?.width + imageData?.width > containerSize?.width && ! imageWidth ? containerSize?.width / imageData?.aspectRatio : undefined, }, imageHeight && { height: imageHeight }, shapeStyle, ]; + const imageSelectedStyles = [ + usePreferredColorSchemeStyle( + styles.imageBorder, + styles.imageBorderDark + ), + { height: containerSize?.height }, + ]; return ( <View @@ -210,12 +217,7 @@ const ImageComponent = ( { { isSelected && highlightSelected && ! ( isUploadInProgress || isUploadFailed ) && ( - <View - style={ [ - styles.imageBorder, - { height: containerSize?.height }, - ] } - /> + <View style={ imageSelectedStyles } /> ) } { ! imageData ? ( diff --git a/packages/components/src/mobile/image/style.native.scss b/packages/components/src/mobile/image/style.native.scss index c5ccd195993519..f6deb3655f3699 100644 --- a/packages/components/src/mobile/image/style.native.scss +++ b/packages/components/src/mobile/image/style.native.scss @@ -7,7 +7,7 @@ } .imageBorder { - border-color: $blue-medium; + border-color: $blue-40; border-width: 2px; border-style: solid; position: absolute; @@ -16,6 +16,10 @@ height: 100%; } +.imageBorderDark { + border-color: $blue-50; +} + .retryIcon { width: 80px; height: 80px; diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js b/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js index 90fda81d05b2f6..8b233612763814 100644 --- a/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js +++ b/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js @@ -154,7 +154,7 @@ export const KeyboardAwareFlatList = ( { scrollEventThrottle={ 16 } style={ style } > - <FlatList { ...props } /> + <FlatList { ...props } scrollEnabled={ false } /> </AnimatedScrollView> ); }; diff --git a/packages/components/src/mobile/link-picker/index.native.js b/packages/components/src/mobile/link-picker/index.native.js index ffde5b28c26171..404bbb1760c156 100644 --- a/packages/components/src/mobile/link-picker/index.native.js +++ b/packages/components/src/mobile/link-picker/index.native.js @@ -57,10 +57,8 @@ export const LinkPicker = ( { onLinkPicked, onCancel: cancel, } ) => { - const [ { value, clipboardUrl }, setValue ] = useState( { - value: initialValue, - clipboardUrl: '', - } ); + const [ value, setValue ] = useState( initialValue ); + const [ clipboardUrl, setClipboardUrl ] = useState( '' ); const directEntry = createDirectEntry( value ); // The title of a direct entry is displayed as the raw input value, but if we @@ -74,7 +72,8 @@ export const LinkPicker = ( { }; const clear = () => { - setValue( { value: '', clipboardUrl } ); + setValue( '' ); + setClipboardUrl( '' ); }; const omniCellStyle = usePreferredColorSchemeStyle( @@ -89,11 +88,8 @@ export const LinkPicker = ( { useEffect( () => { getURLFromClipboard() - .then( ( url ) => setValue( { value, clipboardUrl: url } ) ) - .catch( () => setValue( { value, clipboardUrl: '' } ) ); - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps + .then( setClipboardUrl ) + .catch( () => setClipboardUrl( '' ) ); }, [] ); // TODO: Localize the accessibility label. @@ -115,9 +111,7 @@ export const LinkPicker = ( { autoCapitalize="none" autoCorrect={ false } keyboardType="url" - onChangeValue={ ( newValue ) => { - setValue( { value: newValue, clipboardUrl } ); - } } + onChangeValue={ setValue } onSubmit={ onSubmit } /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus diff --git a/packages/components/src/mobile/link-picker/link-picker-results.native.js b/packages/components/src/mobile/link-picker/link-picker-results.native.js index 9d6e36ae1aa4d8..c2d0b60f8a3dfb 100644 --- a/packages/components/src/mobile/link-picker/link-picker-results.native.js +++ b/packages/components/src/mobile/link-picker/link-picker-results.native.js @@ -97,7 +97,7 @@ export default function LinkPickerResults( { const onEndReached = () => fetchMoreSuggestions( { query, links } ); const spinner = ! hasAllSuggestions && meetsThreshold( query ) && ( - <View style={ styles.spinner }> + <View style={ styles.spinner } testID="link-picker-loading"> <ActivityIndicator animating /> </View> ); diff --git a/packages/components/src/mobile/link-picker/test/performance/index.native.js b/packages/components/src/mobile/link-picker/test/performance/index.native.js new file mode 100644 index 00000000000000..3736bd39b9c607 --- /dev/null +++ b/packages/components/src/mobile/link-picker/test/performance/index.native.js @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { act, measurePerformance } from 'test/helpers'; +import Clipboard from '@react-native-clipboard/clipboard'; + +/** + * Internal dependencies + */ +import { LinkPicker } from '../../index'; + +describe( 'LinkPicker', () => { + const onLinkPicked = jest.fn(); + const onCancel = jest.fn(); + const clipboardResult = Promise.resolve( '' ); + Clipboard.getString.mockReturnValue( clipboardResult ); + + it( 'performance is stable when clipboard results do not change', async () => { + const scenario = async () => { + // Given the clipboard result is an empty string, there are no + // user-facing changes to query. Thus, we must await the promise + // itself. + await act( () => clipboardResult ); + }; + + await measurePerformance( + <LinkPicker + onLinkPicked={ onLinkPicked } + onCancel={ onCancel } + value="" + />, + { scenario } + ); + } ); +} ); diff --git a/packages/components/src/mobile/link-settings/link-settings-navigation.native.js b/packages/components/src/mobile/link-settings/link-settings-navigation.native.js index 7f82ca49a96769..375dae450fbcef 100644 --- a/packages/components/src/mobile/link-settings/link-settings-navigation.native.js +++ b/packages/components/src/mobile/link-settings/link-settings-navigation.native.js @@ -23,7 +23,7 @@ function LinkSettingsNavigation( props ) { isVisible={ props.isVisible } onClose={ props.onClose } onDismiss={ props.onDismiss } - testID={ props.testID } + testID="link-settings-navigation" hideHeader hasNavigation > diff --git a/packages/components/src/mobile/link-settings/test/edit.native.js b/packages/components/src/mobile/link-settings/test/edit.native.js index cbc31a842e1735..715a4478207453 100644 --- a/packages/components/src/mobile/link-settings/test/edit.native.js +++ b/packages/components/src/mobile/link-settings/test/edit.native.js @@ -4,7 +4,11 @@ * External dependencies */ import Clipboard from '@react-native-clipboard/clipboard'; -import { fireEvent, initializeEditor } from 'test/helpers'; +import { + fireEvent, + initializeEditor, + waitForElementToBeRemoved, +} from 'test/helpers'; /** * WordPress dependencies */ @@ -12,6 +16,19 @@ import { registerCoreBlocks } from '@wordpress/block-library'; import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; +// Mock debounce to prevent potentially belated state updates. +jest.mock( '@wordpress/compose/src/utils/debounce', () => ( { + debounce: ( fn ) => { + fn.cancel = jest.fn(); + return fn; + }, +} ) ); +// Mock link suggestions that are fetched by the link picker +// when typing a search query. +jest.mock( '@wordpress/core-data/src/fetch', () => ( { + __experimentalFetchLinkSuggestions: jest.fn().mockResolvedValue( [ {} ] ), +} ) ); + /** * Utility function to unregister all core block types previously registered * when staging the Redux Store `beforeAll` integration tests start running. @@ -34,9 +51,7 @@ describe.each( [ type: 'core/button', initialHtml: ` <!-- wp:button {"style":{"border":{"radius":"5px"}}} --> - <div class="wp-block-button"> - <a class="wp-block-button__link" style="border-radius:5px">Link</a> - </div> + <div class="wp-block-button"><a class="wp-block-button__link wp-element-button" style="border-radius:5px">Link</a></div> <!-- /wp:button --> `, toJSON: () => 'core/button', @@ -112,11 +127,9 @@ describe.each( [ ); fireEvent.press( block ); fireEvent.press( block ); + fireEvent.press( subject.getByLabelText( 'Open Settings' ) ); fireEvent.press( - await subject.findByLabelText( 'Open Settings' ) - ); - fireEvent.press( - await subject.findByLabelText( + subject.getByLabelText( `Link to, ${ type === 'core/image' ? 'None' @@ -125,9 +138,7 @@ describe.each( [ ) ); if ( type === 'core/image' ) { - fireEvent.press( - await subject.findByLabelText( /Custom URL/ ) - ); + fireEvent.press( subject.getByLabelText( /Custom URL/ ) ); } await subject.findByLabelText( 'Apply' ); @@ -157,11 +168,9 @@ describe.each( [ ); fireEvent.press( block ); fireEvent.press( block ); + fireEvent.press( subject.getByLabelText( 'Open Settings' ) ); fireEvent.press( - await subject.findByLabelText( 'Open Settings' ) - ); - fireEvent.press( - await subject.findByLabelText( + subject.getByLabelText( `Link to, ${ type === 'core/image' ? 'None' @@ -171,7 +180,7 @@ describe.each( [ ); if ( type === 'core/image' ) { fireEvent.press( - await subject.findByLabelText( 'Custom URL. Empty' ) + subject.getByLabelText( 'Custom URL. Empty' ) ); } fireEvent.press( @@ -186,11 +195,15 @@ describe.each( [ }` ) ); + if ( type === 'core/image' ) { fireEvent.press( - await subject.findByLabelText( `Custom URL, ${ url }` ) + subject.getByLabelText( `Custom URL, ${ url }` ) ); } + await waitForElementToBeRemoved( () => + subject.getByTestId( 'link-picker-loading' ) + ); await subject.findByLabelText( 'Apply' ); // Assert. @@ -223,10 +236,10 @@ describe.each( [ fireEvent.press( block ); fireEvent.press( block ); fireEvent.press( - await subject.findByLabelText( 'Open Settings' ) + subject.getByLabelText( 'Open Settings' ) ); fireEvent.press( - await subject.findByLabelText( + subject.getByLabelText( `Link to, ${ type === 'core/image' ? 'None' @@ -236,7 +249,7 @@ describe.each( [ ); if ( type === 'core/image' ) { fireEvent.press( - await subject.findByLabelText( /Custom URL/ ) + subject.getByLabelText( /Custom URL/ ) ); } await subject.findByLabelText( @@ -276,10 +289,10 @@ describe.each( [ fireEvent.press( block ); fireEvent.press( block ); fireEvent.press( - await subject.findByLabelText( 'Open Settings' ) + subject.getByLabelText( 'Open Settings' ) ); fireEvent.press( - await subject.findByLabelText( + subject.getByLabelText( `Link to, ${ type === 'core/image' ? 'None' @@ -289,7 +302,7 @@ describe.each( [ ); if ( type === 'core/image' ) { fireEvent.press( - await subject.findByLabelText( /Custom URL/ ) + subject.getByLabelText( /Custom URL/ ) ); } fireEvent.press( diff --git a/packages/components/src/mobile/link-settings/test/link-settings-navigation.native.js b/packages/components/src/mobile/link-settings/test/link-settings-navigation.native.js index 1f861a9ba93320..9d8cf2a844e9b1 100644 --- a/packages/components/src/mobile/link-settings/test/link-settings-navigation.native.js +++ b/packages/components/src/mobile/link-settings/test/link-settings-navigation.native.js @@ -2,7 +2,7 @@ * External dependencies */ import { Keyboard, Platform } from 'react-native'; -import { render, fireEvent, waitFor } from 'test/helpers'; +import { render, fireEvent } from 'test/helpers'; /** * Internal dependencies @@ -40,13 +40,8 @@ describe( 'Android', () => { it( 'improves back animation performance by dismissing keyboard beforehand', async () => { const screen = render( subject ); fireEvent.press( screen.getByText( 'Link to' ) ); - fireEvent.press( - screen.getByLabelText( 'Link to, Search or type URL' ) - ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => - screen.getByLabelText( 'Go back' ) - ); + const backButton = await screen.findByLabelText( 'Go back' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); @@ -57,9 +52,7 @@ describe( 'Android', () => { const screen = render( subject ); fireEvent.press( screen.getByText( 'Link to' ) ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => - screen.getByLabelText( 'Apply' ) - ); + const backButton = await screen.findByLabelText( 'Apply' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); @@ -81,9 +74,7 @@ describe( 'iOS', () => { const screen = render( subject ); fireEvent.press( screen.getByText( 'Link to' ) ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => - screen.getByLabelText( 'Go back' ) - ); + const backButton = await screen.findByLabelText( 'Go back' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); @@ -94,9 +85,7 @@ describe( 'iOS', () => { const screen = render( subject ); fireEvent.press( screen.getByText( 'Link to' ) ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => - screen.getByLabelText( 'Apply' ) - ); + const backButton = await screen.findByLabelText( 'Apply' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); diff --git a/packages/components/src/mobile/segmented-control/index.native.js b/packages/components/src/mobile/segmented-control/index.native.js index 1a218fcece100f..d76a42d6d8c3b1 100644 --- a/packages/components/src/mobile/segmented-control/index.native.js +++ b/packages/components/src/mobile/segmented-control/index.native.js @@ -149,6 +149,17 @@ const SegmentedControls = ( { <View style={ styles.row }> <View style={ styles.flex }>{ addonLeft }</View> <View style={ [ containerStyle, isIOS && styles.containerIOS ] }> + <Animated.View + style={ [ + { + width, + left: positionAnimationValue, + height, + }, + selectedStyle, + outlineStyle, + ] } + /> { segments.map( ( segment, index ) => { return ( <Segment @@ -170,17 +181,6 @@ const SegmentedControls = ( { /> ); } ) } - <Animated.View - style={ [ - { - width, - left: positionAnimationValue, - height, - }, - selectedStyle, - outlineStyle, - ] } - /> </View> <View style={ styles.flex }>{ addonRight }</View> </View> diff --git a/packages/components/src/modal/README.md b/packages/components/src/modal/README.md index 8b0aa84750d16c..01cad7d6ff2e0f 100644 --- a/packages/components/src/modal/README.md +++ b/packages/components/src/modal/README.md @@ -194,6 +194,13 @@ If this property is true, it will focus the first tabbable element rendered in t - Required: No - Default: `true` +#### headerActions + +An optional React node intended to contain additional actions or other elements related to the modal, for example, buttons. Content is rendered in the top right corner of the modal and to the left of the close button, if visible. + +- Required: No +- Default: `null` + #### `isDismissible`: `boolean` If this property is set to false, the modal will not display a close icon and cannot be dismissed. diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx index d9c7b602b83920..d21a3f9ae3535e 100644 --- a/packages/components/src/modal/index.tsx +++ b/packages/components/src/modal/index.tsx @@ -66,6 +66,7 @@ function UnforwardedModal( contentLabel, onKeyDown, isFullScreen = false, + headerActions = null, __experimentalHideHeader = false, } = props; @@ -170,6 +171,34 @@ function UnforwardedModal( [ hasScrolledContent ] ); + let pressTarget: EventTarget | null = null; + const overlayPressHandlers: { + onPointerDown: React.PointerEventHandler< HTMLDivElement >; + onPointerUp: React.PointerEventHandler< HTMLDivElement >; + } = { + onPointerDown: ( event ) => { + if ( event.isPrimary && event.target === event.currentTarget ) { + pressTarget = event.target; + // Avoids loss of focus yet also leaves `useFocusOutside` + // practically useless with its only potential trigger being + // programmatic focus movement. TODO opt for either removing + // the hook or enhancing it such that this isn't needed. + event.preventDefault(); + } + }, + // Closes the modal with two exceptions. 1. Opening the context menu on + // the overlay. 2. Pressing on the overlay then dragging the pointer + // over the modal and releasing. Due to the modal being a child of the + // overlay, such a gesture is a `click` on the overlay and cannot be + // excepted by a `click` handler. Thus the tactic of handling + // `pointerup` and comparing its target to that of the `pointerdown`. + onPointerUp: ( { target, button } ) => { + const isSameTarget = target === pressTarget; + pressTarget = null; + if ( button === 0 && isSameTarget ) onRequestClose(); + }, + }; + return createPortal( // eslint-disable-next-line jsx-a11y/no-static-element-interactions <div @@ -179,6 +208,7 @@ function UnforwardedModal( overlayClassName ) } onKeyDown={ handleEscapeKeyDown } + { ...( shouldCloseOnClickOutside ? overlayPressHandlers : {} ) } > <StyleProvider document={ document }> <div @@ -241,6 +271,7 @@ function UnforwardedModal( </h1> ) } </div> + { headerActions } { isDismissible && ( <Button onClick={ onRequestClose } diff --git a/packages/components/src/modal/stories/index.story.tsx b/packages/components/src/modal/stories/index.story.tsx new file mode 100644 index 00000000000000..8405a6eb0113e3 --- /dev/null +++ b/packages/components/src/modal/stories/index.story.tsx @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { starEmpty, starFilled } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import InputControl from '../../input-control'; +import Modal from '../'; +import type { ModalProps } from '../types'; + +const meta: Meta< typeof Modal > = { + component: Modal, + title: 'Components/Modal', + argTypes: { + children: { + control: { type: null }, + }, + onKeyDown: { + control: { type: null }, + }, + focusOnMount: { + control: { type: 'boolean' }, + }, + role: { + control: { type: 'text' }, + }, + onRequestClose: { + action: 'onRequestClose', + }, + isDismissible: { + control: { type: 'boolean' }, + }, + }, + parameters: { + controls: { expanded: true }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Modal > = ( { onRequestClose, ...args } ) => { + const [ isOpen, setOpen ] = useState( false ); + const openModal = () => setOpen( true ); + const closeModal: ModalProps[ 'onRequestClose' ] = ( event ) => { + setOpen( false ); + onRequestClose( event ); + }; + + return ( + <> + <Button variant="secondary" onClick={ openModal }> + Open Modal + </Button> + { isOpen && ( + <Modal + onRequestClose={ closeModal } + style={ { maxWidth: '600px' } } + { ...args } + > + <p> + Lorem ipsum dolor sit amet, consectetur adipiscing elit, + sed do eiusmod tempor incididunt ut labore et magna + aliqua. Ut enim ad minim veniam, quis nostrud + exercitation ullamco laboris nisi ut aliquip ex ea ea + commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat + non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum. + </p> + + <InputControl style={ { marginBottom: '20px' } } /> + + <Button variant="secondary" onClick={ closeModal }> + Close Modal + </Button> + </Modal> + ) } + </> + ); +}; + +export const Default: StoryFn< typeof Modal > = Template.bind( {} ); +Default.args = { + title: 'Title', +}; +Default.parameters = { + docs: { + source: { + code: '', + }, + }, +}; + +const LikeButton = () => { + const [ isLiked, setIsLiked ] = useState( false ); + return ( + <Button + icon={ isLiked ? starFilled : starEmpty } + label="Like" + onClick={ () => setIsLiked( ! isLiked ) } + /> + ); +}; + +export const WithHeaderActions: StoryFn< typeof Modal > = Template.bind( {} ); +WithHeaderActions.args = { + ...Default.args, + headerActions: <LikeButton />, + isDismissible: false, +}; +WithHeaderActions.parameters = { + ...Default.parameters, +}; diff --git a/packages/components/src/modal/stories/index.tsx b/packages/components/src/modal/stories/index.tsx deleted file mode 100644 index 7f414d47c2d11c..00000000000000 --- a/packages/components/src/modal/stories/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory, ComponentMeta } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import InputControl from '../../input-control'; -import Modal from '../'; -import type { ModalProps } from '../types'; - -const meta: ComponentMeta< typeof Modal > = { - component: Modal, - title: 'Components/Modal', - argTypes: { - children: { - control: { type: null }, - }, - onKeyDown: { - control: { type: null }, - }, - focusOnMount: { - control: { type: 'boolean' }, - }, - role: { - control: { type: 'text' }, - }, - onRequestClose: { - action: 'onRequestClose', - }, - }, - parameters: { - controls: { expanded: true }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Modal > = ( { - onRequestClose, - ...args -} ) => { - const [ isOpen, setOpen ] = useState( false ); - const openModal = () => setOpen( true ); - const closeModal: ModalProps[ 'onRequestClose' ] = ( event ) => { - setOpen( false ); - onRequestClose( event ); - }; - - return ( - <> - <Button variant="secondary" onClick={ openModal }> - Open Modal - </Button> - { isOpen && ( - <Modal - onRequestClose={ closeModal } - style={ { maxWidth: '600px' } } - { ...args } - > - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, - sed do eiusmod tempor incididunt ut labore et magna - aliqua. Ut enim ad minim veniam, quis nostrud - exercitation ullamco laboris nisi ut aliquip ex ea ea - commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat - non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum. - </p> - - <InputControl style={ { marginBottom: '20px' } } /> - - <Button variant="secondary" onClick={ closeModal }> - Close Modal - </Button> - </Modal> - ) } - </> - ); -}; - -export const Default: ComponentStory< typeof Modal > = Template.bind( {} ); -Default.args = { - title: 'Title', -}; -Default.parameters = { - docs: { - source: { - code: '', - }, - }, -}; diff --git a/packages/components/src/modal/style.scss b/packages/components/src/modal/style.scss index acc43de8b7f902..d4ca7c311fcf19 100644 --- a/packages/components/src/modal/style.scss +++ b/packages/components/src/modal/style.scss @@ -20,7 +20,7 @@ width: 100%; background: $white; box-shadow: $shadow-modal; - border-radius: $grid-unit-10 $grid-unit-10 0 0; + border-radius: $grid-unit-05 $grid-unit-05 0 0; overflow: hidden; // Have the content element fill the vertical space yet not overflow. display: flex; @@ -31,7 +31,7 @@ // Show a centered modal on bigger screens. @include break-small() { - border-radius: $grid-unit-10; + border-radius: $grid-unit-05; margin: auto; width: auto; min-width: $modal-min-width; @@ -72,7 +72,7 @@ .components-modal__header { box-sizing: border-box; border-bottom: $border-width solid transparent; - padding: $grid-unit-30 $grid-unit-40 $grid-unit-15; + padding: $grid-unit-30 $grid-unit-40 $grid-unit-10; display: flex; flex-direction: row; justify-content: space-between; @@ -130,7 +130,8 @@ .components-modal__content { flex: 1; margin-top: $header-height + $grid-unit-15; - padding: 0 $grid-unit-40 $grid-unit-40; + // Small top padding required to avoid cutting off the visible outline when the first child element is focusable. + padding: $grid-unit-05 $grid-unit-40 $grid-unit-40; overflow: auto; &.hide-header { diff --git a/packages/components/src/modal/test/index.tsx b/packages/components/src/modal/test/index.tsx index 63ff485c35458e..c2ab277f721570 100644 --- a/packages/components/src/modal/test/index.tsx +++ b/packages/components/src/modal/test/index.tsx @@ -4,6 +4,11 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + /** * Internal dependencies */ @@ -82,4 +87,46 @@ describe( 'Modal', () => { await user.keyboard( '[Escape]' ); expect( onRequestClose ).toHaveBeenCalled(); } ); + + it( 'should return focus when dismissed by clicking outside', async () => { + const user = userEvent.setup(); + const ReturnDemo = () => { + const [ isShown, setIsShown ] = useState( false ); + return ( + <div> + <button onClick={ () => setIsShown( true ) }>📣</button> + { isShown && ( + <Modal onRequestClose={ () => setIsShown( false ) }> + <p>Modal content</p> + </Modal> + ) } + </div> + ); + }; + render( <ReturnDemo /> ); + + const opener = screen.getByRole( 'button' ); + await user.click( opener ); + const modalFrame = screen.getByRole( 'dialog' ); + expect( modalFrame ).toHaveFocus(); + + // Disable reason: No semantic query can reach the overlay. + // eslint-disable-next-line testing-library/no-node-access + await user.click( modalFrame.parentElement! ); + expect( opener ).toHaveFocus(); + } ); + + it( 'should render `headerActions` React nodes', async () => { + render( + <Modal + headerActions={ <button>A sweet button</button> } + onRequestClose={ noop } + > + <p>Modal content</p> + </Modal> + ); + expect( + screen.getByText( 'A sweet button', { selector: 'button' } ) + ).toBeInTheDocument(); + } ); } ); diff --git a/packages/components/src/modal/types.ts b/packages/components/src/modal/types.ts index 6169e42a8a2d4c..2106a6087e9943 100644 --- a/packages/components/src/modal/types.ts +++ b/packages/components/src/modal/types.ts @@ -69,6 +69,14 @@ export type ModalProps = { * @default true */ focusOnMount?: Parameters< typeof useFocusOnMount >[ 0 ]; + /** + * Elements that are injected into the modal header to the left of the close button (if rendered). + * Hidden if `__experimentalHideHeader` is `true`. + * + * @default null + */ + headerActions?: ReactNode; + /** * If this property is added, an icon will be added before the title. */ diff --git a/packages/components/src/navigable-container/stories/navigable-menu.story.tsx b/packages/components/src/navigable-container/stories/navigable-menu.story.tsx new file mode 100644 index 00000000000000..9e5f3eedff5289 --- /dev/null +++ b/packages/components/src/navigable-container/stories/navigable-menu.story.tsx @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { NavigableMenu } from '..'; + +const meta: Meta< typeof NavigableMenu > = { + title: 'Components/NavigableMenu', + component: NavigableMenu, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof NavigableMenu > = ( args ) => { + return ( + <> + <button>Before navigable menu</button> + <NavigableMenu + { ...args } + style={ { + margin: '32px 0', + padding: '16px', + border: '1px solid black', + } } + > + <div role="menuitem">Item 1 (non-tabbable, non-focusable)</div> + <button role="menuitem">Item 2 (tabbable, focusable)</button> + <button role="menuitem" disabled> + Item 3 (disabled, therefore non-tabbable and not-focusable) + </button> + <span role="menuitem" tabIndex={ -1 }> + Item 4 (non-tabbable, non-focusable) + </span> + <div role="menuitem" tabIndex={ 0 }> + Item 5 (tabbable, focusable) + </div> + </NavigableMenu> + <button>After navigable menu</button> + </> + ); +}; diff --git a/packages/components/src/navigable-container/stories/navigable-menu.tsx b/packages/components/src/navigable-container/stories/navigable-menu.tsx deleted file mode 100644 index 5f8ee2e4e5d856..00000000000000 --- a/packages/components/src/navigable-container/stories/navigable-menu.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { NavigableMenu } from '..'; - -const meta: ComponentMeta< typeof NavigableMenu > = { - title: 'Components/NavigableMenu', - component: NavigableMenu, - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof NavigableMenu > = ( args ) => { - return ( - <> - <button>Before navigable menu</button> - <NavigableMenu - { ...args } - style={ { - margin: '32px 0', - padding: '16px', - border: '1px solid black', - } } - > - <div role="menuitem">Item 1 (non-tabbable, non-focusable)</div> - <button role="menuitem">Item 2 (tabbable, focusable)</button> - <button role="menuitem" disabled> - Item 3 (disabled, therefore non-tabbable and not-focusable) - </button> - <span role="menuitem" tabIndex={ -1 }> - Item 4 (non-tabbable, non-focusable) - </span> - <div role="menuitem" tabIndex={ 0 }> - Item 5 (tabbable, focusable) - </div> - </NavigableMenu> - <button>After navigable menu</button> - </> - ); -}; diff --git a/packages/components/src/navigable-container/stories/tabbable-container.story.tsx b/packages/components/src/navigable-container/stories/tabbable-container.story.tsx new file mode 100644 index 00000000000000..3dd090f0b05859 --- /dev/null +++ b/packages/components/src/navigable-container/stories/tabbable-container.story.tsx @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { TabbableContainer } from '..'; + +const meta: Meta< typeof TabbableContainer > = { + title: 'Components/TabbableContainer', + component: TabbableContainer, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof TabbableContainer > = ( args ) => { + return ( + <> + <button>Before tabbable container</button> + <TabbableContainer + { ...args } + style={ { + margin: '32px 0', + padding: '16px', + border: '1px solid black', + } } + > + <button>Item 1</button> + <button>Item 2</button> + <button disabled>Item 3 (disabled)</button> + <button tabIndex={ -1 }>Item 4 (non-tabbable)</button> + <button tabIndex={ 0 }>Item 5</button> + <button>Item 6</button> + </TabbableContainer> + <button>After tabbable container</button> + </> + ); +}; diff --git a/packages/components/src/navigable-container/stories/tabbable-container.tsx b/packages/components/src/navigable-container/stories/tabbable-container.tsx deleted file mode 100644 index b517019e29571b..00000000000000 --- a/packages/components/src/navigable-container/stories/tabbable-container.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { TabbableContainer } from '..'; - -const meta: ComponentMeta< typeof TabbableContainer > = { - title: 'Components/TabbableContainer', - component: TabbableContainer, - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof TabbableContainer > = ( args ) => { - return ( - <> - <button>Before tabbable container</button> - <TabbableContainer - { ...args } - style={ { - margin: '32px 0', - padding: '16px', - border: '1px solid black', - } } - > - <button>Item 1</button> - <button>Item 2</button> - <button disabled>Item 3 (disabled)</button> - <button tabIndex={ -1 }>Item 4 (non-tabbable)</button> - <button tabIndex={ 0 }>Item 5</button> - <button>Item 6</button> - </TabbableContainer> - <button>After tabbable container</button> - </> - ); -}; diff --git a/packages/components/src/navigation/index.tsx b/packages/components/src/navigation/index.tsx index e3d309783e1ee9..dfc1b26cb33ad0 100644 --- a/packages/components/src/navigation/index.tsx +++ b/packages/components/src/navigation/index.tsx @@ -28,7 +28,6 @@ const noop = () => {}; /** * Render a navigation list with optional groupings and hierarchy. * - * @example * ```jsx * import { * __experimentalNavigation as Navigation, diff --git a/packages/components/src/navigation/stories/index.story.tsx b/packages/components/src/navigation/stories/index.story.tsx new file mode 100644 index 00000000000000..e0a3f1e1397576 --- /dev/null +++ b/packages/components/src/navigation/stories/index.story.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import type { Meta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Navigation } from '..'; +import { NavigationBackButton } from '../back-button'; +import { NavigationGroup } from '../group'; +import { NavigationItem } from '../item'; +import { NavigationMenu } from '../menu'; +import { DefaultStory } from './utils/default'; +import { GroupStory } from './utils/group'; +import { ControlledStateStory } from './utils/controlled-state'; +import { SearchStory } from './utils/search'; +import { MoreExamplesStory } from './utils/more-examples'; +import { HideIfEmptyStory } from './utils/hide-if-empty'; +import './style.css'; + +const meta: Meta< typeof Navigation > = { + title: 'Components (Experimental)/Navigation', + component: Navigation, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + NavigationBackButton, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + NavigationGroup, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + NavigationItem, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + NavigationMenu, + }, + argTypes: { + activeItem: { control: { type: null } }, + activeMenu: { control: { type: null } }, + children: { control: { type: null } }, + onActivateMenu: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +export const _default = DefaultStory.bind( {} ); +export const ControlledState = ControlledStateStory.bind( {} ); +export const Groups = GroupStory.bind( {} ); +export const Search = SearchStory.bind( {} ); +export const MoreExamples = MoreExamplesStory.bind( {} ); +export const HideIfEmpty = HideIfEmptyStory.bind( {} ); diff --git a/packages/components/src/navigation/stories/index.tsx b/packages/components/src/navigation/stories/index.tsx deleted file mode 100644 index 2605de6f296673..00000000000000 --- a/packages/components/src/navigation/stories/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Navigation } from '..'; -import { NavigationBackButton } from '../back-button'; -import { NavigationGroup } from '../group'; -import { NavigationItem } from '../item'; -import { NavigationMenu } from '../menu'; -import { DefaultStory } from './utils/default'; -import { GroupStory } from './utils/group'; -import { ControlledStateStory } from './utils/controlled-state'; -import { SearchStory } from './utils/search'; -import { MoreExamplesStory } from './utils/more-examples'; -import { HideIfEmptyStory } from './utils/hide-if-empty'; -import './style.css'; - -const meta: ComponentMeta< typeof Navigation > = { - title: 'Components (Experimental)/Navigation', - component: Navigation, - subcomponents: { - NavigationBackButton, - NavigationGroup, - NavigationItem, - NavigationMenu, - }, - argTypes: { - activeItem: { control: { type: null } }, - activeMenu: { control: { type: null } }, - children: { control: { type: null } }, - onActivateMenu: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -export const _default = DefaultStory.bind( {} ); -export const controlledState = ControlledStateStory.bind( {} ); -export const groups = GroupStory.bind( {} ); -export const search = SearchStory.bind( {} ); -export const moreExamples = MoreExamplesStory.bind( {} ); -export const hideIfEmpty = HideIfEmptyStory.bind( {} ); diff --git a/packages/components/src/navigation/stories/utils/controlled-state.tsx b/packages/components/src/navigation/stories/utils/controlled-state.tsx index fa9687c981f2ee..b5d842b67e78c4 100644 --- a/packages/components/src/navigation/stories/utils/controlled-state.tsx +++ b/packages/components/src/navigation/stories/utils/controlled-state.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ComponentStory } from '@storybook/react'; +import type { StoryFn } from '@storybook/react'; /** * WordPress dependencies @@ -16,7 +16,7 @@ import { Navigation } from '../..'; import { NavigationItem } from '../../item'; import { NavigationMenu } from '../../menu'; -export const ControlledStateStory: ComponentStory< typeof Navigation > = ( { +export const ControlledStateStory: StoryFn< typeof Navigation > = ( { className, ...props } ) => { diff --git a/packages/components/src/navigation/stories/utils/default.tsx b/packages/components/src/navigation/stories/utils/default.tsx index 78188802861d36..d2948494adfb62 100644 --- a/packages/components/src/navigation/stories/utils/default.tsx +++ b/packages/components/src/navigation/stories/utils/default.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ComponentStory } from '@storybook/react'; +import type { StoryFn } from '@storybook/react'; /** * WordPress dependencies @@ -15,7 +15,7 @@ import { Navigation } from '../..'; import { NavigationItem } from '../../item'; import { NavigationMenu } from '../../menu'; -export const DefaultStory: ComponentStory< typeof Navigation > = ( { +export const DefaultStory: StoryFn< typeof Navigation > = ( { className, ...props } ) => { diff --git a/packages/components/src/navigation/stories/utils/group.tsx b/packages/components/src/navigation/stories/utils/group.tsx index 007a71e3f40d16..bdf4f7011a171a 100644 --- a/packages/components/src/navigation/stories/utils/group.tsx +++ b/packages/components/src/navigation/stories/utils/group.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ComponentStory } from '@storybook/react'; +import type { StoryFn } from '@storybook/react'; /** * WordPress dependencies @@ -16,7 +16,7 @@ import { NavigationItem } from '../../item'; import { NavigationMenu } from '../../menu'; import { NavigationGroup } from '../../group'; -export const GroupStory: ComponentStory< typeof Navigation > = ( { +export const GroupStory: StoryFn< typeof Navigation > = ( { className, ...props } ) => { diff --git a/packages/components/src/navigation/stories/utils/hide-if-empty.tsx b/packages/components/src/navigation/stories/utils/hide-if-empty.tsx index 2595aba6b26a7b..9b1414db861c0b 100644 --- a/packages/components/src/navigation/stories/utils/hide-if-empty.tsx +++ b/packages/components/src/navigation/stories/utils/hide-if-empty.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ComponentStory } from '@storybook/react'; +import type { StoryFn } from '@storybook/react'; /** * Internal dependencies @@ -10,7 +10,7 @@ import { Navigation } from '../..'; import { NavigationItem } from '../../item'; import { NavigationMenu } from '../../menu'; -export const HideIfEmptyStory: ComponentStory< typeof Navigation > = ( { +export const HideIfEmptyStory: StoryFn< typeof Navigation > = ( { className, ...props } ) => { diff --git a/packages/components/src/navigation/stories/utils/more-examples.tsx b/packages/components/src/navigation/stories/utils/more-examples.tsx index c2a181dce58f69..e0ce566a5ab66c 100644 --- a/packages/components/src/navigation/stories/utils/more-examples.tsx +++ b/packages/components/src/navigation/stories/utils/more-examples.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ComponentStory } from '@storybook/react'; +import type { StoryFn } from '@storybook/react'; /** * WordPress dependencies @@ -17,7 +17,7 @@ import { NavigationGroup } from '../../group'; import { NavigationItem } from '../../item'; import { NavigationMenu } from '../../menu'; -export const MoreExamplesStory: ComponentStory< typeof Navigation > = ( { +export const MoreExamplesStory: StoryFn< typeof Navigation > = ( { className, ...props } ) => { diff --git a/packages/components/src/navigation/stories/utils/search.tsx b/packages/components/src/navigation/stories/utils/search.tsx index 74f1457adf0b70..44fb352bbdbf1a 100644 --- a/packages/components/src/navigation/stories/utils/search.tsx +++ b/packages/components/src/navigation/stories/utils/search.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { ComponentStory } from '@storybook/react'; +import type { StoryFn } from '@storybook/react'; /** * WordPress dependencies @@ -29,7 +29,7 @@ const searchItems = [ { item: 'waldo', title: 'Waldo' }, ]; -export const SearchStory: ComponentStory< typeof Navigation > = ( { +export const SearchStory: StoryFn< typeof Navigation > = ( { className, ...props } ) => { diff --git a/packages/components/src/navigation/styles/navigation-styles.tsx b/packages/components/src/navigation/styles/navigation-styles.tsx index 45b5e03e32bbaf..7c3ea34f99899d 100644 --- a/packages/components/src/navigation/styles/navigation-styles.tsx +++ b/packages/components/src/navigation/styles/navigation-styles.tsx @@ -155,7 +155,7 @@ export const ItemBaseUI = styled.li` } &.is-active { - background-color: ${ COLORS.ui.theme }; + background-color: ${ COLORS.theme.accent }; color: ${ COLORS.white }; > button, diff --git a/packages/components/src/navigator/navigator-back-button/component.tsx b/packages/components/src/navigator/navigator-back-button/component.tsx index 498096edd7d465..bf005413fdf718 100644 --- a/packages/components/src/navigator/navigator-back-button/component.tsx +++ b/packages/components/src/navigator/navigator-back-button/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; import { useNavigatorBackButton } from './hook'; import type { NavigatorBackButtonProps } from '../types'; diff --git a/packages/components/src/navigator/navigator-back-button/hook.ts b/packages/components/src/navigator/navigator-back-button/hook.ts index 437c60731cc953..255a83997d071e 100644 --- a/packages/components/src/navigator/navigator-back-button/hook.ts +++ b/packages/components/src/navigator/navigator-back-button/hook.ts @@ -6,7 +6,8 @@ import { useCallback } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import Button from '../../button'; import useNavigator from '../use-navigator'; import type { NavigatorBackButtonHookProps } from '../types'; diff --git a/packages/components/src/navigator/navigator-button/component.tsx b/packages/components/src/navigator/navigator-button/component.tsx index 2e446dac010c09..d591758333aa9f 100644 --- a/packages/components/src/navigator/navigator-button/component.tsx +++ b/packages/components/src/navigator/navigator-button/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; import { useNavigatorButton } from './hook'; import type { NavigatorButtonProps } from '../types'; diff --git a/packages/components/src/navigator/navigator-button/hook.ts b/packages/components/src/navigator/navigator-button/hook.ts index 15d52d269b60a2..9b32c07b293c53 100644 --- a/packages/components/src/navigator/navigator-button/hook.ts +++ b/packages/components/src/navigator/navigator-button/hook.ts @@ -7,7 +7,8 @@ import { escapeAttribute } from '@wordpress/escape-html'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import Button from '../../button'; import useNavigator from '../use-navigator'; import type { NavigatorButtonProps } from '../types'; diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx index 4c98db5b1a4d18..2d27605eab15a0 100644 --- a/packages/components/src/navigator/navigator-provider/component.tsx +++ b/packages/components/src/navigator/navigator-provider/component.tsx @@ -20,11 +20,8 @@ import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies */ -import { - contextConnect, - useContextSystem, - WordPressComponentProps, -} from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect, useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import { View } from '../../view'; import { NavigatorContext } from '../context'; @@ -148,6 +145,7 @@ function UnconnectedNavigatorProvider( focusTargetSelector, isBack = false, skipFocus = false, + replace = false, ...restOptions } = options; @@ -172,34 +170,38 @@ function UnconnectedNavigatorProvider( skipFocus, }; - if ( prevLocationHistory.length < 1 ) { - return [ newLocation ]; + if ( prevLocationHistory.length === 0 ) { + return replace ? [] : [ newLocation ]; + } + + const newLocationHistory = prevLocationHistory.slice( + prevLocationHistory.length > MAX_HISTORY_LENGTH - 1 ? 1 : 0, + -1 + ); + + if ( ! replace ) { + newLocationHistory.push( + // Assign `focusTargetSelector` to the previous location in history + // (the one we just navigated from). + { + ...prevLocationHistory[ + prevLocationHistory.length - 1 + ], + focusTargetSelector, + } + ); } - return [ - ...prevLocationHistory.slice( - prevLocationHistory.length > MAX_HISTORY_LENGTH - 1 - ? 1 - : 0, - -1 - ), - // Assign `focusTargetSelector` to the previous location in history - // (the one we just navigated from). - { - ...prevLocationHistory[ - prevLocationHistory.length - 1 - ], - focusTargetSelector, - }, - newLocation, - ]; + newLocationHistory.push( newLocation ); + + return newLocationHistory; } ); }, [ goBack ] ); - const goToParent: NavigatorContextType[ 'goToParent' ] = - useCallback( () => { + const goToParent: NavigatorContextType[ 'goToParent' ] = useCallback( + ( options = {} ) => { const currentPath = currentLocationHistory.current[ currentLocationHistory.current.length - 1 @@ -214,8 +216,10 @@ function UnconnectedNavigatorProvider( if ( parentPath === undefined ) { return; } - goTo( parentPath, { isBack: true } ); - }, [ goTo ] ); + goTo( parentPath, { ...options, isBack: true } ); + }, + [ goTo ] + ); const navigatorContextValue: NavigatorContextType = useMemo( () => ( { @@ -264,7 +268,6 @@ function UnconnectedNavigatorProvider( * view (via the `NavigatorButton` and `NavigatorBackButton` components or the * `useNavigator` hook). * - * @example * ```jsx * import { * __experimentalNavigatorProvider as NavigatorProvider, diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 201f2261ed2f73..7a920bd7e2bc5f 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -3,7 +3,9 @@ */ import type { ForwardedRef } from 'react'; // eslint-disable-next-line no-restricted-imports -import { motion, MotionProps } from 'framer-motion'; +import type { MotionProps } from 'framer-motion'; +// eslint-disable-next-line no-restricted-imports +import { motion } from 'framer-motion'; import { css } from '@emotion/react'; /** @@ -24,11 +26,8 @@ import { escapeAttribute } from '@wordpress/escape-html'; /** * Internal dependencies */ -import { - contextConnect, - useContextSystem, - WordPressComponentProps, -} from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect, useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import { View } from '../../view'; import { NavigatorContext } from '../context'; 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 5dd8ab1624ae91..a717df22c74131 100644 --- a/packages/components/src/navigator/navigator-to-parent-button/component.tsx +++ b/packages/components/src/navigator/navigator-to-parent-button/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { View } from '../../view'; import { useNavigatorBackButton } from '../navigator-back-button/hook'; import type { NavigatorToParentButtonProps } from '../types'; diff --git a/packages/components/src/navigator/stories/index.story.tsx b/packages/components/src/navigator/stories/index.story.tsx new file mode 100644 index 00000000000000..5adeadcf7ac1d6 --- /dev/null +++ b/packages/components/src/navigator/stories/index.story.tsx @@ -0,0 +1,366 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import { Card, CardBody, CardFooter, CardHeader } from '../../card'; +import { VStack } from '../../v-stack'; +import Dropdown from '../../dropdown'; +import { + NavigatorProvider, + NavigatorScreen, + NavigatorButton, + NavigatorBackButton, + NavigatorToParentButton, + useNavigator, +} from '..'; + +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', + argTypes: { + as: { control: { type: null } }, + children: { control: { type: null } }, + initialPath: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof NavigatorProvider > = ( { + style, + ...props +} ) => ( + <NavigatorProvider + style={ { ...style, height: '100vh', maxHeight: '450px' } } + { ...props } + > + <NavigatorScreen path="/"> + <Card> + <CardBody> + <p>This is the home screen.</p> + + <VStack alignment="left"> + <NavigatorButton variant="secondary" path="/child"> + Navigate to child screen. + </NavigatorButton> + + <NavigatorButton + variant="secondary" + path="/overflow-child" + > + Navigate to screen with horizontal overflow. + </NavigatorButton> + + <NavigatorButton variant="secondary" path="/stickies"> + Navigate to screen with sticky content. + </NavigatorButton> + + <NavigatorButton variant="secondary" path="/product/1"> + Navigate to product screen with id 1. + </NavigatorButton> + + <Dropdown + renderToggle={ ( { + isOpen, + onToggle, + }: { + // TODO: remove once `Dropdown` is refactored to TypeScript + isOpen: boolean; + onToggle: () => void; + } ) => ( + <Button + onClick={ onToggle } + aria-expanded={ isOpen } + variant="primary" + > + Open test dialog + </Button> + ) } + renderContent={ () => ( + <Card> + <CardHeader>Go</CardHeader> + <CardBody>Stuff</CardBody> + </Card> + ) } + /> + </VStack> + </CardBody> + </Card> + </NavigatorScreen> + + <NavigatorScreen path="/child"> + <Card> + <CardBody> + <p>This is the child screen.</p> + <NavigatorBackButton variant="secondary"> + Go back + </NavigatorBackButton> + </CardBody> + </Card> + </NavigatorScreen> + + <NavigatorScreen path="/overflow-child"> + <Card> + <CardBody> + <NavigatorBackButton variant="secondary"> + Go back + </NavigatorBackButton> + <div + style={ { + display: 'inline-block', + background: 'papayawhip', + } } + > + <span + style={ { + color: 'palevioletred', + whiteSpace: 'nowrap', + fontSize: '42vw', + } } + > + ¯\_(ツ)_/¯ + </span> + </div> + </CardBody> + </Card> + </NavigatorScreen> + + <NavigatorScreen path="/stickies"> + <Card> + <CardHeader style={ getStickyStyles( { zIndex: 2 } ) }> + <NavigatorBackButton variant="secondary"> + Go back + </NavigatorBackButton> + </CardHeader> + <CardBody> + <div + style={ getStickyStyles( { + top: 69, + bgColor: 'peachpuff', + } ) } + > + <h2>A wild sticky element appears</h2> + </div> + <MetaphorIpsum quantity={ 3 } /> + </CardBody> + <CardBody> + <div + style={ getStickyStyles( { + top: 69, + bgColor: 'paleturquoise', + } ) } + > + <h2>Another wild sticky element appears</h2> + </div> + <MetaphorIpsum quantity={ 3 } /> + </CardBody> + <CardFooter + style={ getStickyStyles( { + bgColor: 'mistyrose', + } ) } + > + <Button variant="primary">Primary noop</Button> + </CardFooter> + </Card> + </NavigatorScreen> + + <NavigatorScreen path="/product/:id"> + <ProductDetails /> + </NavigatorScreen> + </NavigatorProvider> +); + +export const Default: StoryFn< typeof NavigatorProvider > = Template.bind( {} ); +Default.args = { + initialPath: '/', +}; + +function getStickyStyles( { + bottom = 0, + bgColor = 'whitesmoke', + top = 0, + zIndex = 1, +} ): React.CSSProperties { + return { + display: 'flex', + position: 'sticky', + top, + bottom, + zIndex, + backgroundColor: bgColor, + }; +} + +function MetaphorIpsum( { quantity }: { quantity: number } ) { + const list = [ + 'A loopy clarinet’s year comes with it the thought that the fenny step-son is an ophthalmologist. The literature would have us believe that a glabrate country is not but a rhythm. A beech is a rub from the right perspective. In ancient times few can name an unglossed walrus that isn’t an unspilt trial.', + 'Authors often misinterpret the afterthought as a roseless mother-in-law, when in actuality it feels more like an uncapped thunderstorm. In recent years, some posit the tarry bottle to be less than acerb. They were lost without the unkissed timbale that composed their customer. A donna is a springtime breath.', + 'It’s an undeniable fact, really; their museum was, in this moment, a snotty beef. The swordfishes could be said to resemble prowessed lasagnas. However, the rainier authority comes from a cureless soup. Unfortunately, that is wrong; on the contrary, the cover is a powder.', + ]; + quantity = Math.min( list.length, quantity ); + return ( + <> + { list.slice( 0, quantity ).map( ( text, key ) => ( + <p style={ { maxWidth: '20em' } } key={ key }> + { text } + </p> + ) ) } + </> + ); +} + +function ProductDetails() { + const { params } = useNavigator(); + + return ( + <Card> + <CardBody> + <NavigatorBackButton variant="secondary"> + Go back + </NavigatorBackButton> + <p>This is the screen for the product with id: { params.id }</p> + </CardBody> + </Card> + ); +} + +const NestedNavigatorTemplate: StoryFn< typeof NavigatorProvider > = ( { + style, + ...props +} ) => ( + <NavigatorProvider + style={ { ...style, height: '100vh', maxHeight: '450px' } } + { ...props } + > + <NavigatorScreen path="/"> + <Card> + <CardBody> + <NavigatorButton variant="secondary" path="/child1"> + Go to first child. + </NavigatorButton> + <NavigatorButton variant="secondary" path="/child2"> + Go to second child. + </NavigatorButton> + </CardBody> + </Card> + </NavigatorScreen> + <NavigatorScreen path="/child1"> + <Card> + <CardBody> + This is the first child + <NavigatorToParentButton variant="secondary"> + Go back to parent + </NavigatorToParentButton> + </CardBody> + </Card> + </NavigatorScreen> + <NavigatorScreen path="/child2"> + <Card> + <CardBody> + This is the second child + <NavigatorToParentButton variant="secondary"> + Go back to parent + </NavigatorToParentButton> + <NavigatorButton + variant="secondary" + path="/child2/grandchild" + > + Go to grand child. + </NavigatorButton> + </CardBody> + </Card> + </NavigatorScreen> + <NavigatorScreen path="/child2/grandchild"> + <Card> + <CardBody> + This is the grand child + <NavigatorToParentButton variant="secondary"> + Go back to parent + </NavigatorToParentButton> + </CardBody> + </Card> + </NavigatorScreen> + </NavigatorProvider> +); + +export const NestedNavigator: StoryFn< typeof NavigatorProvider > = + NestedNavigatorTemplate.bind( {} ); +NestedNavigator.args = { + initialPath: '/child2/grandchild', +}; + +const NavigatorButtonWithSkipFocus = ( { + path, + onClick, + ...props +}: React.ComponentProps< typeof NavigatorButton > ) => { + const { goTo } = useNavigator(); + + return ( + <Button + { ...props } + onClick={ ( e: React.MouseEvent< HTMLButtonElement > ) => { + goTo( path, { skipFocus: true } ); + onClick?.( e ); + } } + /> + ); +}; + +export const SkipFocus: StoryFn< typeof NavigatorProvider > = ( args ) => { + return <NavigatorProvider { ...args } />; +}; +SkipFocus.args = { + initialPath: '/', + children: ( + <> + <div + style={ { + height: 250, + border: '1px solid black', + } } + > + <NavigatorScreen + path="/" + style={ { + height: '100%', + } } + > + <h1>Home screen</h1> + <NavigatorButton variant="secondary" path="/child"> + Go to child screen. + </NavigatorButton> + </NavigatorScreen> + <NavigatorScreen + path="/child" + style={ { + height: '100%', + } } + > + <h2>Child screen</h2> + <NavigatorToParentButton variant="secondary"> + Go to parent screen. + </NavigatorToParentButton> + </NavigatorScreen> + </div> + + <NavigatorButtonWithSkipFocus + variant="secondary" + path="/child" + style={ { margin: '1rem 2rem' } } + > + Go to child screen, but keep focus on this button + </NavigatorButtonWithSkipFocus> + </> + ), +}; diff --git a/packages/components/src/navigator/stories/index.tsx b/packages/components/src/navigator/stories/index.tsx deleted file mode 100644 index fb353a354d5446..00000000000000 --- a/packages/components/src/navigator/stories/index.tsx +++ /dev/null @@ -1,368 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import { Card, CardBody, CardFooter, CardHeader } from '../../card'; -import { VStack } from '../../v-stack'; -import Dropdown from '../../dropdown'; -import { - NavigatorProvider, - NavigatorScreen, - NavigatorButton, - NavigatorBackButton, - NavigatorToParentButton, - useNavigator, -} from '..'; - -const meta: ComponentMeta< typeof NavigatorProvider > = { - component: NavigatorProvider, - title: 'Components (Experimental)/Navigator', - subcomponents: { NavigatorScreen, NavigatorButton, NavigatorBackButton }, - argTypes: { - as: { control: { type: null } }, - children: { control: { type: null } }, - initialPath: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof NavigatorProvider > = ( { - style, - ...props -} ) => ( - <NavigatorProvider - style={ { ...style, height: '100vh', maxHeight: '450px' } } - { ...props } - > - <NavigatorScreen path="/"> - <Card> - <CardBody> - <p>This is the home screen.</p> - - <VStack alignment="left"> - <NavigatorButton variant="secondary" path="/child"> - Navigate to child screen. - </NavigatorButton> - - <NavigatorButton - variant="secondary" - path="/overflow-child" - > - Navigate to screen with horizontal overflow. - </NavigatorButton> - - <NavigatorButton variant="secondary" path="/stickies"> - Navigate to screen with sticky content. - </NavigatorButton> - - <NavigatorButton variant="secondary" path="/product/1"> - Navigate to product screen with id 1. - </NavigatorButton> - - <Dropdown - renderToggle={ ( { - isOpen, - onToggle, - }: { - // TODO: remove once `Dropdown` is refactored to TypeScript - isOpen: boolean; - onToggle: () => void; - } ) => ( - <Button - onClick={ onToggle } - aria-expanded={ isOpen } - variant="primary" - > - Open test dialog - </Button> - ) } - renderContent={ () => ( - <Card> - <CardHeader>Go</CardHeader> - <CardBody>Stuff</CardBody> - </Card> - ) } - /> - </VStack> - </CardBody> - </Card> - </NavigatorScreen> - - <NavigatorScreen path="/child"> - <Card> - <CardBody> - <p>This is the child screen.</p> - <NavigatorBackButton variant="secondary"> - Go back - </NavigatorBackButton> - </CardBody> - </Card> - </NavigatorScreen> - - <NavigatorScreen path="/overflow-child"> - <Card> - <CardBody> - <NavigatorBackButton variant="secondary"> - Go back - </NavigatorBackButton> - <div - style={ { - display: 'inline-block', - background: 'papayawhip', - } } - > - <span - style={ { - color: 'palevioletred', - whiteSpace: 'nowrap', - fontSize: '42vw', - } } - > - ¯\_(ツ)_/¯ - </span> - </div> - </CardBody> - </Card> - </NavigatorScreen> - - <NavigatorScreen path="/stickies"> - <Card> - <CardHeader style={ getStickyStyles( { zIndex: 2 } ) }> - <NavigatorBackButton variant="secondary"> - Go back - </NavigatorBackButton> - </CardHeader> - <CardBody> - <div - style={ getStickyStyles( { - top: 69, - bgColor: 'peachpuff', - } ) } - > - <h2>A wild sticky element appears</h2> - </div> - <MetaphorIpsum quantity={ 3 } /> - </CardBody> - <CardBody> - <div - style={ getStickyStyles( { - top: 69, - bgColor: 'paleturquoise', - } ) } - > - <h2>Another wild sticky element appears</h2> - </div> - <MetaphorIpsum quantity={ 3 } /> - </CardBody> - <CardFooter - style={ getStickyStyles( { - bgColor: 'mistyrose', - } ) } - > - <Button variant="primary">Primary noop</Button> - </CardFooter> - </Card> - </NavigatorScreen> - - <NavigatorScreen path="/product/:id"> - <ProductDetails /> - </NavigatorScreen> - </NavigatorProvider> -); - -export const Default: ComponentStory< typeof NavigatorProvider > = - Template.bind( {} ); -Default.args = { - initialPath: '/', -}; - -function getStickyStyles( { - bottom = 0, - bgColor = 'whitesmoke', - top = 0, - zIndex = 1, -} ): React.CSSProperties { - return { - display: 'flex', - position: 'sticky', - top, - bottom, - zIndex, - backgroundColor: bgColor, - }; -} - -function MetaphorIpsum( { quantity }: { quantity: number } ) { - const list = [ - 'A loopy clarinet’s year comes with it the thought that the fenny step-son is an ophthalmologist. The literature would have us believe that a glabrate country is not but a rhythm. A beech is a rub from the right perspective. In ancient times few can name an unglossed walrus that isn’t an unspilt trial.', - 'Authors often misinterpret the afterthought as a roseless mother-in-law, when in actuality it feels more like an uncapped thunderstorm. In recent years, some posit the tarry bottle to be less than acerb. They were lost without the unkissed timbale that composed their customer. A donna is a springtime breath.', - 'It’s an undeniable fact, really; their museum was, in this moment, a snotty beef. The swordfishes could be said to resemble prowessed lasagnas. However, the rainier authority comes from a cureless soup. Unfortunately, that is wrong; on the contrary, the cover is a powder.', - ]; - quantity = Math.min( list.length, quantity ); - return ( - <> - { list.slice( 0, quantity ).map( ( text, key ) => ( - <p style={ { maxWidth: '20em' } } key={ key }> - { text } - </p> - ) ) } - </> - ); -} - -function ProductDetails() { - const { params } = useNavigator(); - - return ( - <Card> - <CardBody> - <NavigatorBackButton variant="secondary"> - Go back - </NavigatorBackButton> - <p>This is the screen for the product with id: { params.id }</p> - </CardBody> - </Card> - ); -} - -const NestedNavigatorTemplate: ComponentStory< typeof NavigatorProvider > = ( { - style, - ...props -} ) => ( - <NavigatorProvider - style={ { ...style, height: '100vh', maxHeight: '450px' } } - { ...props } - > - <NavigatorScreen path="/"> - <Card> - <CardBody> - <NavigatorButton variant="secondary" path="/child1"> - Go to first child. - </NavigatorButton> - <NavigatorButton variant="secondary" path="/child2"> - Go to second child. - </NavigatorButton> - </CardBody> - </Card> - </NavigatorScreen> - <NavigatorScreen path="/child1"> - <Card> - <CardBody> - This is the first child - <NavigatorToParentButton variant="secondary"> - Go back to parent - </NavigatorToParentButton> - </CardBody> - </Card> - </NavigatorScreen> - <NavigatorScreen path="/child2"> - <Card> - <CardBody> - This is the second child - <NavigatorToParentButton variant="secondary"> - Go back to parent - </NavigatorToParentButton> - <NavigatorButton - variant="secondary" - path="/child2/grandchild" - > - Go to grand child. - </NavigatorButton> - </CardBody> - </Card> - </NavigatorScreen> - <NavigatorScreen path="/child2/grandchild"> - <Card> - <CardBody> - This is the grand child - <NavigatorToParentButton variant="secondary"> - Go back to parent - </NavigatorToParentButton> - </CardBody> - </Card> - </NavigatorScreen> - </NavigatorProvider> -); - -export const NestedNavigator: ComponentStory< typeof NavigatorProvider > = - NestedNavigatorTemplate.bind( {} ); -NestedNavigator.args = { - initialPath: '/child2/grandchild', -}; - -const NavigatorButtonWithSkipFocus = ( { - path, - onClick, - ...props -}: React.ComponentProps< typeof NavigatorButton > ) => { - const { goTo } = useNavigator(); - - return ( - <Button - { ...props } - onClick={ ( e: React.MouseEvent< HTMLButtonElement > ) => { - goTo( path, { skipFocus: true } ); - onClick?.( e ); - } } - /> - ); -}; - -export const SkipFocus: ComponentStory< typeof NavigatorProvider > = ( - args -) => { - return <NavigatorProvider { ...args } />; -}; -SkipFocus.args = { - initialPath: '/', - children: ( - <> - <div - style={ { - height: 250, - border: '1px solid black', - } } - > - <NavigatorScreen - path="/" - style={ { - height: '100%', - } } - > - <h1>Home screen</h1> - <NavigatorButton variant="secondary" path="/child"> - Go to child screen. - </NavigatorButton> - </NavigatorScreen> - <NavigatorScreen - path="/child" - style={ { - height: '100%', - } } - > - <h2>Child screen</h2> - <NavigatorToParentButton variant="secondary"> - Go to parent screen. - </NavigatorToParentButton> - </NavigatorScreen> - </div> - - <NavigatorButtonWithSkipFocus - variant="secondary" - path="/child" - style={ { margin: '1rem 2rem' } } - > - Go to child screen, but keep focus on this button - </NavigatorButtonWithSkipFocus> - </> - ), -}; diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts index e638084e8376d5..557f8074fd42e2 100644 --- a/packages/components/src/navigator/types.ts +++ b/packages/components/src/navigator/types.ts @@ -14,8 +14,11 @@ export type NavigateOptions = { focusTargetSelector?: string; isBack?: boolean; skipFocus?: boolean; + replace?: boolean; }; +export type NavigateToParentOptions = Omit< NavigateOptions, 'isBack' >; + export type NavigatorLocation = NavigateOptions & { isInitial?: boolean; path?: string; @@ -28,7 +31,7 @@ export type Navigator = { params: MatchParams; goTo: ( path: string, options?: NavigateOptions ) => void; goBack: () => void; - goToParent: () => void; + goToParent: ( options?: NavigateToParentOptions ) => void; }; export type NavigatorContext = Navigator & { diff --git a/packages/components/src/notice/stories/index.story.tsx b/packages/components/src/notice/stories/index.story.tsx new file mode 100644 index 00000000000000..16a68ab293f551 --- /dev/null +++ b/packages/components/src/notice/stories/index.story.tsx @@ -0,0 +1,118 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Notice from '..'; +import Button from '../../button'; +import NoticeList from '../list'; +import type { NoticeListProps } from '../types'; + +const meta: Meta< typeof Notice > = { + title: 'Components/Notice', + component: Notice, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { NoticeList }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Notice > = ( props ) => { + return <Notice { ...props } />; +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: 'This is a notice.', +}; + +export const WithCustomSpokenMessage = Template.bind( {} ); +WithCustomSpokenMessage.args = { + ...Default.args, + politeness: 'assertive', + spokenMessage: 'This is a notice with a custom spoken message', +}; + +export const WithJSXChildren = Template.bind( {} ); +WithJSXChildren.args = { + ...Default.args, + children: ( + <> + <p> + JSX elements can be helpful + <strong> if you need to format</strong> the notice output. + </p> + <code> + note: in the interest of consistency, this should not be + overused! + </code> + </> + ), +}; + +export const WithActions = Template.bind( {} ); +WithActions.args = { + ...Default.args, + actions: [ + { + label: 'Click me!', + onClick: () => {}, + variant: 'primary', + }, + { + label: 'Or click me instead!', + onClick: () => {}, + }, + { + label: 'Or visit a link for more info', + url: 'https://wordpress.org', + variant: 'link', + }, + ], +}; + +export const NoticeListSubcomponent: StoryFn< typeof NoticeList > = () => { + const exampleNotices = [ + { + id: 'second-notice', + content: 'second notice content', + }, + { + id: 'first-notice', + content: 'first notice content', + }, + ]; + const [ notices, setNotices ] = useState( exampleNotices ); + + const removeNotice = ( + id: NoticeListProps[ 'notices' ][ number ][ 'id' ] + ) => { + setNotices( notices.filter( ( notice ) => notice.id !== id ) ); + }; + + const resetNotices = () => { + setNotices( exampleNotices ); + }; + + return ( + <> + <NoticeList notices={ notices } onRemove={ removeNotice } /> + <Button variant={ 'primary' } onClick={ resetNotices }> + Reset Notices + </Button> + </> + ); +}; +NoticeListSubcomponent.storyName = 'NoticeList Subcomponent'; diff --git a/packages/components/src/notice/stories/index.tsx b/packages/components/src/notice/stories/index.tsx deleted file mode 100644 index b0fa9b9baeee1a..00000000000000 --- a/packages/components/src/notice/stories/index.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Notice from '..'; -import Button from '../../button'; -import NoticeList from '../list'; -import type { NoticeListProps } from '../types'; - -const meta: ComponentMeta< typeof Notice > = { - title: 'Components/Notice', - component: Notice, - subcomponents: { NoticeList }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Notice > = ( props ) => { - return <Notice { ...props } />; -}; - -export const Default = Template.bind( {} ); -Default.args = { - children: 'This is a notice.', -}; - -export const WithCustomSpokenMessage = Template.bind( {} ); -WithCustomSpokenMessage.args = { - ...Default.args, - politeness: 'assertive', - spokenMessage: 'This is a notice with a custom spoken message', -}; - -export const WithJSXChildren = Template.bind( {} ); -WithJSXChildren.args = { - ...Default.args, - children: ( - <> - <p> - JSX elements can be helpful - <strong> if you need to format</strong> the notice output. - </p> - <code> - note: in the interest of consistency, this should not be - overused! - </code> - </> - ), -}; - -export const WithActions = Template.bind( {} ); -WithActions.args = { - ...Default.args, - actions: [ - { - label: 'Click me!', - onClick: () => {}, - variant: 'primary', - }, - { - label: 'Or click me instead!', - onClick: () => {}, - }, - { - label: 'Or visit a link for more info', - url: 'https://wordpress.org', - variant: 'link', - }, - ], -}; - -export const NoticeListSubcomponent: ComponentStory< - typeof NoticeList -> = () => { - const exampleNotices = [ - { - id: 'second-notice', - content: 'second notice content', - }, - { - id: 'first-notice', - content: 'first notice content', - }, - ]; - const [ notices, setNotices ] = useState( exampleNotices ); - - const removeNotice = ( - id: NoticeListProps[ 'notices' ][ number ][ 'id' ] - ) => { - setNotices( notices.filter( ( notice ) => notice.id !== id ) ); - }; - - const resetNotices = () => { - setNotices( exampleNotices ); - }; - - return ( - <> - <NoticeList notices={ notices } onRemove={ removeNotice } /> - <Button variant={ 'primary' } onClick={ resetNotices }> - Reset Notices - </Button> - </> - ); -}; -NoticeListSubcomponent.storyName = 'NoticeList Subcomponent'; diff --git a/packages/components/src/number-control/index.tsx b/packages/components/src/number-control/index.tsx index 0df307e4ee45c2..a70fc500d4134f 100644 --- a/packages/components/src/number-control/index.tsx +++ b/packages/components/src/number-control/index.tsx @@ -16,7 +16,7 @@ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -import { Input, SpinButton } from './styles/number-control-styles'; +import { Input, SpinButton, styles } from './styles/number-control-styles'; import * as inputControlActionTypes from '../input-control/reducer/actions'; import { add, subtract, roundClamp } from '../utils/math'; import { ensureNumber, isValueEmpty } from '../utils/values'; @@ -24,6 +24,7 @@ import type { WordPressComponentProps } from '../ui/context/wordpress-component' import type { NumberControlProps } from './types'; import { HStack } from '../h-stack'; import { Spacer } from '../spacer'; +import { useCx } from '../utils'; const noop = () => {}; @@ -42,6 +43,7 @@ function UnforwardedNumberControl( required = false, shiftStep = 10, step = 1, + spinFactor = 1, type: typeProp = 'number', value: valueProp, size = 'default', @@ -59,25 +61,28 @@ function UnforwardedNumberControl( } ); spinControls = 'none'; } - const inputRef = useRef< HTMLInputElement >(); const mergedRef = useMergeRefs( [ inputRef, forwardedRef ] ); const isStepAny = step === 'any'; const baseStep = isStepAny ? 1 : ensureNumber( step ); + const baseSpin = ensureNumber( spinFactor ) * baseStep; const baseValue = roundClamp( 0, min, max, baseStep ); const constrainValue = ( value: number | string, stepOverride?: number ) => { // When step is "any" clamp the value, otherwise round and clamp it. + // Use '' + to convert to string for use in input value attribute. return isStepAny - ? Math.min( max, Math.max( min, ensureNumber( value ) ) ) - : roundClamp( value, min, max, stepOverride ?? baseStep ); + ? '' + Math.min( max, Math.max( min, ensureNumber( value ) ) ) + : '' + roundClamp( value, min, max, stepOverride ?? baseStep ); }; const autoComplete = typeProp === 'number' ? 'off' : undefined; const classes = classNames( 'components-number-control', className ); + const cx = useCx(); + const spinButtonClasses = cx( size === 'small' && styles.smallSpinButtons ); const spinValue = ( value: string | number | undefined, @@ -86,7 +91,7 @@ function UnforwardedNumberControl( ) => { event?.preventDefault(); const shift = event?.shiftKey && isShiftStepEnabled; - const delta = shift ? ensureNumber( shiftStep ) * baseStep : baseStep; + const delta = shift ? ensureNumber( shiftStep ) * baseSpin : baseSpin; let nextValue = isValueEmpty( value ) ? baseValue : value; if ( direction === 'up' ) { nextValue = add( nextValue, delta ); @@ -118,7 +123,6 @@ function UnforwardedNumberControl( type === inputControlActionTypes.PRESS_UP || type === inputControlActionTypes.PRESS_DOWN ) { - // @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components nextState.value = spinValue( currentValue, type === inputControlActionTypes.PRESS_UP ? 'up' : 'down', @@ -133,8 +137,8 @@ function UnforwardedNumberControl( const [ x, y ] = payload.delta; const enableShift = payload.shiftKey && isShiftStepEnabled; const modifier = enableShift - ? ensureNumber( shiftStep ) * baseStep - : baseStep; + ? ensureNumber( shiftStep ) * baseSpin + : baseSpin; let directionModifier; let delta; @@ -165,7 +169,6 @@ function UnforwardedNumberControl( delta = Math.ceil( Math.abs( delta ) ) * Math.sign( delta ); const distance = delta * modifier * directionModifier; - // @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components nextState.value = constrainValue( // @ts-expect-error TODO: Investigate if it's ok for currentValue to be undefined add( currentValue, distance ), @@ -184,7 +187,6 @@ function UnforwardedNumberControl( const applyEmptyValue = required === false && currentValue === ''; - // @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components nextState.value = applyEmptyValue ? currentValue : // @ts-expect-error TODO: Investigate if it's ok for currentValue to be undefined @@ -236,6 +238,7 @@ function UnforwardedNumberControl( <Spacer marginBottom={ 0 } marginRight={ 2 }> <HStack spacing={ 1 }> <SpinButton + className={ spinButtonClasses } icon={ plusIcon } isSmall aria-hidden="true" @@ -244,9 +247,9 @@ function UnforwardedNumberControl( onClick={ buildSpinButtonClickHandler( 'up' ) } - size={ size } /> <SpinButton + className={ spinButtonClasses } icon={ resetIcon } isSmall aria-hidden="true" @@ -255,7 +258,6 @@ function UnforwardedNumberControl( onClick={ buildSpinButtonClickHandler( 'down' ) } - size={ size } /> </HStack> </Spacer> diff --git a/packages/components/src/number-control/stories/index.story.tsx b/packages/components/src/number-control/stories/index.story.tsx new file mode 100644 index 00000000000000..3588063f0f4bb2 --- /dev/null +++ b/packages/components/src/number-control/stories/index.story.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NumberControl from '..'; + +const meta: Meta< typeof NumberControl > = { + title: 'Components (Experimental)/NumberControl', + component: NumberControl, + argTypes: { + onChange: { action: 'onChange' }, + prefix: { control: { type: 'text' } }, + step: { control: { type: 'text' } }, + suffix: { control: { type: 'text' } }, + type: { control: { type: 'text' } }, + value: { control: null }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +const Template: StoryFn< typeof NumberControl > = ( { + onChange, + ...props +} ) => { + const [ value, setValue ] = useState< string | undefined >( '0' ); + const [ isValidValue, setIsValidValue ] = useState( true ); + + return ( + <> + <NumberControl + { ...props } + value={ value } + onChange={ ( v, extra ) => { + setValue( v ); + setIsValidValue( + ( extra.event.target as HTMLInputElement ).validity + .valid + ); + onChange?.( v, extra ); + } } + /> + <p>Is valid? { isValidValue ? 'Yes' : 'No' }</p> + </> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + label: 'Value', +}; diff --git a/packages/components/src/number-control/stories/index.tsx b/packages/components/src/number-control/stories/index.tsx deleted file mode 100644 index 8dce45db26d5da..00000000000000 --- a/packages/components/src/number-control/stories/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import NumberControl from '..'; - -const meta: ComponentMeta< typeof NumberControl > = { - title: 'Components (Experimental)/NumberControl', - component: NumberControl, - argTypes: { - onChange: { action: 'onChange' }, - prefix: { control: { type: 'text' } }, - step: { control: { type: 'text' } }, - suffix: { control: { type: 'text' } }, - type: { control: { type: 'text' } }, - value: { control: null }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -const Template: ComponentStory< typeof NumberControl > = ( { - onChange, - ...props -} ) => { - const [ value, setValue ] = useState< string | undefined >( '0' ); - const [ isValidValue, setIsValidValue ] = useState( true ); - - return ( - <> - <NumberControl - { ...props } - value={ value } - onChange={ ( v, extra ) => { - setValue( v ); - setIsValidValue( - ( extra.event.target as HTMLInputElement ).validity - .valid - ); - onChange?.( v, extra ); - } } - /> - <p>Is valid? { isValidValue ? 'Yes' : 'No' }</p> - </> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - label: 'Value', -}; diff --git a/packages/components/src/number-control/styles/number-control-styles.ts b/packages/components/src/number-control/styles/number-control-styles.ts index dfc6171cf411b4..6cbb18b565c25d 100644 --- a/packages/components/src/number-control/styles/number-control-styles.ts +++ b/packages/components/src/number-control/styles/number-control-styles.ts @@ -11,7 +11,6 @@ import InputControl from '../../input-control'; import { COLORS } from '../../utils'; import Button from '../../button'; import { space } from '../../ui/utils/space'; -import type { NumberControlProps } from '../types'; const htmlArrowStyles = ( { hideHTMLArrows }: { hideHTMLArrows: boolean } ) => { if ( ! hideHTMLArrows ) { @@ -35,23 +34,16 @@ export const Input = styled( InputControl )` ${ htmlArrowStyles }; `; -const spinButtonSizeStyles = ( { - size, -}: Pick< NumberControlProps, 'size' > ) => { - if ( size !== 'small' ) { - return ``; - } - - return css` - width: ${ space( 5 ) }; - min-width: ${ space( 5 ) }; - height: ${ space( 5 ) }; - `; -}; - export const SpinButton = styled( Button )` &&&&& { - color: ${ COLORS.ui.theme }; - ${ spinButtonSizeStyles } + color: ${ COLORS.theme.accent }; } `; + +const smallSpinButtons = css` + width: ${ space( 5 ) }; + min-width: ${ space( 5 ) }; + height: ${ space( 5 ) }; +`; + +export const styles = { smallSpinButtons }; diff --git a/packages/components/src/number-control/test/index.tsx b/packages/components/src/number-control/test/index.tsx index ae92875708b568..3cf3368f1636ba 100644 --- a/packages/components/src/number-control/test/index.tsx +++ b/packages/components/src/number-control/test/index.tsx @@ -97,7 +97,7 @@ describe( 'NumberControl', () => { // Second call: type '1' expect( onChangeSpy ).toHaveBeenNthCalledWith( 2, '1', false ); // Third call: clamp value - expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, 4, true ); + expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, '4', true ); } ); it( 'should call onChange callback when value is not valid', async () => { @@ -139,7 +139,7 @@ describe( 'NumberControl', () => { // Third call: invalid, unclamped value expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, '14', false ); // Fourth call: valid, clamped value - expect( onChangeSpy ).toHaveBeenNthCalledWith( 4, 10, true ); + expect( onChangeSpy ).toHaveBeenNthCalledWith( 4, '10', true ); } ); } ); @@ -292,6 +292,24 @@ describe( 'NumberControl', () => { expect( input ).toHaveValue( 867.5309 ); } ); + it( 'should increment by step multiplied by spinFactor when spinFactor is provided', async () => { + const user = userEvent.setup(); + + render( + <StatefulNumberControl + step={ 0.01 } + spinFactor={ 10 } + value={ 1.65 } + /> + ); + + const input = screen.getByRole( 'spinbutton' ); + await user.click( input ); + await user.keyboard( '[ArrowUp]' ); + + expect( input ).toHaveValue( 1.75 ); + } ); + it( 'should increment by shiftStep on key UP + shift press', async () => { const user = userEvent.setup(); @@ -304,6 +322,18 @@ describe( 'NumberControl', () => { expect( input ).toHaveValue( 20 ); } ); + it( 'should increment by shiftStep multiplied by spinFactor on key UP + shift press', async () => { + const user = userEvent.setup(); + + render( <StatefulNumberControl value={ 5 } spinFactor={ 5 } /> ); + + const input = screen.getByRole( 'spinbutton' ); + await user.click( input ); + await user.keyboard( '{Shift>}[ArrowUp]{/Shift}' ); + + expect( input ).toHaveValue( 50 ); + } ); + it( 'should increment by shiftStep while preserving the decimal value when `step` is “any”', async () => { const user = userEvent.setup(); @@ -415,6 +445,24 @@ describe( 'NumberControl', () => { expect( input ).toHaveValue( 867.5309 ); } ); + it( 'should decrement by step multiplied by spinFactor when spinFactor is provided', async () => { + const user = userEvent.setup(); + + render( + <StatefulNumberControl + step={ 0.01 } + spinFactor={ 10 } + value={ 1.65 } + /> + ); + + const input = screen.getByRole( 'spinbutton' ); + await user.click( input ); + await user.keyboard( '[ArrowDown]' ); + + expect( input ).toHaveValue( 1.55 ); + } ); + it( 'should decrement by shiftStep on key DOWN + shift press', async () => { const user = userEvent.setup(); @@ -427,6 +475,18 @@ describe( 'NumberControl', () => { expect( input ).toHaveValue( 0 ); } ); + it( 'should decrement by shiftStep multiplied by spinFactor on key DOWN + shift press', async () => { + const user = userEvent.setup(); + + render( <StatefulNumberControl value={ 100 } spinFactor={ 5 } /> ); + + const input = screen.getByRole( 'spinbutton' ); + await user.click( input ); + await user.keyboard( '{Shift>}[ArrowDown]{/Shift}' ); + + expect( input ).toHaveValue( 50 ); + } ); + it( 'should decrement by shiftStep while preserving the decimal value when `step` is “any”', async () => { const user = userEvent.setup(); @@ -511,10 +571,12 @@ describe( 'NumberControl', () => { [ 'up', '2', { value: '1' } ], [ 'up', '12', { value: '10', step: '2' } ], [ 'up', '10', { value: '10', max: 10 } ], + [ 'up', '10.1', { value: '10', step: '0.01', spinFactor: 10 } ], [ 'down', '-1', {} ], [ 'down', '1', { value: '2' } ], [ 'down', '10', { value: '12', step: '2' } ], [ 'down', '10', { value: '10', min: 10 } ], + [ 'down', '9.9', { value: '10', step: '0.01', spinFactor: 10 } ], ] )( 'should spin %s to %s when props = %o', async ( direction, expectedValue, props ) => { diff --git a/packages/components/src/number-control/types.ts b/packages/components/src/number-control/types.ts index a6cb68d81c3914..98ee8e0a672f08 100644 --- a/packages/components/src/number-control/types.ts +++ b/packages/components/src/number-control/types.ts @@ -74,6 +74,13 @@ export type NumberControlProps = Omit< * @default 1 */ step?: InputControlProps[ 'step' ]; + /** + * Optional multiplication factor in spin changes. i.e. A spin changes + * by `spinFactor * step` (if `step` is "any", 1 is used instead). + * + * @default 1 + */ + spinFactor?: number; /** * The `type` attribute of the `input` element. * diff --git a/packages/components/src/palette-edit/stories/index.story.tsx b/packages/components/src/palette-edit/stories/index.story.tsx new file mode 100644 index 00000000000000..dd2ab92978c923 --- /dev/null +++ b/packages/components/src/palette-edit/stories/index.story.tsx @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import PaletteEdit from '..'; +import type { Color, Gradient } from '../types'; + +const meta: Meta< typeof PaletteEdit > = { + title: 'Components/PaletteEdit', + component: PaletteEdit, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof PaletteEdit > = ( args ) => { + const { colors, gradients, onChange, ...props } = args; + const [ value, setValue ] = useState( gradients || colors ); + + return ( + <PaletteEdit + { ...( gradients + ? { + gradients: value as Gradient[], + onChange: ( newValue?: Gradient[] ) => { + setValue( newValue ); + onChange( newValue ); + }, + } + : { + colors: value as Color[], + onChange: ( newValue?: Color[] ) => { + setValue( newValue ); + onChange( newValue ); + }, + } ) } + { ...props } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + colors: [ + { color: '#1a4548', name: 'Primary', slug: 'primary' }, + { color: '#0000ff', name: 'Secondary', slug: 'secondary' }, + ], + paletteLabel: 'Colors', + emptyMessage: 'Colors are empty', + popoverProps: { + placement: 'bottom-start', + offset: 8, + }, +}; + +export const Gradients = Template.bind( {} ); +Gradients.args = { + gradients: [ + { + gradient: + 'linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%)', + name: 'Pale ocean', + slug: 'pale-ocean', + }, + { + gradient: + 'linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%)', + name: 'Midnight', + slug: 'midnight', + }, + ], + paletteLabel: 'Gradients', + emptyMessage: 'Gradients are empty', +}; diff --git a/packages/components/src/palette-edit/stories/index.tsx b/packages/components/src/palette-edit/stories/index.tsx deleted file mode 100644 index 5739964fbb2764..00000000000000 --- a/packages/components/src/palette-edit/stories/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import PaletteEdit from '..'; -import type { Color, Gradient } from '../types'; - -const meta: ComponentMeta< typeof PaletteEdit > = { - title: 'Components/PaletteEdit', - component: PaletteEdit, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof PaletteEdit > = ( args ) => { - const { colors, gradients, onChange, ...props } = args; - const [ value, setValue ] = useState( gradients || colors ); - - return ( - <PaletteEdit - { ...( gradients - ? { - gradients: value as Gradient[], - onChange: ( newValue?: Gradient[] ) => { - setValue( newValue ); - onChange( newValue ); - }, - } - : { - colors: value as Color[], - onChange: ( newValue?: Color[] ) => { - setValue( newValue ); - onChange( newValue ); - }, - } ) } - { ...props } - /> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - colors: [ - { color: '#1a4548', name: 'Primary', slug: 'primary' }, - { color: '#0000ff', name: 'Secondary', slug: 'secondary' }, - ], - paletteLabel: 'Colors', - emptyMessage: 'Colors are empty', - popoverProps: { - placement: 'bottom-start', - offset: 8, - }, -}; - -export const Gradients = Template.bind( {} ); -Gradients.args = { - gradients: [ - { - gradient: - 'linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%)', - name: 'Pale ocean', - slug: 'pale-ocean', - }, - { - gradient: - 'linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%)', - name: 'Midnight', - slug: 'midnight', - }, - ], - paletteLabel: 'Gradients', - emptyMessage: 'Gradients are empty', -}; diff --git a/packages/components/src/palette-edit/styles.js b/packages/components/src/palette-edit/styles.js index e524f153f88482..72e6bff97e4a29 100644 --- a/packages/components/src/palette-edit/styles.js +++ b/packages/components/src/palette-edit/styles.js @@ -42,7 +42,6 @@ export const NameInputControl = styled( InputControl )` export const PaletteItem = styled( View )` padding: 3px 0 3px ${ space( 3 ) }; - height: calc( 40px - ${ CONFIG.borderWidth } ); border: 1px solid ${ CONFIG.surfaceBorderColor }; border-bottom-color: transparent; &:first-of-type { @@ -58,7 +57,7 @@ export const PaletteItem = styled( View )` border-top-color: transparent; } &.is-selected { - border-color: ${ COLORS.ui.theme }; + border-color: ${ COLORS.theme.accent }; } `; @@ -69,7 +68,7 @@ export const NameContainer = styled.div` white-space: nowrap; overflow: hidden; ${ PaletteItem }:hover & { - color: ${ COLORS.ui.theme }; + color: ${ COLORS.theme.accent }; } `; @@ -103,7 +102,7 @@ export const PaletteEditStyles = styled( View )` export const DoneButton = styled( Button )` && { - color: ${ COLORS.ui.theme }; + color: ${ COLORS.theme.accent }; } `; diff --git a/packages/components/src/panel/stories/index.story.tsx b/packages/components/src/panel/stories/index.story.tsx new file mode 100644 index 00000000000000..7f69f766603ebb --- /dev/null +++ b/packages/components/src/panel/stories/index.story.tsx @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Panel from '../'; +import PanelRow from '../row'; +import PanelBody from '../body'; +import InputControl from '../../input-control'; + +/** + * WordPress dependencies + */ +import { wordpress } from '@wordpress/icons'; + +const meta: Meta< typeof Panel > = { + title: 'Components/Panel', + component: Panel, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { PanelRow, PanelBody }, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Panel > = ( props ) => <Panel { ...props } />; + +export const Default: StoryFn< typeof Panel > = Template.bind( {} ); +Default.args = { + header: 'My panel', + children: ( + <> + <PanelBody title="First section"> + <PanelRow> + <div + style={ { + background: '#ddd', + height: 100, + width: '100%', + } } + /> + </PanelRow> + </PanelBody> + <PanelBody title="Second section" initialOpen={ false }> + <PanelRow> + <div + style={ { + background: '#ddd', + height: 100, + width: '100%', + } } + /> + </PanelRow> + </PanelBody> + </> + ), +}; + +/** + * `PanelRow` is a generic container for rows within a `PanelBody`. + * It is a flex container with a top margin for spacing. + */ +export const _PanelRow: StoryFn< typeof Panel > = Template.bind( {} ); +_PanelRow.args = { + children: ( + <PanelBody title="My Profile"> + <PanelRow> + <InputControl label="First name" /> + <InputControl label="Last name" /> + </PanelRow> + <PanelRow> + <div style={ { flex: 1 } }> + <InputControl label="Email" /> + </div> + </PanelRow> + </PanelBody> + ), +}; + +export const DisabledSection: StoryFn< typeof Panel > = Template.bind( {} ); +DisabledSection.args = { + ...Default.args, + children: ( + <PanelBody + title="Disabled section" + initialOpen={ false } + buttonProps={ { disabled: true } } + /> + ), +}; + +export const WithIcon: StoryFn< typeof Panel > = Template.bind( {} ); +WithIcon.args = { + ...Default.args, + children: ( + <PanelBody title="Section title" icon={ wordpress }> + <PanelRow> + <div + style={ { + background: '#ddd', + height: 100, + width: '100%', + } } + /> + </PanelRow> + </PanelBody> + ), +}; diff --git a/packages/components/src/panel/stories/index.tsx b/packages/components/src/panel/stories/index.tsx deleted file mode 100644 index b3c189d11cb888..00000000000000 --- a/packages/components/src/panel/stories/index.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Panel from '../'; -import PanelRow from '../row'; -import PanelBody from '../body'; -import InputControl from '../../input-control'; - -/** - * WordPress dependencies - */ -import { wordpress } from '@wordpress/icons'; - -const meta: ComponentMeta< typeof Panel > = { - title: 'Components/Panel', - component: Panel, - subcomponents: { PanelRow, PanelBody }, - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Panel > = ( props ) => ( - <Panel { ...props } /> -); - -export const Default: ComponentStory< typeof Panel > = Template.bind( {} ); -Default.args = { - header: 'My panel', - children: ( - <> - <PanelBody title="First section"> - <PanelRow> - <div - style={ { - background: '#ddd', - height: 100, - width: '100%', - } } - /> - </PanelRow> - </PanelBody> - <PanelBody title="Second section" initialOpen={ false }> - <PanelRow> - <div - style={ { - background: '#ddd', - height: 100, - width: '100%', - } } - /> - </PanelRow> - </PanelBody> - </> - ), -}; - -/** - * `PanelRow` is a generic container for rows within a `PanelBody`. - * It is a flex container with a top margin for spacing. - */ -export const _PanelRow: ComponentStory< typeof Panel > = Template.bind( {} ); -_PanelRow.args = { - children: ( - <PanelBody title="My Profile"> - <PanelRow> - <InputControl label="First name" /> - <InputControl label="Last name" /> - </PanelRow> - <PanelRow> - <div style={ { flex: 1 } }> - <InputControl label="Email" /> - </div> - </PanelRow> - </PanelBody> - ), -}; - -export const DisabledSection: ComponentStory< typeof Panel > = Template.bind( - {} -); -DisabledSection.args = { - ...Default.args, - children: ( - <PanelBody - title="Disabled section" - initialOpen={ false } - buttonProps={ { disabled: true } } - /> - ), -}; - -export const WithIcon: ComponentStory< typeof Panel > = Template.bind( {} ); -WithIcon.args = { - ...Default.args, - children: ( - <PanelBody title="Section title" icon={ wordpress }> - <PanelRow> - <div - style={ { - background: '#ddd', - height: 100, - width: '100%', - } } - /> - </PanelRow> - </PanelBody> - ), -}; diff --git a/packages/components/src/placeholder/stories/index.story.tsx b/packages/components/src/placeholder/stories/index.story.tsx new file mode 100644 index 00000000000000..541eeceedc27d4 --- /dev/null +++ b/packages/components/src/placeholder/stories/index.story.tsx @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { starEmpty, starFilled, styles, wordpress } from '@wordpress/icons'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Placeholder from '../'; +import TextControl from '../../text-control'; + +const ICONS = { starEmpty, starFilled, styles, wordpress }; + +const meta: Meta< typeof Placeholder > = { + component: Placeholder, + title: 'Components/Placeholder', + argTypes: { + children: { control: { type: null } }, + notices: { control: { type: null } }, + preview: { control: { type: null } }, + icon: { + control: { type: 'select' }, + options: Object.keys( ICONS ), + mapping: ICONS, + }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Placeholder > = ( args ) => { + const [ value, setValue ] = useState( '' ); + + return ( + <Placeholder { ...args }> + <div> + <TextControl + __nextHasNoMarginBottom + label="Sample Field" + placeholder="Enter something here" + value={ value } + onChange={ setValue } + /> + </div> + </Placeholder> + ); +}; + +export const Default: StoryFn< typeof Placeholder > = Template.bind( {} ); +Default.args = { + icon: 'wordpress', + label: 'My Placeholder Label', + instructions: 'Here are instructions you should follow', +}; diff --git a/packages/components/src/placeholder/stories/index.tsx b/packages/components/src/placeholder/stories/index.tsx deleted file mode 100644 index 3796c8efd1e60b..00000000000000 --- a/packages/components/src/placeholder/stories/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { starEmpty, starFilled, styles, wordpress } from '@wordpress/icons'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Placeholder from '../'; -import TextControl from '../../text-control'; - -const ICONS = { starEmpty, starFilled, styles, wordpress }; - -const meta: ComponentMeta< typeof Placeholder > = { - component: Placeholder, - title: 'Components/Placeholder', - argTypes: { - children: { control: { type: null } }, - notices: { control: { type: null } }, - preview: { control: { type: null } }, - icon: { - control: { type: 'select' }, - options: Object.keys( ICONS ), - mapping: ICONS, - }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Placeholder > = ( args ) => { - const [ value, setValue ] = useState( '' ); - - return ( - <Placeholder { ...args }> - <div> - <TextControl - __nextHasNoMarginBottom - label="Sample Field" - placeholder="Enter something here" - value={ value } - onChange={ setValue } - /> - </div> - </Placeholder> - ); -}; - -export const Default: ComponentStory< typeof Placeholder > = Template.bind( - {} -); -Default.args = { - icon: 'wordpress', - label: 'My Placeholder Label', - instructions: 'Here are instructions you should follow', -}; diff --git a/packages/components/src/placeholder/style.scss b/packages/components/src/placeholder/style.scss index 7fd9977e4645dc..df06969852fdae 100644 --- a/packages/components/src/placeholder/style.scss +++ b/packages/components/src/placeholder/style.scss @@ -161,6 +161,11 @@ padding: 0 $grid-unit-10 2px; } } + .components-placeholder__learn-more { + .components-external-link { + color: var(--wp-admin-theme-color); + } + } } diff --git a/packages/components/src/popover/README.md b/packages/components/src/popover/README.md index ad04de226e90d3..709254672be8b2 100644 --- a/packages/components/src/popover/README.md +++ b/packages/components/src/popover/README.md @@ -6,7 +6,7 @@ The behavior of the popover when it exceeds the viewport's edges can be controll ## Usage -Render a Popover within the parent to which it should anchor. +Render a Popover adjacent to its container. If a Popover is returned by your component, it will be shown. To hide the popover, simply omit it from your component's render value. @@ -60,7 +60,7 @@ const MyPopover = () => { }; ``` -If you want Popover elements to render to a specific location on the page to allow style cascade to take effect, you must render a `Popover.Slot` further up the element tree: +By default Popovers render at the end of the body of your document. If you want Popover elements to render to a specific location on the page, you must render a `Popover.Slot` further up the element tree: ```jsx import { render } from '@wordpress/element'; diff --git a/packages/components/src/popover/index.tsx b/packages/components/src/popover/index.tsx index e1afb10035c306..69c107cffda8d6 100644 --- a/packages/components/src/popover/index.tsx +++ b/packages/components/src/popover/index.tsx @@ -7,20 +7,16 @@ import { useFloating, flip as flipMiddleware, shift as shiftMiddleware, + limitShift, autoUpdate, arrow, offset as offsetMiddleware, size, - Middleware, - MiddlewareArguments, } from '@floating-ui/react-dom'; // eslint-disable-next-line no-restricted-imports -import { - motion, - useReducedMotion, - HTMLMotionProps, - MotionProps, -} from 'framer-motion'; +import type { HTMLMotionProps, MotionProps } from 'framer-motion'; +// eslint-disable-next-line no-restricted-imports +import { motion, useReducedMotion } from 'framer-motion'; /** * WordPress dependencies @@ -34,6 +30,7 @@ import { useMemo, useState, useCallback, + createPortal, } from '@wordpress/element'; import { useViewportMatch, @@ -52,8 +49,7 @@ import Button from '../button'; import ScrollLock from '../scroll-lock'; import { Slot, Fill, useSlot } from '../slot-fill'; import { - getFrameOffset, - getFrameScale, + computePopoverPosition, positionToPlacement, placementToMotionAnimationProps, getReferenceOwnerDocument, @@ -66,7 +62,6 @@ import type { PopoverAnchorRefReference, PopoverAnchorRefTopBottom, } from './types'; -import { limitShift as customLimitShift } from './limit-shift'; import { overlayMiddlewares } from './overlay-middlewares'; /** @@ -74,7 +69,7 @@ import { overlayMiddlewares } from './overlay-middlewares'; * * @type {string} */ -const SLOT_NAME = 'Popover'; +export const SLOT_NAME = 'Popover'; // An SVG displaying a triangle facing down, filled with a solid // color and bordered in such a way to create an arrow-like effect. @@ -142,6 +137,20 @@ const AnimatedWrapper = forwardRef( const slotNameContext = createContext< string | undefined >( undefined ); +const fallbackContainerClassname = 'components-popover__fallback-container'; +const getPopoverFallbackContainer = () => { + let container = document.body.querySelector( + '.' + fallbackContainerClassname + ); + if ( ! container ) { + container = document.createElement( 'div' ); + container.className = fallbackContainerClassname; + document.body.append( container ); + } + + return container; +}; + const UnforwardedPopover = ( props: Omit< WordPressComponentProps< PopoverProps, 'div', false >, @@ -170,6 +179,7 @@ const UnforwardedPopover = ( flip = true, resize = true, shift = false, + inline = false, variant, // Deprecated props @@ -249,69 +259,34 @@ const UnforwardedPopover = ( ? positionToPlacement( position ) : placementProp; - /** - * Offsets the position of the popover when the anchor is inside an iframe. - * - * Store the offset in a ref, due to constraints with floating-ui: - * https://floating-ui.com/docs/react-dom#variables-inside-middleware-functions. - */ - const frameOffsetRef = useRef( getFrameOffset( referenceOwnerDocument ) ); - const middleware = [ ...( placementProp === 'overlay' ? overlayMiddlewares() : [] ), - // Custom middleware which adjusts the popover's position by taking into - // account the offset of the anchor's iframe (if any) compared to the page. - { - name: 'frameOffset', - fn( { x, y }: MiddlewareArguments ) { - if ( ! frameOffsetRef.current ) { - return { - x, - y, - }; - } - - return { - x: x + frameOffsetRef.current.x, - y: y + frameOffsetRef.current.y, - data: { - // This will be used in the customLimitShift() function. - amount: frameOffsetRef.current, - }, - }; - }, - }, offsetMiddleware( offsetProp ), - computedFlipProp ? flipMiddleware() : undefined, - computedResizeProp - ? size( { - apply( sizeProps ) { - const { firstElementChild } = - refs.floating.current ?? {}; - - // Only HTMLElement instances have the `style` property. - if ( ! ( firstElementChild instanceof HTMLElement ) ) - return; - - // Reduce the height of the popover to the available space. - Object.assign( firstElementChild.style, { - maxHeight: `${ sizeProps.availableHeight }px`, - overflow: 'auto', - } ); - }, - } ) - : undefined, - shift - ? shiftMiddleware( { - crossAxis: true, - limiter: customLimitShift(), - padding: 1, // Necessary to avoid flickering at the edge of the viewport. - } ) - : undefined, + computedFlipProp && flipMiddleware(), + computedResizeProp && + size( { + apply( sizeProps ) { + const { firstElementChild } = refs.floating.current ?? {}; + + // Only HTMLElement instances have the `style` property. + if ( ! ( firstElementChild instanceof HTMLElement ) ) + return; + + // Reduce the height of the popover to the available space. + Object.assign( firstElementChild.style, { + maxHeight: `${ sizeProps.availableHeight }px`, + overflow: 'auto', + } ); + }, + } ), + shift && + shiftMiddleware( { + crossAxis: true, + limiter: limitShift(), + padding: 1, // Necessary to avoid flickering at the edge of the viewport. + } ), arrow( { element: arrowRef } ), - ].filter( - ( m: Middleware | undefined ): m is Middleware => m !== undefined - ); + ]; const slotName = useContext( slotNameContext ) || __unstableSlotName; const slot = useSlot( slotName ); @@ -340,10 +315,6 @@ const UnforwardedPopover = ( // Positioning coordinates x, y, - // Callback refs (not regular refs). This allows the position to be updated. - // when either elements change. - reference: referenceCallbackRef, - floating, // Object with "regular" refs to both "reference" and "floating" refs, // Type of CSS position property to use (absolute or fixed) @@ -359,6 +330,7 @@ const UnforwardedPopover = ( middleware, whileElementsMounted: ( referenceParam, floatingParam, updateParam ) => autoUpdate( referenceParam, floatingParam, updateParam, { + layoutShift: false, animationFrame: true, } ), } ); @@ -393,17 +365,16 @@ const UnforwardedPopover = ( fallbackReferenceElement, fallbackDocument: document, } ); - const scale = getFrameScale( resultingReferenceOwnerDoc ); + const resultingReferenceElement = getReferenceElement( { anchor, anchorRef, anchorRect, getAnchorRect, fallbackReferenceElement, - scale, } ); - referenceCallbackRef( resultingReferenceElement ); + refs.setReference( resultingReferenceElement ); setReferenceOwnerDocument( resultingReferenceOwnerDoc ); }, [ @@ -416,23 +387,17 @@ const UnforwardedPopover = ( anchorRect, getAnchorRect, fallbackReferenceElement, - referenceCallbackRef, + refs, ] ); // If the reference element is in a different ownerDocument (e.g. iFrame), // we need to manually update the floating's position as the reference's owner - // document scrolls. Also update the frame offset if the view resizes. + // document scrolls. useLayoutEffect( () => { if ( - // Reference and root documents are the same. - referenceOwnerDocument === document || - // Reference and floating are in the same document. - referenceOwnerDocument === refs.floating.current?.ownerDocument || - // The reference's document has no view (i.e. window) - // or frame element (ie. it's not an iframe). - ! referenceOwnerDocument?.defaultView?.frameElement + ! referenceOwnerDocument || + ! referenceOwnerDocument.defaultView ) { - frameOffsetRef.current = undefined; return; } @@ -443,23 +408,17 @@ const UnforwardedPopover = ( ? getScrollContainer( frameElement ) : null; - const updateFrameOffset = () => { - frameOffsetRef.current = getFrameOffset( referenceOwnerDocument ); - update(); - }; - defaultView.addEventListener( 'resize', updateFrameOffset ); - scrollContainer?.addEventListener( 'scroll', updateFrameOffset ); - - updateFrameOffset(); + defaultView.addEventListener( 'resize', update ); + scrollContainer?.addEventListener( 'scroll', update ); return () => { - defaultView.removeEventListener( 'resize', updateFrameOffset ); - scrollContainer?.removeEventListener( 'scroll', updateFrameOffset ); + defaultView.removeEventListener( 'resize', update ); + scrollContainer?.removeEventListener( 'scroll', update ); }; - }, [ referenceOwnerDocument, update, refs.floating ] ); + }, [ referenceOwnerDocument, update ] ); const mergedFloatingRef = useMergeRefs( [ - floating, + refs.setFloating, dialogRef, forwardedRef, ] ); @@ -499,8 +458,8 @@ const UnforwardedPopover = ( // to use `translateX` and `translateY` because those values would // be overridden by the return value of the // `placementToMotionAnimationProps` function in `AnimatedWrapper` - x: Math.round( x ?? 0 ) || undefined, - y: Math.round( y ?? 0 ) || undefined, + x: computePopoverPosition( x ), + y: computePopoverPosition( y ), } } > @@ -530,18 +489,12 @@ const UnforwardedPopover = ( left: typeof arrowData?.x !== 'undefined' && Number.isFinite( arrowData.x ) - ? `${ - arrowData.x + - ( frameOffsetRef.current?.x ?? 0 ) - }px` + ? `${ arrowData.x }px` : '', top: typeof arrowData?.y !== 'undefined' && Number.isFinite( arrowData.y ) - ? `${ - arrowData.y + - ( frameOffsetRef.current?.y ?? 0 ) - }px` + ? `${ arrowData.y }px` : '', } } > @@ -551,15 +504,25 @@ const UnforwardedPopover = ( </AnimatedWrapper> ); - if ( slot.ref ) { + const shouldRenderWithinSlot = slot.ref && ! inline; + const hasAnchor = anchorRef || anchorRect || anchor; + + if ( shouldRenderWithinSlot ) { content = <Fill name={ slotName }>{ content }</Fill>; + } else if ( ! inline ) { + content = createPortal( content, getPopoverFallbackContainer() ); } - if ( anchorRef || anchorRect || anchor ) { + if ( hasAnchor ) { return content; } - return <span ref={ anchorRefFallback }>{ content }</span>; + return ( + <> + <span ref={ anchorRefFallback } /> + { content } + </> + ); }; /** diff --git a/packages/components/src/popover/limit-shift.ts b/packages/components/src/popover/limit-shift.ts deleted file mode 100644 index 45e65a0b619098..00000000000000 --- a/packages/components/src/popover/limit-shift.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * External dependencies - */ -import type { - Axis, - Coords, - Placement, - Side, - MiddlewareArguments, -} from '@floating-ui/react-dom'; - -/** - * Parts of this source were derived and modified from `floating-ui`, - * released under the MIT license. - * - * https://github.com/floating-ui/floating-ui - * - * Copyright (c) 2021 Floating UI contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -/** - * Custom limiter function for the `shift` middleware. - * This function is mostly identical default `limitShift` from ``@floating-ui`; - * the only difference is that, when computing the min/max shift limits, it - * also takes into account the iframe offset that is added by the - * custom "frameOffset" middleware. - * - * All unexported types and functions are also from the `@floating-ui` library, - * and have been copied to this file for convenience. - */ - -type LimitShiftOffset = - | ( ( args: MiddlewareArguments ) => - | number - | { - /** - * Offset the limiting of the axis that runs along the alignment of the - * floating element. - */ - mainAxis?: number; - /** - * Offset the limiting of the axis that runs along the side of the - * floating element. - */ - crossAxis?: number; - } ) - | number - | { - /** - * Offset the limiting of the axis that runs along the alignment of the - * floating element. - */ - mainAxis?: number; - /** - * Offset the limiting of the axis that runs along the side of the - * floating element. - */ - crossAxis?: number; - }; - -type LimitShiftOptions = { - /** - * Offset when limiting starts. `0` will limit when the opposite edges of the - * reference and floating elements are aligned. - * - positive = start limiting earlier - * - negative = start limiting later - */ - offset: LimitShiftOffset; - /** - * Whether to limit the axis that runs along the alignment of the floating - * element. - */ - mainAxis: boolean; - /** - * Whether to limit the axis that runs along the side of the floating element. - */ - crossAxis: boolean; -}; - -function getSide( placement: Placement ): Side { - return placement.split( '-' )[ 0 ] as Side; -} - -function getMainAxisFromPlacement( placement: Placement ): Axis { - return [ 'top', 'bottom' ].includes( getSide( placement ) ) ? 'x' : 'y'; -} - -function getCrossAxis( axis: Axis ): Axis { - return axis === 'x' ? 'y' : 'x'; -} - -export const limitShift = ( - options: Partial< LimitShiftOptions > = {} -): { - options: Partial< LimitShiftOffset >; - fn: ( middlewareArguments: MiddlewareArguments ) => Coords; -} => ( { - options, - fn( middlewareArguments ) { - const { x, y, placement, rects, middlewareData } = middlewareArguments; - const { - offset = 0, - mainAxis: checkMainAxis = true, - crossAxis: checkCrossAxis = true, - } = options; - - const coords = { x, y }; - const mainAxis = getMainAxisFromPlacement( placement ); - const crossAxis = getCrossAxis( mainAxis ); - - let mainAxisCoord = coords[ mainAxis ]; - let crossAxisCoord = coords[ crossAxis ]; - - const rawOffset = - typeof offset === 'function' - ? offset( middlewareArguments ) - : offset; - const computedOffset = - typeof rawOffset === 'number' - ? { mainAxis: rawOffset, crossAxis: 0 } - : { mainAxis: 0, crossAxis: 0, ...rawOffset }; - - // At the moment of writing, this is the only difference - // with the `limitShift` function from `@floating-ui`. - // This offset needs to be added to all min/max limits - // in order to make the shift-limiting work as expected. - const additionalFrameOffset = { - x: 0, - y: 0, - ...middlewareData.frameOffset?.amount, - }; - - if ( checkMainAxis ) { - const len = mainAxis === 'y' ? 'height' : 'width'; - const limitMin = - rects.reference[ mainAxis ] - - rects.floating[ len ] + - computedOffset.mainAxis + - additionalFrameOffset[ mainAxis ]; - const limitMax = - rects.reference[ mainAxis ] + - rects.reference[ len ] - - computedOffset.mainAxis + - additionalFrameOffset[ mainAxis ]; - - if ( mainAxisCoord < limitMin ) { - mainAxisCoord = limitMin; - } else if ( mainAxisCoord > limitMax ) { - mainAxisCoord = limitMax; - } - } - - if ( checkCrossAxis ) { - const len = mainAxis === 'y' ? 'width' : 'height'; - const isOriginSide = [ 'top', 'left' ].includes( - getSide( placement ) - ); - const limitMin = - rects.reference[ crossAxis ] - - rects.floating[ len ] + - ( isOriginSide - ? middlewareData.offset?.[ crossAxis ] ?? 0 - : 0 ) + - ( isOriginSide ? 0 : computedOffset.crossAxis ) + - additionalFrameOffset[ crossAxis ]; - const limitMax = - rects.reference[ crossAxis ] + - rects.reference[ len ] + - ( isOriginSide - ? 0 - : middlewareData.offset?.[ crossAxis ] ?? 0 ) - - ( isOriginSide ? computedOffset.crossAxis : 0 ) + - additionalFrameOffset[ crossAxis ]; - - if ( crossAxisCoord < limitMin ) { - crossAxisCoord = limitMin; - } else if ( crossAxisCoord > limitMax ) { - crossAxisCoord = limitMax; - } - } - - return { - [ mainAxis ]: mainAxisCoord, - [ crossAxis ]: crossAxisCoord, - } as Coords; - }, -} ); diff --git a/packages/components/src/popover/overlay-middlewares.tsx b/packages/components/src/popover/overlay-middlewares.tsx index da138af7fa331f..fb64d739dce3b6 100644 --- a/packages/components/src/popover/overlay-middlewares.tsx +++ b/packages/components/src/popover/overlay-middlewares.tsx @@ -1,13 +1,14 @@ /** * External dependencies */ -import { size, MiddlewareArguments } from '@floating-ui/react-dom'; +import type { MiddlewareState } from '@floating-ui/react-dom'; +import { size } from '@floating-ui/react-dom'; export function overlayMiddlewares() { return [ { name: 'overlay', - fn( { rects }: MiddlewareArguments ) { + fn( { rects }: MiddlewareState ) { return rects.reference; }, }, diff --git a/packages/components/src/popover/stories/e2e/index.tsx b/packages/components/src/popover/stories/e2e/index.story.tsx similarity index 100% rename from packages/components/src/popover/stories/e2e/index.tsx rename to packages/components/src/popover/stories/e2e/index.story.tsx diff --git a/packages/components/src/popover/stories/index.story.tsx b/packages/components/src/popover/stories/index.story.tsx new file mode 100644 index 00000000000000..7f06045ac9b057 --- /dev/null +++ b/packages/components/src/popover/stories/index.story.tsx @@ -0,0 +1,254 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState, useRef, useEffect } from '@wordpress/element'; +// @ts-expect-error The `@wordpress/block-editor` is not typed +import { __unstableIframe as Iframe } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import { Popover } from '..'; +import { PopoverInsideIframeRenderedInExternalSlot } from '../test/utils'; +import type { PopoverProps } from '../types'; + +const AVAILABLE_PLACEMENTS: PopoverProps[ 'placement' ][] = [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'overlay', +]; + +const meta: Meta< typeof Popover > = { + title: 'Components/Popover', + component: Popover, + argTypes: { + anchor: { control: { type: null } }, + anchorRef: { control: { type: null } }, + anchorRect: { control: { type: null } }, + children: { control: { type: null } }, + focusOnMount: { + control: { type: 'select' }, + options: [ 'firstElement', true, false ], + }, + getAnchorRect: { control: { type: null } }, + onClose: { action: 'onClose' }, + onFocusOutside: { action: 'onFocusOutside' }, + __unstableSlotName: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + }, +}; + +export default meta; + +const PopoverWithAnchor = ( args: PopoverProps ) => { + const anchorRef = useRef( null ); + + return ( + <div + style={ { + height: '200px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + } } + > + <p + style={ { padding: '8px', background: 'salmon' } } + ref={ anchorRef } + > + Popover&apos;s anchor + </p> + <Popover { ...args } anchorRef={ anchorRef } /> + </div> + ); +}; + +const Template: StoryFn< typeof Popover > = ( args ) => { + const [ isVisible, setIsVisible ] = useState( false ); + const toggleVisible = () => { + setIsVisible( ( state ) => ! state ); + }; + const buttonRef = useRef< HTMLButtonElement | undefined >(); + useEffect( () => { + buttonRef.current?.scrollIntoView?.( { + block: 'center', + inline: 'center', + } ); + }, [] ); + + return ( + <div + style={ { + width: '300vw', + height: '300vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } } + > + <Button + variant="secondary" + onClick={ toggleVisible } + ref={ buttonRef } + > + Toggle Popover + { isVisible && <Popover { ...args } /> } + </Button> + </div> + ); +}; + +export const Default: StoryFn< typeof Popover > = Template.bind( {} ); +Default.args = { + children: ( + <div style={ { width: '280px', whiteSpace: 'normal' } }> + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim + ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. + </div> + ), +}; + +export const Unstyled: StoryFn< typeof Popover > = Template.bind( {} ); +Unstyled.args = { + children: ( + <div style={ { width: '280px', whiteSpace: 'normal' } }> + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim + ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. + </div> + ), + variant: 'unstyled', +}; + +export const AllPlacements: StoryFn< typeof Popover > = ( { + children, + ...args +} ) => ( + <div + style={ { + minWidth: '600px', + marginLeft: 'auto', + marginRight: 'auto', + } } + > + <h2> + Resize / scroll the viewport to test the behavior of the popovers + when they reach the viewport boundaries. + </h2> + <div> + { AVAILABLE_PLACEMENTS.map( ( p ) => ( + <PopoverWithAnchor + key={ p } + placement={ p } + { ...args } + resize={ p === 'overlay' ? true : args.resize } + > + { children } + <div> + <small>(placement: { p })</small> + </div> + </PopoverWithAnchor> + ) ) } + </div> + </div> +); +// Excluding placement and position since they all possible values +// are passed directly in code. +AllPlacements.parameters = { + controls: { + exclude: [ 'placement', 'position' ], + }, +}; +AllPlacements.args = { + ...Default.args, + children: ( + <div style={ { width: '280px', whiteSpace: 'normal' } }> + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. + </div> + ), + noArrow: false, + offset: 10, + resize: false, + flip: false, +}; + +export const DynamicHeight: StoryFn< typeof Popover > = ( { + children, + ...args +} ) => { + const [ height, setHeight ] = useState( 200 ); + const increase = () => setHeight( height + 100 ); + const decrease = () => setHeight( height - 100 ); + + return ( + <div style={ { padding: '20px' } }> + <div> + <Button + variant="primary" + onClick={ increase } + style={ { + marginRight: '20px', + } } + > + Increase Size + </Button> + + <Button variant="primary" onClick={ decrease }> + Decrease Size + </Button> + </div> + + <p> + When the height of the popover exceeds the available space in + the canvas, a scrollbar inside the popover should appear. + </p> + + <div> + <Popover { ...args }> + <div + style={ { + height, + background: '#eee', + padding: '20px', + } } + > + { children } + </div> + </Popover> + </div> + </div> + ); +}; +DynamicHeight.args = { + ...Default.args, + children: 'Content with dynamic height', +}; + +export const WithSlotOutsideIframe: StoryFn< typeof Popover > = ( args ) => { + return <PopoverInsideIframeRenderedInExternalSlot { ...args } />; +}; +WithSlotOutsideIframe.args = { + ...Default.args, +}; diff --git a/packages/components/src/popover/stories/index.tsx b/packages/components/src/popover/stories/index.tsx deleted file mode 100644 index 15723becf12f37..00000000000000 --- a/packages/components/src/popover/stories/index.tsx +++ /dev/null @@ -1,313 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory, ComponentMeta } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState, useRef, useEffect } from '@wordpress/element'; -// @ts-expect-error The `@wordpress/block-editor` is not typed -import { __unstableIframe as Iframe } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import { Provider as SlotFillProvider } from '../../slot-fill'; -import { Popover } from '..'; -import type { PopoverProps } from '../types'; - -const AVAILABLE_PLACEMENTS: PopoverProps[ 'placement' ][] = [ - 'top', - 'top-start', - 'top-end', - 'right', - 'right-start', - 'right-end', - 'bottom', - 'bottom-start', - 'bottom-end', - 'left', - 'left-start', - 'left-end', - 'overlay', -]; - -const meta: ComponentMeta< typeof Popover > = { - title: 'Components/Popover', - component: Popover, - argTypes: { - anchor: { control: { type: null } }, - anchorRef: { control: { type: null } }, - anchorRect: { control: { type: null } }, - children: { control: { type: null } }, - focusOnMount: { - control: { type: 'select' }, - options: [ 'firstElement', true, false ], - }, - getAnchorRect: { control: { type: null } }, - onClose: { action: 'onClose' }, - onFocusOutside: { action: 'onFocusOutside' }, - __unstableSlotName: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - }, -}; - -export default meta; - -const PopoverWithAnchor = ( args: PopoverProps ) => { - const anchorRef = useRef( null ); - - return ( - <div - style={ { - height: '200px', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - } } - > - <p - style={ { padding: '8px', background: 'salmon' } } - ref={ anchorRef } - > - Popover&apos;s anchor - </p> - <Popover { ...args } anchorRef={ anchorRef } /> - </div> - ); -}; - -const Template: ComponentStory< typeof Popover > = ( args ) => { - const [ isVisible, setIsVisible ] = useState( false ); - const toggleVisible = () => { - setIsVisible( ( state ) => ! state ); - }; - const buttonRef = useRef< HTMLButtonElement | undefined >(); - useEffect( () => { - buttonRef.current?.scrollIntoView?.( { - block: 'center', - inline: 'center', - } ); - }, [] ); - - return ( - <div - style={ { - width: '300vw', - height: '300vh', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - } } - > - <Button - variant="secondary" - onClick={ toggleVisible } - ref={ buttonRef } - > - Toggle Popover - { isVisible && <Popover { ...args } /> } - </Button> - </div> - ); -}; - -export const Default: ComponentStory< typeof Popover > = Template.bind( {} ); -Default.args = { - children: ( - <div style={ { width: '280px', whiteSpace: 'normal' } }> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim - ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. - </div> - ), -}; - -export const Toolbar: ComponentStory< typeof Popover > = Template.bind( {} ); -Toolbar.args = { - children: ( - <div style={ { width: '280px', whiteSpace: 'normal' } }> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim - ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. - </div> - ), - variant: 'toolbar', -}; - -export const Unstyled: ComponentStory< typeof Popover > = Template.bind( {} ); -Unstyled.args = { - children: ( - <div style={ { width: '280px', whiteSpace: 'normal' } }> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim - ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. - </div> - ), - variant: 'unstyled', -}; - -export const AllPlacements: ComponentStory< typeof Popover > = ( { - children, - ...args -} ) => ( - <div - style={ { - minWidth: '600px', - marginLeft: 'auto', - marginRight: 'auto', - } } - > - <h2> - Resize / scroll the viewport to test the behavior of the popovers - when they reach the viewport boundaries. - </h2> - <div> - { AVAILABLE_PLACEMENTS.map( ( p ) => ( - <PopoverWithAnchor - key={ p } - placement={ p } - { ...args } - resize={ p === 'overlay' ? true : args.resize } - > - { children } - <div> - <small>(placement: { p })</small> - </div> - </PopoverWithAnchor> - ) ) } - </div> - </div> -); -// Excluding placement and position since they all possible values -// are passed directly in code. -AllPlacements.parameters = { - controls: { - exclude: [ 'placement', 'position' ], - }, -}; -AllPlacements.args = { - ...Default.args, - children: ( - <div style={ { width: '280px', whiteSpace: 'normal' } }> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. - </div> - ), - noArrow: false, - offset: 10, - resize: false, - flip: false, -}; - -export const DynamicHeight: ComponentStory< typeof Popover > = ( { - children, - ...args -} ) => { - const [ height, setHeight ] = useState( 200 ); - const increase = () => setHeight( height + 100 ); - const decrease = () => setHeight( height - 100 ); - - return ( - <div style={ { padding: '20px' } }> - <div> - <Button - variant="primary" - onClick={ increase } - style={ { - marginRight: '20px', - } } - > - Increase Size - </Button> - - <Button variant="primary" onClick={ decrease }> - Decrease Size - </Button> - </div> - - <p> - When the height of the popover exceeds the available space in - the canvas, a scrollbar inside the popover should appear. - </p> - - <div> - <Popover { ...args }> - <div - style={ { - height, - background: '#eee', - padding: '20px', - } } - > - { children } - </div> - </Popover> - </div> - </div> - ); -}; -DynamicHeight.args = { - ...Default.args, - children: 'Content with dynamic height', -}; - -export const WithSlotOutsideIframe: ComponentStory< typeof Popover > = ( - args -) => { - const anchorRef = useRef( null ); - const slotName = 'popover-with-slot-outside-iframe'; - - return ( - <SlotFillProvider> - <div> - { /* @ts-expect-error Slot is not currently typed on Popover */ } - <Popover.Slot name={ slotName } /> - <Iframe - style={ { - width: '100%', - height: '400px', - border: '0', - outline: '1px solid purple', - } } - > - <div - style={ { - height: '200vh', - paddingTop: '10vh', - } } - > - <p - style={ { - padding: '8px', - background: 'salmon', - maxWidth: '200px', - marginTop: '100px', - marginLeft: 'auto', - marginRight: 'auto', - } } - ref={ anchorRef } - > - Popover&apos;s anchor - </p> - <Popover - { ...args } - __unstableSlotName={ slotName } - anchorRef={ anchorRef } - /> - </div> - </Iframe> - </div> - </SlotFillProvider> - ); -}; -WithSlotOutsideIframe.args = { - ...Default.args, -}; diff --git a/packages/components/src/popover/test/index.tsx b/packages/components/src/popover/test/index.tsx index 256b89415cfae9..fa96c74cffbf3f 100644 --- a/packages/components/src/popover/test/index.tsx +++ b/packages/components/src/popover/test/index.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, getByText } from '@testing-library/react'; import type { CSSProperties } from 'react'; /** @@ -12,9 +12,14 @@ import { useState } from '@wordpress/element'; /** * Internal dependencies */ -import { positionToPlacement, placementToMotionAnimationProps } from '../utils'; +import { + computePopoverPosition, + positionToPlacement, + placementToMotionAnimationProps, +} from '../utils'; import Popover from '..'; import type { PopoverProps } from '../types'; +import { PopoverInsideIframeRenderedInExternalSlot } from './utils'; type PositionToPlacementTuple = [ NonNullable< PopoverProps[ 'position' ] >, @@ -107,6 +112,20 @@ describe( 'Popover', () => { expect( screen.getByRole( 'tooltip' ) ).toBeVisible() ); } ); + + it( 'should render inline regardless of slot name', async () => { + const { container } = render( + <Popover inline __unstableSlotName="Popover"> + Hello + </Popover> + ); + + await waitFor( () => + // We want to explicitly check if it's within the container. + // eslint-disable-next-line testing-library/prefer-screen-queries + expect( getByText( container, 'Hello' ) ).toBeVisible() + ); + } ); } ); describe( 'anchor', () => { @@ -171,6 +190,19 @@ describe( 'Popover', () => { } ); } ); + describe( 'Slot outside iframe', () => { + it( 'should support cross-document rendering', async () => { + render( + <PopoverInsideIframeRenderedInExternalSlot> + <span>content</span> + </PopoverInsideIframeRenderedInExternalSlot> + ); + await waitFor( async () => + expect( screen.getByText( 'content' ) ).toBeVisible() + ); + } ); + } ); + describe( 'positionToPlacement', () => { it.each( ALL_POSITIONS_TO_EXPECTED_PLACEMENTS )( 'converts `%s` to `%s`', @@ -248,4 +280,21 @@ describe( 'Popover', () => { ); } ); } ); + + describe( 'computePopoverPosition', () => { + it.each( [ + [ 14, 14 ], // valid integers shouldn't be changes + [ 14.02, 14 ], // floating numbers are parsed to integers + [ 0, 0 ], // zero remains zero + [ null, undefined ], + [ NaN, undefined ], + ] )( + 'converts `%s` to `%s`', + ( inputCoordinate, expectedCoordinated ) => { + expect( computePopoverPosition( inputCoordinate ) ).toEqual( + expectedCoordinated + ); + } + ); + } ); } ); diff --git a/packages/components/src/popover/test/utils/index.tsx b/packages/components/src/popover/test/utils/index.tsx new file mode 100644 index 00000000000000..53af9af21b838a --- /dev/null +++ b/packages/components/src/popover/test/utils/index.tsx @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +import { createPortal, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Popover from '../..'; +import { Provider as SlotFillProvider } from '../../../slot-fill'; +import type { WordPressComponentProps } from '../../../ui/context'; + +const GenericIframe = ( { + children, + ...props +}: WordPressComponentProps< { children: React.ReactNode }, 'iframe' > ) => { + const [ containerNode, setContainerNode ] = useState< HTMLElement >(); + + return ( + <iframe + { ...props } + title="My Iframe" + srcDoc="<!doctype html><html><body></body></html>" + // Waiting for the load event ensures that this works in Firefox. + // See https://github.com/facebook/react/issues/22847#issuecomment-991394558 + onLoad={ ( event ) => { + if ( event.currentTarget.contentDocument ) { + setContainerNode( + event.currentTarget.contentDocument.body + ); + } + } } + > + { containerNode && createPortal( children, containerNode ) } + </iframe> + ); +}; + +export const PopoverInsideIframeRenderedInExternalSlot = ( + props: React.ComponentProps< typeof Popover > +) => { + const SLOT_NAME = 'my-slot'; + const [ anchorRef, setAnchorRef ] = useState< HTMLParagraphElement | null >( + null + ); + + return ( + <SlotFillProvider> + { /* @ts-expect-error Slot is not currently typed on Popover */ } + <Popover.Slot name={ SLOT_NAME } /> + <GenericIframe + style={ { + width: '100%', + height: '400px', + border: '0', + outline: '1px solid purple', + } } + > + <div + style={ { + height: '200vh', + paddingTop: '10vh', + } } + > + <p + style={ { + padding: '8px', + background: 'salmon', + maxWidth: '200px', + marginTop: '100px', + marginLeft: 'auto', + marginRight: 'auto', + } } + ref={ setAnchorRef } + > + Popover&apos;s anchor + </p> + <Popover + { ...props } + __unstableSlotName={ SLOT_NAME } + anchor={ anchorRef } + /> + </div> + </GenericIframe> + </SlotFillProvider> + ); +}; diff --git a/packages/components/src/popover/types.ts b/packages/components/src/popover/types.ts index 6dc3a4ae7d53f6..350801e3a5b8c9 100644 --- a/packages/components/src/popover/types.ts +++ b/packages/components/src/popover/types.ts @@ -150,6 +150,12 @@ export type PopoverProps = { * @default undefined */ variant?: 'unstyled' | 'toolbar'; + /** + * Whether to render the popover inline or within the slot. + * + * @default false + */ + inline?: boolean; // Deprecated props /** * Prevent the popover from flipping and resizing when meeting the viewport diff --git a/packages/components/src/popover/utils.ts b/packages/components/src/popover/utils.ts index 022e7796fe37de..5833a65816c6d1 100644 --- a/packages/components/src/popover/utils.ts +++ b/packages/components/src/popover/utils.ts @@ -3,7 +3,7 @@ */ // eslint-disable-next-line no-restricted-imports import type { MotionProps } from 'framer-motion'; -import type { ReferenceType } from '@floating-ui/react-dom'; +import type { ReferenceType, VirtualElement } from '@floating-ui/react-dom'; /** * Internal dependencies @@ -139,42 +139,6 @@ export const placementToMotionAnimationProps = ( }; }; -/** - * Returns the offset of a document's frame element. - * - * @param document The iframe's owner document. - * - * @return The offset of the document's frame element, or undefined if the - * document has no frame element. - */ -export const getFrameOffset = ( - document?: Document -): { x: number; y: number } | undefined => { - const frameElement = document?.defaultView?.frameElement; - if ( ! frameElement ) { - return; - } - const iframeRect = frameElement.getBoundingClientRect(); - return { x: iframeRect.left, y: iframeRect.top }; -}; - -export const getFrameScale = ( - document?: Document -): { - x: number; - y: number; -} => { - const frameElement = document?.defaultView?.frameElement as HTMLElement; - if ( ! frameElement ) { - return { x: 1, y: 1 }; - } - const rect = frameElement.getBoundingClientRect(); - return { - x: rect.width / frameElement.offsetWidth, - y: rect.height / frameElement.offsetHeight, - }; -}; - export const getReferenceOwnerDocument = ( { anchor, anchorRef, @@ -197,7 +161,10 @@ export const getReferenceOwnerDocument = ( { // with the `getBoundingClientRect()` function (like real elements). // See https://floating-ui.com/docs/virtual-elements for more info. let resultingReferenceOwnerDoc; - if ( anchor ) { + if ( ( anchor as VirtualElement )?.contextElement ) { + resultingReferenceOwnerDoc = ( anchor as VirtualElement ).contextElement + ?.ownerDocument; + } else if ( anchor ) { resultingReferenceOwnerDoc = anchor.ownerDocument; } else if ( ( anchorRef as PopoverAnchorRefTopBottom | undefined )?.top ) { resultingReferenceOwnerDoc = ( anchorRef as PopoverAnchorRefTopBottom ) @@ -231,13 +198,11 @@ export const getReferenceElement = ( { anchorRect, getAnchorRect, fallbackReferenceElement, - scale, }: Pick< PopoverProps, 'anchorRef' | 'anchorRect' | 'getAnchorRect' | 'anchor' > & { fallbackReferenceElement: Element | null; - scale: { x: number; y: number }; } ): ReferenceType | null => { let referenceElement = null; @@ -299,22 +264,18 @@ export const getReferenceElement = ( { referenceElement = fallbackReferenceElement.parentElement; } - if ( referenceElement && ( scale.x !== 1 || scale.y !== 1 ) ) { - // If the popover is inside an iframe, the coordinates of the - // reference element need to be scaled to match the iframe's scale. - const rect = referenceElement.getBoundingClientRect(); - referenceElement = { - getBoundingClientRect() { - return new window.DOMRect( - rect.x * scale.x, - rect.y * scale.y, - rect.width * scale.x, - rect.height * scale.y - ); - }, - }; - } - // Convert any `undefined` value to `null`. return referenceElement ?? null; }; + +/** + * Computes the final coordinate that needs to be applied to the floating + * element when applying transform inline styles, defaulting to `undefined` + * if the provided value is `null` or `NaN`. + * + * @param c input coordinate (usually as returned from floating-ui) + * @return The coordinate's value to be used for inline styles. An `undefined` + * return value means "no style set" for this coordinate. + */ +export const computePopoverPosition = ( c: number | null ) => + c === null || Number.isNaN( c ) ? undefined : Math.round( c ); diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index 3d94ac4a44ea2d..6e17abde0c627e 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -8,6 +8,7 @@ import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/pri */ import { default as CustomSelectControl } from './custom-select-control'; import { positionToPlacement as __experimentalPopoverLegacyPositionToPlacement } from './popover/utils'; +import { default as ProgressBar } from './progress-bar'; import { createPrivateSlotFill } from './slot-fill'; import { DropdownMenu as DropdownMenuV2, @@ -21,6 +22,8 @@ import { DropdownSubMenu as DropdownSubMenuV2, DropdownSubMenuTrigger as DropdownSubMenuTriggerV2, } from './dropdown-menu-v2'; +import { ComponentsContext } from './ui/context/context-system-provider'; +import Theme from './theme'; export const { lock, unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( @@ -33,6 +36,7 @@ lock( privateApis, { CustomSelectControl, __experimentalPopoverLegacyPositionToPlacement, createPrivateSlotFill, + ComponentsContext, DropdownMenuV2, DropdownMenuCheckboxItemV2, DropdownMenuGroupV2, @@ -43,4 +47,6 @@ lock( privateApis, { DropdownMenuSeparatorV2, DropdownSubMenuV2, DropdownSubMenuTriggerV2, + ProgressBar, + Theme, } ); diff --git a/packages/components/src/progress-bar/README.md b/packages/components/src/progress-bar/README.md new file mode 100644 index 00000000000000..d0f62ce7e3be57 --- /dev/null +++ b/packages/components/src/progress-bar/README.md @@ -0,0 +1,30 @@ +# ProgressBar + +<div class="callout callout-alert"> +This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +</div> + +A simple horizontal progress bar component. + +Supports two modes: determinate and indeterminate. A progress bar is determinate when a specific progress value has been specified (from 0 to 100), and indeterminate when a value hasn't been specified. + +### Props + +The component accepts the following props: + +#### `value`: `number` + +The progress value, a number from 0 to 100. +If a `value` is not specified, the progress bar will be considered indeterminate. + +- Required: No + +##### `className`: `string` + +A CSS class to apply to the underlying `div` element, serving as a progress bar track. + +- Required: No + +#### Inherited props + +Any additional props will be passed the underlying `<progress/>` element. diff --git a/packages/components/src/progress-bar/index.tsx b/packages/components/src/progress-bar/index.tsx new file mode 100644 index 00000000000000..16f2630953840a --- /dev/null +++ b/packages/components/src/progress-bar/index.tsx @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as ProgressBarStyled from './styles'; +import type { ProgressBarProps } from './types'; +import type { WordPressComponentProps } from '../ui/context'; + +function UnforwardedProgressBar( + props: WordPressComponentProps< ProgressBarProps, 'progress', false >, + ref: ForwardedRef< HTMLProgressElement > +) { + const { className, value, ...progressProps } = props; + const isIndeterminate = ! Number.isFinite( value ); + + return ( + <ProgressBarStyled.Track className={ className }> + <ProgressBarStyled.Indicator + isIndeterminate={ isIndeterminate } + value={ value } + /> + <ProgressBarStyled.ProgressElement + max={ 100 } + value={ value } + aria-label={ __( 'Loading …' ) } + ref={ ref } + { ...progressProps } + /> + </ProgressBarStyled.Track> + ); +} + +export const ProgressBar = forwardRef( UnforwardedProgressBar ); + +export default ProgressBar; diff --git a/packages/components/src/progress-bar/stories/index.story.tsx b/packages/components/src/progress-bar/stories/index.story.tsx new file mode 100644 index 00000000000000..e3d217ffd3f415 --- /dev/null +++ b/packages/components/src/progress-bar/stories/index.story.tsx @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { ProgressBar } from '..'; + +const meta: Meta< typeof ProgressBar > = { + component: ProgressBar, + title: 'Components (Experimental)/ProgressBar', + argTypes: { + value: { control: { type: 'number', min: 0, max: 100, step: 1 } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ProgressBar > = ( { ...args } ) => { + return <ProgressBar { ...args } />; +}; + +export const Default: StoryFn< typeof ProgressBar > = Template.bind( {} ); +Default.args = {}; diff --git a/packages/components/src/progress-bar/styles.ts b/packages/components/src/progress-bar/styles.ts new file mode 100644 index 00000000000000..e983797d3d92bc --- /dev/null +++ b/packages/components/src/progress-bar/styles.ts @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; +import { css, keyframes } from '@emotion/react'; + +/** + * Internal dependencies + */ +import { COLORS, CONFIG } from '../utils'; + +const animateProgressBar = keyframes( { + '0%': { + left: '-50%', + }, + '100%': { + left: '100%', + }, +} ); + +// Width of the indicator for the indeterminate progress bar +export const INDETERMINATE_TRACK_WIDTH = 50; + +export const Track = styled.div` + position: relative; + overflow: hidden; + width: 100%; + max-width: 160px; + height: ${ CONFIG.borderWidthFocus }; + background-color: var( + --wp-components-color-gray-300, + ${ COLORS.gray[ 300 ] } + ); + border-radius: ${ CONFIG.radiusBlockUi }; +`; + +export const Indicator = styled.div< { + isIndeterminate: boolean; + value?: number; +} >` + display: inline-block; + position: absolute; + top: 0; + height: 100%; + border-radius: ${ CONFIG.radiusBlockUi }; + background-color: ${ COLORS.theme.accent }; + + ${ ( { isIndeterminate, value } ) => + isIndeterminate + ? css( { + animationDuration: '1.5s', + animationTimingFunction: 'ease-in-out', + animationIterationCount: 'infinite', + animationName: animateProgressBar, + width: `${ INDETERMINATE_TRACK_WIDTH }%`, + } ) + : css( { + width: `${ value }%`, + transition: 'width 0.4s ease-in-out', + } ) }; +`; + +export const ProgressElement = styled.progress` + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; +`; diff --git a/packages/components/src/progress-bar/test/index.tsx b/packages/components/src/progress-bar/test/index.tsx new file mode 100644 index 00000000000000..425c54ce8a3022 --- /dev/null +++ b/packages/components/src/progress-bar/test/index.tsx @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { ProgressBar } from '..'; +import { INDETERMINATE_TRACK_WIDTH } from '../styles'; + +describe( 'ProgressBar', () => { + it( 'should render an indeterminate semantic progress bar element', () => { + render( <ProgressBar /> ); + + const progressBar = screen.getByRole( 'progressbar' ); + + expect( progressBar ).toBeInTheDocument(); + expect( progressBar ).not.toBeVisible(); + expect( progressBar ).not.toHaveValue(); + } ); + + it( 'should render a determinate semantic progress bar element', () => { + render( <ProgressBar value={ 55 } /> ); + + const progressBar = screen.getByRole( 'progressbar' ); + + expect( progressBar ).toBeInTheDocument(); + expect( progressBar ).not.toBeVisible(); + expect( progressBar ).toHaveValue( 55 ); + } ); + + it( 'should use `INDETERMINATE_TRACK_WIDTH`% as track width for indeterminate progress bar', () => { + const { container } = render( <ProgressBar /> ); + + /** + * We're intentionally not using an accessible selector, because + * the track is an intentionally non-interactive presentation element. + */ + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const indicator = container.firstChild?.firstChild; + + expect( indicator ).toHaveStyle( { + width: `${ INDETERMINATE_TRACK_WIDTH }%`, + } ); + } ); + + it( 'should use `value`% as width for determinate progress bar', () => { + const { container } = render( <ProgressBar value={ 55 } /> ); + + /** + * We're intentionally not using an accessible selector, because + * the track is an intentionally non-interactive presentation element. + */ + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const indicator = container.firstChild?.firstChild; + + expect( indicator ).toHaveStyle( { + width: '55%', + } ); + } ); + + it( 'should pass any additional props down to the underlying `progress` element', () => { + const id = 'foo-bar-123'; + const ariaLabel = 'in progress...'; + const style = { opacity: 1 }; + + render( + <ProgressBar id={ id } aria-label={ ariaLabel } style={ style } /> + ); + + expect( screen.getByRole( 'progressbar' ) ).toHaveAttribute( 'id', id ); + expect( screen.getByRole( 'progressbar' ) ).toHaveAttribute( + 'aria-label', + ariaLabel + ); + expect( screen.getByRole( 'progressbar' ) ).toHaveStyle( style ); + } ); +} ); diff --git a/packages/components/src/progress-bar/types.ts b/packages/components/src/progress-bar/types.ts new file mode 100644 index 00000000000000..9beb28317e58aa --- /dev/null +++ b/packages/components/src/progress-bar/types.ts @@ -0,0 +1,11 @@ +export type ProgressBarProps = { + /** + * Value of the progress bar. + */ + value?: number; + + /** + * A CSS class to apply to the progress bar wrapper (track) element. + */ + className?: string; +}; diff --git a/packages/components/src/query-controls/index.native.js b/packages/components/src/query-controls/index.native.js index 6ba18f646146e6..51ba7cba690be5 100644 --- a/packages/components/src/query-controls/index.native.js +++ b/packages/components/src/query-controls/index.native.js @@ -84,6 +84,7 @@ const QueryControls = memo( ) } { onNumberOfItemsChange && ( <RangeControl + __next40pxDefaultSize label={ __( 'Number of items' ) } value={ numberOfItems } onChange={ onNumberOfItemsChange } diff --git a/packages/components/src/query-controls/index.tsx b/packages/components/src/query-controls/index.tsx index f325347d10c393..6c3c6ba952a062 100644 --- a/packages/components/src/query-controls/index.tsx +++ b/packages/components/src/query-controls/index.tsx @@ -177,6 +177,7 @@ export function QueryControls( { onNumberOfItemsChange && ( <RangeControl __nextHasNoMarginBottom + __next40pxDefaultSize key="query-controls-range-control" label={ __( 'Number of items' ) } value={ numberOfItems } diff --git a/packages/components/src/query-controls/stories/index.story.tsx b/packages/components/src/query-controls/stories/index.story.tsx new file mode 100644 index 00000000000000..04fe185a59eac1 --- /dev/null +++ b/packages/components/src/query-controls/stories/index.story.tsx @@ -0,0 +1,202 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import QueryControls from '..'; +import type { + Category, + QueryControlsWithSingleCategorySelectionProps, + QueryControlsWithMultipleCategorySelectionProps, +} from '../types'; + +const meta: Meta< typeof QueryControls > = { + title: 'Components/QueryControls', + component: QueryControls, + argTypes: { + numberOfItems: { control: { type: null } }, + order: { control: { type: null } }, + orderBy: { control: { type: null } }, + selectedAuthorId: { control: { type: null } }, + selectedCategories: { control: { type: null } }, + selectedCategoryId: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof QueryControls > = ( args ) => { + const { + onAuthorChange, + onCategoryChange, + onNumberOfItemsChange, + onOrderByChange, + onOrderChange, + ...props + } = args as QueryControlsWithMultipleCategorySelectionProps; + const [ ownNumberOfItems, setOwnNumberOfItems ] = useState( + props.numberOfItems + ); + const [ ownOrder, setOwnOrder ] = useState( props.order ); + const [ ownOrderBy, setOwnOrderBy ] = useState( props.orderBy ); + const [ ownSelectedAuthorId, setOwnSelectedAuthorId ] = useState( + props.selectedAuthorId + ); + const [ ownSelectedCategories, setOwnSelectedCategories ] = useState( + props.selectedCategories + ); + + const handleCategoryChange: QueryControlsWithMultipleCategorySelectionProps[ 'onCategoryChange' ] = + ( tokens ) => { + onCategoryChange?.( tokens ); + + const hasNoSuggestion = tokens.some( + ( token ) => + typeof token === 'string' && + ! props.categorySuggestions?.[ token ] + ); + if ( hasNoSuggestion ) { + return; + } + const allCategories = tokens + .map( ( token ) => { + return typeof token === 'string' + ? props.categorySuggestions?.[ token ] + : token; + } ) + .filter( Boolean ) as Array< Required< Category > >; + + setOwnSelectedCategories( allCategories ); + }; + + return ( + <QueryControls + { ...props } + numberOfItems={ ownNumberOfItems } + onCategoryChange={ handleCategoryChange } + onOrderByChange={ ( newOrderBy ) => { + onOrderByChange?.( newOrderBy ); + setOwnOrderBy( newOrderBy ); + } } + onOrderChange={ ( newOrder ) => { + onOrderChange?.( newOrder ); + setOwnOrder( newOrder ); + } } + order={ ownOrder } + orderBy={ ownOrderBy } + onNumberOfItemsChange={ ( newNumber ) => { + onNumberOfItemsChange?.( newNumber ); + setOwnNumberOfItems( newNumber ); + } } + onAuthorChange={ ( newAuthor ) => { + onAuthorChange?.( newAuthor ); + setOwnSelectedAuthorId( Number( newAuthor ) ); + } } + selectedAuthorId={ ownSelectedAuthorId } + selectedCategories={ ownSelectedCategories } + /> + ); +}; + +Default.args = { + authorList: [ + { + id: 1, + name: 'admin', + }, + { + id: 2, + name: 'editor', + }, + ], + categorySuggestions: { + TypeScript: { + id: 11, + name: 'TypeScript', + parent: 0, + }, + JavaScript: { + id: 12, + name: 'JavaScript', + parent: 0, + }, + }, + selectedCategories: [ + { + id: 11, + name: 'JavaScript', + parent: 0, + }, + ], + numberOfItems: 5, + order: 'desc', + orderBy: 'date', + selectedAuthorId: 1, +}; + +const SingleCategoryTemplate: StoryFn< typeof QueryControls > = ( args ) => { + const { + onAuthorChange, + onCategoryChange, + onNumberOfItemsChange, + onOrderByChange, + onOrderChange, + ...props + } = args as QueryControlsWithSingleCategorySelectionProps; + const [ ownOrder, setOwnOrder ] = useState( props.order ); + const [ ownOrderBy, setOwnOrderBy ] = useState( props.orderBy ); + const [ ownSelectedCategoryId, setSelectedCategoryId ] = useState( + props.selectedCategoryId + ); + + const handleCategoryChange: QueryControlsWithSingleCategorySelectionProps[ 'onCategoryChange' ] = + ( newCategory ) => { + onCategoryChange?.( newCategory ); + setSelectedCategoryId( Number( newCategory ) ); + }; + + return ( + <QueryControls + { ...props } + onCategoryChange={ handleCategoryChange } + onOrderByChange={ ( newOrderBy ) => { + setOwnOrderBy( newOrderBy ); + } } + onOrderChange={ ( newOrder ) => { + onOrderChange?.( newOrder ); + setOwnOrder( newOrder ); + } } + order={ ownOrder } + orderBy={ ownOrderBy } + selectedCategoryId={ ownSelectedCategoryId } + /> + ); +}; +export const SelectSingleCategory = SingleCategoryTemplate.bind( {} ); +SelectSingleCategory.args = { + categoriesList: [ + { + id: 11, + name: 'TypeScript', + parent: 0, + }, + { + id: 12, + name: 'JavaScript', + parent: 0, + }, + ], + selectedCategoryId: 11, +}; diff --git a/packages/components/src/query-controls/stories/index.tsx b/packages/components/src/query-controls/stories/index.tsx deleted file mode 100644 index d7fa5e50a4531a..00000000000000 --- a/packages/components/src/query-controls/stories/index.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import QueryControls from '..'; -import type { - Category, - QueryControlsWithSingleCategorySelectionProps, - QueryControlsWithMultipleCategorySelectionProps, -} from '../types'; - -const meta: ComponentMeta< typeof QueryControls > = { - title: 'Components/QueryControls', - component: QueryControls, - argTypes: { - numberOfItems: { control: { type: null } }, - order: { control: { type: null } }, - orderBy: { control: { type: null } }, - selectedAuthorId: { control: { type: null } }, - selectedCategories: { control: { type: null } }, - selectedCategoryId: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof QueryControls > = ( args ) => { - const { - onAuthorChange, - onCategoryChange, - onNumberOfItemsChange, - onOrderByChange, - onOrderChange, - ...props - } = args as QueryControlsWithMultipleCategorySelectionProps; - const [ ownNumberOfItems, setOwnNumberOfItems ] = useState( - props.numberOfItems - ); - const [ ownOrder, setOwnOrder ] = useState( props.order ); - const [ ownOrderBy, setOwnOrderBy ] = useState( props.orderBy ); - const [ ownSelectedAuthorId, setOwnSelectedAuthorId ] = useState( - props.selectedAuthorId - ); - const [ ownSelectedCategories, setOwnSelectedCategories ] = useState( - props.selectedCategories - ); - - const handleCategoryChange: QueryControlsWithMultipleCategorySelectionProps[ 'onCategoryChange' ] = - ( tokens ) => { - onCategoryChange?.( tokens ); - - const hasNoSuggestion = tokens.some( - ( token ) => - typeof token === 'string' && - ! props.categorySuggestions?.[ token ] - ); - if ( hasNoSuggestion ) { - return; - } - const allCategories = tokens - .map( ( token ) => { - return typeof token === 'string' - ? props.categorySuggestions?.[ token ] - : token; - } ) - .filter( Boolean ) as Array< Required< Category > >; - - setOwnSelectedCategories( allCategories ); - }; - - return ( - <QueryControls - { ...props } - numberOfItems={ ownNumberOfItems } - onCategoryChange={ handleCategoryChange } - onOrderByChange={ ( newOrderBy ) => { - onOrderByChange?.( newOrderBy ); - setOwnOrderBy( newOrderBy ); - } } - onOrderChange={ ( newOrder ) => { - onOrderChange?.( newOrder ); - setOwnOrder( newOrder ); - } } - order={ ownOrder } - orderBy={ ownOrderBy } - onNumberOfItemsChange={ ( newNumber ) => { - onNumberOfItemsChange?.( newNumber ); - setOwnNumberOfItems( newNumber ); - } } - onAuthorChange={ ( newAuthor ) => { - onAuthorChange?.( newAuthor ); - setOwnSelectedAuthorId( Number( newAuthor ) ); - } } - selectedAuthorId={ ownSelectedAuthorId } - selectedCategories={ ownSelectedCategories } - /> - ); -}; - -Default.args = { - authorList: [ - { - id: 1, - name: 'admin', - }, - { - id: 2, - name: 'editor', - }, - ], - categorySuggestions: { - TypeScript: { - id: 11, - name: 'TypeScript', - parent: 0, - }, - JavaScript: { - id: 12, - name: 'JavaScript', - parent: 0, - }, - }, - selectedCategories: [ - { - id: 11, - name: 'JavaScript', - parent: 0, - }, - ], - numberOfItems: 5, - order: 'desc', - orderBy: 'date', - selectedAuthorId: 1, -}; - -const SingleCategoryTemplate: ComponentStory< typeof QueryControls > = ( - args -) => { - const { - onAuthorChange, - onCategoryChange, - onNumberOfItemsChange, - onOrderByChange, - onOrderChange, - ...props - } = args as QueryControlsWithSingleCategorySelectionProps; - const [ ownOrder, setOwnOrder ] = useState( props.order ); - const [ ownOrderBy, setOwnOrderBy ] = useState( props.orderBy ); - const [ ownSelectedCategoryId, setSelectedCategoryId ] = useState( - props.selectedCategoryId - ); - - const handleCategoryChange: QueryControlsWithSingleCategorySelectionProps[ 'onCategoryChange' ] = - ( newCategory ) => { - onCategoryChange?.( newCategory ); - setSelectedCategoryId( Number( newCategory ) ); - }; - - return ( - <QueryControls - { ...props } - onCategoryChange={ handleCategoryChange } - onOrderByChange={ ( newOrderBy ) => { - setOwnOrderBy( newOrderBy ); - } } - onOrderChange={ ( newOrder ) => { - onOrderChange?.( newOrder ); - setOwnOrder( newOrder ); - } } - order={ ownOrder } - orderBy={ ownOrderBy } - selectedCategoryId={ ownSelectedCategoryId } - /> - ); -}; -export const SelectSingleCategory: ComponentStory< typeof QueryControls > = - SingleCategoryTemplate.bind( {} ); -SelectSingleCategory.args = { - categoriesList: [ - { - id: 11, - name: 'TypeScript', - parent: 0, - }, - { - id: 12, - name: 'JavaScript', - parent: 0, - }, - ], - selectedCategoryId: 11, -}; diff --git a/packages/components/src/radio-control/stories/index.story.tsx b/packages/components/src/radio-control/stories/index.story.tsx new file mode 100644 index 00000000000000..7be398e77e17c7 --- /dev/null +++ b/packages/components/src/radio-control/stories/index.story.tsx @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import RadioControl from '..'; + +const meta: Meta< typeof RadioControl > = { + component: RadioControl, + title: 'Components/RadioControl', + argTypes: { + onChange: { + action: 'onChange', + }, + selected: { + control: { type: null }, + }, + label: { + control: { type: 'text' }, + }, + help: { + control: { type: 'text' }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof RadioControl > = ( { + onChange, + options, + ...args +} ) => { + const [ value, setValue ] = useState( options?.[ 0 ]?.value ); + + return ( + <RadioControl + { ...args } + selected={ value } + options={ options } + onChange={ ( v ) => { + setValue( v ); + onChange( v ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof RadioControl > = Template.bind( {} ); +Default.args = { + label: 'Post visibility', + options: [ + { label: 'Public', value: 'public' }, + { label: 'Private', value: 'private' }, + { label: 'Password Protected', value: 'password' }, + ], +}; diff --git a/packages/components/src/radio-control/stories/index.tsx b/packages/components/src/radio-control/stories/index.tsx deleted file mode 100644 index bf4bbcca24ea34..00000000000000 --- a/packages/components/src/radio-control/stories/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import RadioControl from '..'; - -const meta: ComponentMeta< typeof RadioControl > = { - component: RadioControl, - title: 'Components/RadioControl', - argTypes: { - onChange: { - action: 'onChange', - }, - selected: { - control: { type: null }, - }, - label: { - control: { type: 'text' }, - }, - help: { - control: { type: 'text' }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof RadioControl > = ( { - onChange, - options, - ...args -} ) => { - const [ value, setValue ] = useState( options?.[ 0 ]?.value ); - - return ( - <RadioControl - { ...args } - selected={ value } - options={ options } - onChange={ ( v ) => { - setValue( v ); - onChange( v ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof RadioControl > = Template.bind( - {} -); -Default.args = { - label: 'Post visibility', - options: [ - { label: 'Public', value: 'public' }, - { label: 'Private', value: 'private' }, - { label: 'Password Protected', value: 'password' }, - ], -}; diff --git a/packages/components/src/radio-group/stories/index.js b/packages/components/src/radio-group/stories/index.js deleted file mode 100644 index 10fb5dfd028000..00000000000000 --- a/packages/components/src/radio-group/stories/index.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Radio from '../radio'; -import RadioGroup from '../'; - -export default { - title: 'Components (Deprecated)/RadioGroup', - subcomponents: { Radio }, - component: RadioGroup, - parameters: { - docs: { - description: { - component: - 'This component is deprecated. Use `RadioControl` or `ToggleGroupControl` instead.', - }, - }, - }, -}; - -export const _default = () => { - /* eslint-disable no-restricted-syntax */ - return ( - <RadioGroup - // id is required for server side rendering - id="default-radiogroup" - label="options" - defaultChecked="option2" - > - <Radio value="option1">Option 1</Radio> - <Radio value="option2">Option 2</Radio> - <Radio value="option3">Option 3</Radio> - </RadioGroup> - ); - /* eslint-enable no-restricted-syntax */ -}; - -export const disabled = () => { - /* eslint-disable no-restricted-syntax */ - return ( - <RadioGroup - // id is required for server side rendering - id="disabled-radiogroup" - disabled - label="options" - defaultChecked="option2" - > - <Radio value="option1">Option 1</Radio> - <Radio value="option2">Option 2</Radio> - <Radio value="option3">Option 3</Radio> - </RadioGroup> - ); - /* eslint-enable no-restricted-syntax */ -}; - -const ControlledRadioGroupWithState = () => { - const [ checked, setChecked ] = useState( 1 ); - - /* eslint-disable no-restricted-syntax */ - return ( - <RadioGroup - // id is required for server side rendering - id="controlled-radiogroup" - label="options" - checked={ checked } - onChange={ setChecked } - > - <Radio value={ 0 }>Option 1</Radio> - <Radio value={ 1 }>Option 2</Radio> - <Radio value={ 2 }>Option 3</Radio> - </RadioGroup> - ); - /* eslint-enable no-restricted-syntax */ -}; - -export const controlled = () => { - return <ControlledRadioGroupWithState />; -}; diff --git a/packages/components/src/radio-group/stories/index.story.js b/packages/components/src/radio-group/stories/index.story.js new file mode 100644 index 00000000000000..58125bf808be2b --- /dev/null +++ b/packages/components/src/radio-group/stories/index.story.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Radio from '../radio'; +import RadioGroup from '../'; + +export default { + title: 'Components (Deprecated)/RadioGroup', + component: RadioGroup, + subcomponents: { Radio }, + parameters: { + docs: { + description: { + component: + 'This component is deprecated. Use `RadioControl` or `ToggleGroupControl` instead.', + }, + }, + }, +}; + +export const _default = () => { + /* eslint-disable no-restricted-syntax */ + return ( + <RadioGroup + // id is required for server side rendering + id="default-radiogroup" + label="options" + defaultChecked="option2" + > + <Radio value="option1">Option 1</Radio> + <Radio value="option2">Option 2</Radio> + <Radio value="option3">Option 3</Radio> + </RadioGroup> + ); + /* eslint-enable no-restricted-syntax */ +}; + +export const Disabled = () => { + /* eslint-disable no-restricted-syntax */ + return ( + <RadioGroup + // id is required for server side rendering + id="disabled-radiogroup" + disabled + label="options" + defaultChecked="option2" + > + <Radio value="option1">Option 1</Radio> + <Radio value="option2">Option 2</Radio> + <Radio value="option3">Option 3</Radio> + </RadioGroup> + ); + /* eslint-enable no-restricted-syntax */ +}; + +const ControlledRadioGroupWithState = () => { + const [ checked, setChecked ] = useState( 1 ); + + /* eslint-disable no-restricted-syntax */ + return ( + <RadioGroup + // id is required for server side rendering + id="controlled-radiogroup" + label="options" + checked={ checked } + onChange={ setChecked } + > + <Radio value={ 0 }>Option 1</Radio> + <Radio value={ 1 }>Option 2</Radio> + <Radio value={ 2 }>Option 3</Radio> + </RadioGroup> + ); + /* eslint-enable no-restricted-syntax */ +}; + +export const Controlled = () => { + return <ControlledRadioGroupWithState />; +}; diff --git a/packages/components/src/range-control/index.tsx b/packages/components/src/range-control/index.tsx index 928595132e2540..fbf472b66d38fd 100644 --- a/packages/components/src/range-control/index.tsx +++ b/packages/components/src/range-control/index.tsx @@ -37,6 +37,7 @@ import { import type { RangeControlProps } from './types'; import type { WordPressComponentProps } from '../ui/context'; +import { space } from '../ui/utils/space'; const noop = () => {}; @@ -50,7 +51,7 @@ function UnforwardedRangeControl( allowReset = false, beforeIcon, className, - color: colorProp = COLORS.ui.theme, + color: colorProp = COLORS.theme.accent, currentInput, disabled = false, help, @@ -69,6 +70,7 @@ function UnforwardedRangeControl( railColor, renderTooltipContent = ( v ) => v, resetFallbackValue, + __next40pxDefaultSize = false, shiftStep = 10, showTooltip: showTooltipProp, step = 1, @@ -208,7 +210,6 @@ function UnforwardedRangeControl( const offsetStyle = { [ isRTL() ? 'right' : 'left' ]: fillValueOffset, }; - return ( <BaseControl __nextHasNoMarginBottom={ __nextHasNoMarginBottom } @@ -218,7 +219,10 @@ function UnforwardedRangeControl( id={ `${ id }` } help={ help } > - <Root className="components-range-control__root"> + <Root + className="components-range-control__root" + __next40pxDefaultSize={ __next40pxDefaultSize } + > { beforeIcon && ( <BeforeIconWrapper> <Icon icon={ beforeIcon } /> @@ -305,6 +309,14 @@ function UnforwardedRangeControl( onBlur={ handleOnInputNumberBlur } onChange={ handleOnChange } shiftStep={ shiftStep } + size={ + __next40pxDefaultSize + ? '__unstable-large' + : 'default' + } + __unstableInputWidth={ + __next40pxDefaultSize ? space( 20 ) : space( 16 ) + } step={ step } // @ts-expect-error TODO: Investigate if the `null` value is necessary value={ inputSliderValue } diff --git a/packages/components/src/range-control/input-range.tsx b/packages/components/src/range-control/input-range.tsx index 3b663309918ae1..1daa351444686a 100644 --- a/packages/components/src/range-control/input-range.tsx +++ b/packages/components/src/range-control/input-range.tsx @@ -16,7 +16,6 @@ function InputRange( ref: React.ForwardedRef< HTMLInputElement > ) { const { describedBy, label, value, ...otherProps } = props; - return ( <BaseInputRange { ...otherProps } diff --git a/packages/components/src/range-control/stories/index.story.tsx b/packages/components/src/range-control/stories/index.story.tsx new file mode 100644 index 00000000000000..4e0c1f19e078b6 --- /dev/null +++ b/packages/components/src/range-control/stories/index.story.tsx @@ -0,0 +1,239 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { starEmpty, starFilled, styles, wordpress } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import RangeControl from '..'; + +const ICONS = { starEmpty, starFilled, styles, wordpress }; + +const meta: Meta< typeof RangeControl > = { + component: RangeControl, + title: 'Components/RangeControl', + argTypes: { + afterIcon: { + control: { type: 'select' }, + options: Object.keys( ICONS ), + mapping: ICONS, + }, + beforeIcon: { + control: { type: 'select' }, + options: Object.keys( ICONS ), + mapping: ICONS, + }, + color: { control: { type: 'color' } }, + help: { control: { type: 'text' } }, + icon: { control: { type: null } }, + marks: { control: { type: 'object' } }, + onBlur: { control: { type: null } }, + onChange: { control: { type: null } }, + onFocus: { control: { type: null } }, + onMouseLeave: { control: { type: null } }, + onMouseMove: { control: { type: null } }, + railColor: { control: { type: 'color' } }, + step: { control: { type: 'number' } }, + trackColor: { control: { type: 'color' } }, + type: { control: { type: 'check' }, options: [ 'stepper' ] }, + value: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof RangeControl > = ( { onChange, ...args } ) => { + const [ value, setValue ] = useState< number >(); + + return ( + <RangeControl + { ...args } + value={ value } + onChange={ ( v ) => { + setValue( v ); + onChange?.( v ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof RangeControl > = Template.bind( {} ); +Default.args = { + help: 'Please select how transparent you would like this.', + initialPosition: 50, + label: 'Opacity', + max: 100, + min: 0, +}; + +/** + * Setting the `step` prop to `"any"` will allow users to select non-integer + * values. This also overrides both `withInputField` and `showTooltip` props to + * `false`. + */ +export const WithAnyStep: StoryFn< typeof RangeControl > = ( { + onChange, + ...args +} ) => { + const [ value, setValue ] = useState< number >(); + + return ( + <> + <RangeControl + { ...args } + value={ value } + onChange={ ( v ) => { + setValue( v ); + onChange?.( v ); + } } + /> + <hr style={ { marginTop: '5em' } } /> + <p>Current value: { value }</p> + </> + ); +}; +WithAnyStep.args = { + label: 'Brightness', + step: 'any', +}; + +const MarkTemplate: StoryFn< typeof RangeControl > = ( { + label, + onChange, + ...args +} ) => { + const [ automaticValue, setAutomaticValue ] = useState< number >(); + const [ customValue, setCustomValue ] = useState< number >(); + + return ( + <> + <h2>{ label }</h2> + <RangeControl + { ...args } + label="Automatic marks" + marks + onChange={ ( v ) => { + setAutomaticValue( v ); + onChange?.( v ); + } } + value={ automaticValue } + /> + <RangeControl + { ...args } + label="Custom marks" + onChange={ ( v ) => { + setCustomValue( v ); + onChange?.( v ); + } } + value={ customValue } + /> + </> + ); +}; + +const marksBase = [ + { value: 0, label: '0' }, + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 8, label: '8' }, + { value: 10, label: '10' }, +]; + +const marksWithNegatives = [ + ...marksBase, + { value: -1, label: '-1' }, + { value: -2, label: '-2' }, + { value: -4, label: '-4' }, + { value: -8, label: '-8' }, +]; + +/** + * Use `marks` to render a visual representation of `step` ticks. Marks may be + * automatically generated or custom mark indicators can be provided by an + * `Array`. + */ +export const WithIntegerStepAndMarks: StoryFn< typeof RangeControl > = + MarkTemplate.bind( {} ); + +WithIntegerStepAndMarks.args = { + label: 'Integer Step', + marks: marksBase, + max: 10, + min: 0, + step: 1, +}; + +/** + * Decimal values may be used for `marks` rendered as a visual representation of + * `step` ticks. Marks may be automatically generated or custom mark indicators + * can be provided by an `Array`. + */ +export const WithDecimalStepAndMarks: StoryFn< typeof RangeControl > = + MarkTemplate.bind( {} ); + +WithDecimalStepAndMarks.args = { + marks: [ + ...marksBase, + { value: 3.5, label: '3.5' }, + { value: 5.8, label: '5.8' }, + ], + max: 10, + min: 0, + step: 0.1, +}; + +/** + * A negative `min` value can be used to constrain `RangeControl` values. Mark + * indicators can represent negative values as well. Marks may be automatically + * generated or custom mark indicators can be provided by an `Array`. + */ +export const WithNegativeMinimumAndMarks: StoryFn< typeof RangeControl > = + MarkTemplate.bind( {} ); + +WithNegativeMinimumAndMarks.args = { + marks: marksWithNegatives, + max: 10, + min: -10, + step: 1, +}; + +/** + * The entire range of valid values for a `RangeControl` may be negative. Mark + * indicators can represent negative values as well. Marks may be automatically + * generated or custom mark indicators can be provided by an `Array`. + */ +export const WithNegativeRangeAndMarks: StoryFn< typeof RangeControl > = + MarkTemplate.bind( {} ); + +WithNegativeRangeAndMarks.args = { + marks: marksWithNegatives, + max: -1, + min: -10, + step: 1, +}; + +/** + * When a `RangeControl` has a `step` value of `any` a user may select + * non-integer values. This may still be used in conjunction with `marks` + * rendering a visual representation of `step` ticks. + */ +export const WithAnyStepAndMarks: StoryFn< typeof RangeControl > = + MarkTemplate.bind( {} ); + +WithAnyStepAndMarks.args = { + marks: marksBase, + max: 10, + min: 0, + step: 'any', +}; diff --git a/packages/components/src/range-control/stories/index.tsx b/packages/components/src/range-control/stories/index.tsx deleted file mode 100644 index 587231c03f3d25..00000000000000 --- a/packages/components/src/range-control/stories/index.tsx +++ /dev/null @@ -1,245 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -import { starEmpty, starFilled, styles, wordpress } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import RangeControl from '..'; - -const ICONS = { starEmpty, starFilled, styles, wordpress }; - -const meta: ComponentMeta< typeof RangeControl > = { - component: RangeControl, - title: 'Components/RangeControl', - argTypes: { - afterIcon: { - control: { type: 'select' }, - options: Object.keys( ICONS ), - mapping: ICONS, - }, - beforeIcon: { - control: { type: 'select' }, - options: Object.keys( ICONS ), - mapping: ICONS, - }, - color: { control: { type: 'color' } }, - help: { control: { type: 'text' } }, - icon: { control: { type: null } }, - marks: { control: { type: 'object' } }, - onBlur: { control: { type: null } }, - onChange: { control: { type: null } }, - onFocus: { control: { type: null } }, - onMouseLeave: { control: { type: null } }, - onMouseMove: { control: { type: null } }, - railColor: { control: { type: 'color' } }, - step: { control: { type: 'number' } }, - trackColor: { control: { type: 'color' } }, - type: { control: { type: 'check' }, options: [ 'stepper' ] }, - value: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof RangeControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState< number >(); - - return ( - <RangeControl - { ...args } - value={ value } - onChange={ ( v ) => { - setValue( v ); - onChange?.( v ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof RangeControl > = Template.bind( - {} -); -Default.args = { - help: 'Please select how transparent you would like this.', - initialPosition: 50, - label: 'Opacity', - max: 100, - min: 0, -}; - -/** - * Setting the `step` prop to `"any"` will allow users to select non-integer - * values. This also overrides both `withInputField` and `showTooltip` props to - * `false`. - */ -export const WithAnyStep: ComponentStory< typeof RangeControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState< number >(); - - return ( - <> - <RangeControl - { ...args } - value={ value } - onChange={ ( v ) => { - setValue( v ); - onChange?.( v ); - } } - /> - <hr style={ { marginTop: '5em' } } /> - <p>Current value: { value }</p> - </> - ); -}; -WithAnyStep.args = { - label: 'Brightness', - step: 'any', -}; - -const MarkTemplate: ComponentStory< typeof RangeControl > = ( { - label, - onChange, - ...args -} ) => { - const [ automaticValue, setAutomaticValue ] = useState< number >(); - const [ customValue, setCustomValue ] = useState< number >(); - - return ( - <> - <h2>{ label }</h2> - <RangeControl - { ...args } - label="Automatic marks" - marks - onChange={ ( v ) => { - setAutomaticValue( v ); - onChange?.( v ); - } } - value={ automaticValue } - /> - <RangeControl - { ...args } - label="Custom marks" - onChange={ ( v ) => { - setCustomValue( v ); - onChange?.( v ); - } } - value={ customValue } - /> - </> - ); -}; - -const marksBase = [ - { value: 0, label: '0' }, - { value: 1, label: '1' }, - { value: 2, label: '2' }, - { value: 8, label: '8' }, - { value: 10, label: '10' }, -]; - -const marksWithNegatives = [ - ...marksBase, - { value: -1, label: '-1' }, - { value: -2, label: '-2' }, - { value: -4, label: '-4' }, - { value: -8, label: '-8' }, -]; - -/** - * Use `marks` to render a visual representation of `step` ticks. Marks may be - * automatically generated or custom mark indicators can be provided by an - * `Array`. - */ -export const WithIntegerStepAndMarks: ComponentStory< typeof RangeControl > = - MarkTemplate.bind( {} ); - -WithIntegerStepAndMarks.args = { - label: 'Integer Step', - marks: marksBase, - max: 10, - min: 0, - step: 1, -}; - -/** - * Decimal values may be used for `marks` rendered as a visual representation of - * `step` ticks. Marks may be automatically generated or custom mark indicators - * can be provided by an `Array`. - */ -export const WithDecimalStepAndMarks: ComponentStory< typeof RangeControl > = - MarkTemplate.bind( {} ); - -WithDecimalStepAndMarks.args = { - marks: [ - ...marksBase, - { value: 3.5, label: '3.5' }, - { value: 5.8, label: '5.8' }, - ], - max: 10, - min: 0, - step: 0.1, -}; - -/** - * A negative `min` value can be used to constrain `RangeControl` values. Mark - * indicators can represent negative values as well. Marks may be automatically - * generated or custom mark indicators can be provided by an `Array`. - */ -export const WithNegativeMinimumAndMarks: ComponentStory< - typeof RangeControl -> = MarkTemplate.bind( {} ); - -WithNegativeMinimumAndMarks.args = { - marks: marksWithNegatives, - max: 10, - min: -10, - step: 1, -}; - -/** - * The entire range of valid values for a `RangeControl` may be negative. Mark - * indicators can represent negative values as well. Marks may be automatically - * generated or custom mark indicators can be provided by an `Array`. - */ -export const WithNegativeRangeAndMarks: ComponentStory< typeof RangeControl > = - MarkTemplate.bind( {} ); - -WithNegativeRangeAndMarks.args = { - marks: marksWithNegatives, - max: -1, - min: -10, - step: 1, -}; - -/** - * When a `RangeControl` has a `step` value of `any` a user may select - * non-integer values. This may still be used in conjunction with `marks` - * rendering a visual representation of `step` ticks. - */ -export const WithAnyStepAndMarks: ComponentStory< typeof RangeControl > = - MarkTemplate.bind( {} ); - -WithAnyStepAndMarks.args = { - marks: marksBase, - max: 10, - min: 0, - step: 'any', -}; diff --git a/packages/components/src/range-control/styles/range-control-styles.ts b/packages/components/src/range-control/styles/range-control-styles.ts index a351a7db26bdd4..1e4aa96bba03ba 100644 --- a/packages/components/src/range-control/styles/range-control-styles.ts +++ b/packages/components/src/range-control/styles/range-control-styles.ts @@ -18,6 +18,7 @@ import type { TooltipProps, TrackProps, WrapperProps, + RangeControlProps, } from '../types'; const rangeHeightValue = 30; @@ -26,15 +27,24 @@ const rangeHeight = () => css( { height: rangeHeightValue, minHeight: rangeHeightValue } ); const thumbSize = 12; -export const Root = styled.div` +const deprecatedHeight = ( { + __next40pxDefaultSize, +}: Pick< RangeControlProps, '__next40pxDefaultSize' > ) => + ! __next40pxDefaultSize && css( { minHeight: rangeHeightValue } ); + +type RootProps = Pick< RangeControlProps, '__next40pxDefaultSize' >; +export const Root = styled.div< RootProps >` -webkit-tap-highlight-color: transparent; - align-items: flex-start; + align-items: center; display: flex; justify-content: flex-start; padding: 0; position: relative; touch-action: none; width: 100%; + min-height: 40px; + /* TODO: remove after removing the __next40pxDefaultSize prop */ + ${ deprecatedHeight }; `; const wrapperColor = ( { color = COLORS.ui.borderFocus }: WrapperProps ) => @@ -174,7 +184,7 @@ const thumbColor = ( { disabled }: ThumbProps ) => background-color: ${ COLORS.gray[ 400 ] }; ` : css` - background-color: ${ COLORS.ui.theme }; + background-color: ${ COLORS.theme.accent }; `; export const ThumbWrapper = styled.span` @@ -205,7 +215,7 @@ const thumbFocus = ( { isFocused }: ThumbProps ) => { &::before { content: ' '; position: absolute; - background-color: ${ COLORS.ui.theme }; + background-color: ${ COLORS.theme.accent }; opacity: 0.4; border-radius: 50%; height: ${ thumbSize + 8 }px; @@ -296,7 +306,6 @@ export const InputNumber = styled( NumberControl )` display: inline-block; font-size: 13px; margin-top: 0; - width: ${ space( 16 ) } !important; input[type='number']& { ${ rangeHeight }; diff --git a/packages/components/src/range-control/types.ts b/packages/components/src/range-control/types.ts index 6d7d95a2e2bf41..a427ab4f942af6 100644 --- a/packages/components/src/range-control/types.ts +++ b/packages/components/src/range-control/types.ts @@ -105,7 +105,7 @@ export type RangeControlProps = Pick< /** * CSS color string for the `RangeControl` wrapper. * - * @default COLORS.ui.theme + * @default COLORS.theme.accent * @see /packages/components/src/utils/colors-values.js */ color?: CSSProperties[ 'color' ]; @@ -203,6 +203,12 @@ export type RangeControlProps = Pick< * @default 10 */ shiftStep?: number; + /** + * Start opting into the larger default height that will become the default size in a future version. + * + * @default false + */ + __next40pxDefaultSize?: boolean; /** * Forcing the Tooltip UI to show or hide. This is overridden to `false` * when `step` is set to the special string value `any`. diff --git a/packages/components/src/resizable-box/resize-tooltip/index.tsx b/packages/components/src/resizable-box/resize-tooltip/index.tsx index 53969a664a8635..4daef4d65d4dfd 100644 --- a/packages/components/src/resizable-box/resize-tooltip/index.tsx +++ b/packages/components/src/resizable-box/resize-tooltip/index.tsx @@ -13,7 +13,8 @@ import { forwardRef } from '@wordpress/element'; * Internal dependencies */ import Label from './label'; -import { useResizeLabel, Axis, Position, POSITIONS } from './utils'; +import type { Axis, Position } from './utils'; +import { useResizeLabel, POSITIONS } from './utils'; import { Root } from './styles/resize-tooltip.styles'; type ResizeTooltipProps = React.ComponentProps< typeof Root > & { diff --git a/packages/components/src/resizable-box/resize-tooltip/label.tsx b/packages/components/src/resizable-box/resize-tooltip/label.tsx index 8d1c91f13c8b50..36bb0db72dd2cb 100644 --- a/packages/components/src/resizable-box/resize-tooltip/label.tsx +++ b/packages/components/src/resizable-box/resize-tooltip/label.tsx @@ -12,7 +12,8 @@ import { isRTL } from '@wordpress/i18n'; /** * Internal dependencies */ -import { Position, POSITIONS } from './utils'; +import type { Position } from './utils'; +import { POSITIONS } from './utils'; import { TooltipWrapper, Tooltip, diff --git a/packages/components/src/resizable-box/stories/index.story.tsx b/packages/components/src/resizable-box/stories/index.story.tsx new file mode 100644 index 00000000000000..81852f9cb4ea43 --- /dev/null +++ b/packages/components/src/resizable-box/stories/index.story.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import ResizableBox from '..'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +const meta: Meta< typeof ResizableBox > = { + title: 'Components/ResizableBox', + component: ResizableBox, + argTypes: { + children: { control: { type: null } }, + enable: { control: 'object' }, + onResizeStop: { action: 'onResizeStop' }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ResizableBox > = ( { + onResizeStop, + ...props +} ) => { + const [ { height, width }, setAttributes ] = useState( { + height: 200, + width: 400, + } ); + + return ( + <ResizableBox + { ...props } + size={ { + height, + width, + } } + onResizeStop={ ( event, direction, elt, delta ) => { + onResizeStop?.( event, direction, elt, delta ); + setAttributes( { + height: height + delta.height, + width: width + delta.width, + } ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: ( + <div + style={ { + background: '#eee', + display: 'flex', + height: '100%', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + } } + > + Resize + </div> + ), +}; + +/** + * The `enable` prop can be used to disable resizing in specific directions. + */ +export const DisabledDirections = Template.bind( {} ); +DisabledDirections.args = { + ...Default.args, + enable: { + top: false, + right: true, + bottom: true, + left: false, + topRight: false, + bottomRight: true, + bottomLeft: false, + topLeft: false, + }, +}; diff --git a/packages/components/src/resizable-box/stories/index.tsx b/packages/components/src/resizable-box/stories/index.tsx deleted file mode 100644 index 77b98ce67bbded..00000000000000 --- a/packages/components/src/resizable-box/stories/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import ResizableBox from '..'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -const meta: ComponentMeta< typeof ResizableBox > = { - title: 'Components/ResizableBox', - component: ResizableBox, - argTypes: { - children: { control: { type: null } }, - enable: { control: 'object' }, - onResizeStop: { action: 'onResizeStop' }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ResizableBox > = ( { - onResizeStop, - ...props -} ) => { - const [ { height, width }, setAttributes ] = useState( { - height: 200, - width: 400, - } ); - - return ( - <ResizableBox - { ...props } - size={ { - height, - width, - } } - onResizeStop={ ( event, direction, elt, delta ) => { - onResizeStop?.( event, direction, elt, delta ); - setAttributes( { - height: height + delta.height, - width: width + delta.width, - } ); - } } - /> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - children: ( - <div - style={ { - background: '#eee', - display: 'flex', - height: '100%', - width: '100%', - alignItems: 'center', - justifyContent: 'center', - } } - > - Resize - </div> - ), -}; - -/** - * The `enable` prop can be used to disable resizing in specific directions. - */ -export const DisabledDirections = Template.bind( {} ); -DisabledDirections.args = { - ...Default.args, - enable: { - top: false, - right: true, - bottom: true, - left: false, - topRight: false, - bottomRight: true, - bottomLeft: false, - topLeft: false, - }, -}; diff --git a/packages/components/src/responsive-wrapper/stories/index.story.tsx b/packages/components/src/responsive-wrapper/stories/index.story.tsx new file mode 100644 index 00000000000000..cf676c3bcec801 --- /dev/null +++ b/packages/components/src/responsive-wrapper/stories/index.story.tsx @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import ResponsiveWrapper from '..'; + +const meta: Meta< typeof ResponsiveWrapper > = { + component: ResponsiveWrapper, + title: 'Components/ResponsiveWrapper', + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ResponsiveWrapper > = ( args ) => ( + <ResponsiveWrapper { ...args } /> +); + +export const Default = Template.bind( {} ); +Default.args = { + naturalWidth: 2000, + naturalHeight: 680, + children: ( + <img + src="https://s.w.org/style/images/about/WordPress-logotype-standard.png" + alt="WordPress" + /> + ), +}; + +/** + * When passing an `SVG` element as the `<ResponsiveWrapper />`'s child, make + * sure that it has the `viewbox` and the `preserveAspectRatio` set. + * + * When dealing with SVGs, it may not be possible to derive its `naturalWidth` + * and `naturalHeight` and therefore passing them as propertied to + * `<ResponsiveWrapper />`. In this case, the SVG simply keeps scaling up to fill + * its container, unless the `height` and `width` attributes are specified. + */ +export const WithSVG: StoryFn< typeof ResponsiveWrapper > = Template.bind( {} ); +WithSVG.args = { + children: ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 280 640" + preserveAspectRatio="xMinYMin meet" + width="280px" + height="640px" + > + <rect + x="0" + y="0" + width="280" + height="640" + style={ { fill: 'blue' } } + /> + <g> + <circle style={ { fill: 'red' } } cx="140" cy="160" r="60" /> + <circle style={ { fill: 'yellow' } } cx="140" cy="320" r="60" /> + <circle + style={ { fill: '#40CC40' } } + cx="140" + cy="480" + r="60" + /> + </g> + </svg> + ), +}; diff --git a/packages/components/src/responsive-wrapper/stories/index.tsx b/packages/components/src/responsive-wrapper/stories/index.tsx deleted file mode 100644 index acca93afe56bc9..00000000000000 --- a/packages/components/src/responsive-wrapper/stories/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import ResponsiveWrapper from '..'; - -const meta: ComponentMeta< typeof ResponsiveWrapper > = { - component: ResponsiveWrapper, - title: 'Components/ResponsiveWrapper', - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ResponsiveWrapper > = ( args ) => ( - <ResponsiveWrapper { ...args } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - naturalWidth: 2000, - naturalHeight: 680, - children: ( - <img - src="https://s.w.org/style/images/about/WordPress-logotype-standard.png" - alt="WordPress" - /> - ), -}; - -/** - * When passing an `SVG` element as the `<ResponsiveWrapper />`'s child, make - * sure that it has the `viewbox` and the `preserveAspectRatio` set. - * - * When dealing with SVGs, it may not be possible to derive its `naturalWidth` - * and `naturalHeight` and therefore passing them as propertied to - * `<ResponsiveWrapper />`. In this case, the SVG simply keeps scaling up to fill - * its container, unless the `height` and `width` attributes are specified. - */ -export const WithSVG: ComponentStory< typeof ResponsiveWrapper > = - Template.bind( {} ); -WithSVG.args = { - children: ( - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 280 640" - preserveAspectRatio="xMinYMin meet" - width="280px" - height="640px" - > - <rect - x="0" - y="0" - width="280" - height="640" - style={ { fill: 'blue' } } - /> - <g> - <circle style={ { fill: 'red' } } cx="140" cy="160" r="60" /> - <circle style={ { fill: 'yellow' } } cx="140" cy="320" r="60" /> - <circle - style={ { fill: '#40CC40' } } - cx="140" - cy="480" - r="60" - /> - </g> - </svg> - ), -}; diff --git a/packages/components/src/sandbox/index.tsx b/packages/components/src/sandbox/index.tsx index b914165721fadc..ecd51e1fc26643 100644 --- a/packages/components/src/sandbox/index.tsx +++ b/packages/components/src/sandbox/index.tsx @@ -254,7 +254,10 @@ function SandBox( { return () => { iframe?.removeEventListener( 'load', tryNoForceSandBox, false ); - defaultView?.addEventListener( 'message', checkMessageForResize ); + defaultView?.removeEventListener( + 'message', + checkMessageForResize + ); }; // Ignore reason: passing `exhaustive-deps` will likely involve a more detailed refactor. // See https://github.com/WordPress/gutenberg/pull/44378 diff --git a/packages/components/src/sandbox/stories/index.story.tsx b/packages/components/src/sandbox/stories/index.story.tsx new file mode 100644 index 00000000000000..efa8db621a4a63 --- /dev/null +++ b/packages/components/src/sandbox/stories/index.story.tsx @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import SandBox from '..'; + +const meta: Meta< typeof SandBox > = { + component: SandBox, + title: 'Components/SandBox', + argTypes: { + onFocus: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof SandBox > = ( args ) => <SandBox { ...args } />; + +export const Default = Template.bind( {} ); +Default.args = { + html: '<p>Arbitrary HTML content</p>', +}; diff --git a/packages/components/src/sandbox/stories/index.tsx b/packages/components/src/sandbox/stories/index.tsx deleted file mode 100644 index c5fea158cbd21d..00000000000000 --- a/packages/components/src/sandbox/stories/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import SandBox from '..'; - -const meta: ComponentMeta< typeof SandBox > = { - component: SandBox, - title: 'Components/SandBox', - argTypes: { - onFocus: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof SandBox > = ( args ) => ( - <SandBox { ...args } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - html: '<p>Arbitrary HTML content</p>', -}; diff --git a/packages/components/src/scroll-lock/stories/index.story.tsx b/packages/components/src/scroll-lock/stories/index.story.tsx new file mode 100644 index 00000000000000..8518c0c520a486 --- /dev/null +++ b/packages/components/src/scroll-lock/stories/index.story.tsx @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import ScrollLock from '..'; + +const meta: Meta< typeof ScrollLock > = { + component: ScrollLock, + title: 'Components/ScrollLock', + parameters: { + controls: { hideNoControlsWarning: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +function StripedBackground( props: { children: ReactNode } ) { + return ( + <div + style={ { + backgroundColor: '#fff', + backgroundImage: + 'linear-gradient(transparent 50%, rgba(0, 0, 0, 0.05) 50%)', + backgroundSize: '50px 50px', + height: 3000, + position: 'relative', + } } + { ...props } + /> + ); +} + +function ToggleContainer( props: { children: ReactNode } ) { + const { children } = props; + return ( + <div + style={ { + position: 'sticky', + top: 0, + padding: 40, + display: 'flex', + justifyContent: 'center', + textAlign: 'center', + } } + > + <div>{ children }</div> + </div> + ); +} + +export const Default: StoryFn< typeof ScrollLock > = () => { + const [ isScrollLocked, setScrollLocked ] = useState( false ); + const toggleLock = () => setScrollLocked( ! isScrollLocked ); + + return ( + <div style={ { height: 1000 } }> + <div + style={ { + overflow: 'auto', + height: 240, + border: '1px solid lightgray', + } } + > + <StripedBackground> + <div> + Start scrolling down. Once you scroll to the end of this + container with the stripes, the rest of the page will + continue scrolling. <code>ScrollLock</code> prevents + this &quot;scroll bleed&quot; from happening. + </div> + <ToggleContainer> + <Button variant="primary" onClick={ toggleLock }> + Toggle Scroll Lock + </Button> + { isScrollLocked && <ScrollLock /> } + <p> + Scroll locked:{ ' ' } + <strong>{ isScrollLocked ? 'Yes' : 'No' }</strong> + </p> + </ToggleContainer> + </StripedBackground> + </div> + </div> + ); +}; diff --git a/packages/components/src/scroll-lock/stories/index.tsx b/packages/components/src/scroll-lock/stories/index.tsx deleted file mode 100644 index 61f6fc166094bf..00000000000000 --- a/packages/components/src/scroll-lock/stories/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { ReactNode } from 'react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Button from '../../button'; -import ScrollLock from '..'; - -const meta: ComponentMeta< typeof ScrollLock > = { - component: ScrollLock, - title: 'Components/ScrollLock', - parameters: { - controls: { hideNoControlsWarning: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -function StripedBackground( props: { children: ReactNode } ) { - return ( - <div - style={ { - backgroundColor: '#fff', - backgroundImage: - 'linear-gradient(transparent 50%, rgba(0, 0, 0, 0.05) 50%)', - backgroundSize: '50px 50px', - height: 3000, - position: 'relative', - } } - { ...props } - /> - ); -} - -function ToggleContainer( props: { children: ReactNode } ) { - const { children } = props; - return ( - <div - style={ { - position: 'sticky', - top: 0, - padding: 40, - display: 'flex', - justifyContent: 'center', - textAlign: 'center', - } } - > - <div>{ children }</div> - </div> - ); -} - -export const Default: ComponentStory< typeof ScrollLock > = () => { - const [ isScrollLocked, setScrollLocked ] = useState( false ); - const toggleLock = () => setScrollLocked( ! isScrollLocked ); - - return ( - <div style={ { height: 1000 } }> - <div - style={ { - overflow: 'auto', - height: 240, - border: '1px solid lightgray', - } } - > - <StripedBackground> - <div> - Start scrolling down. Once you scroll to the end of this - container with the stripes, the rest of the page will - continue scrolling. <code>ScrollLock</code> prevents - this &quot;scroll bleed&quot; from happening. - </div> - <ToggleContainer> - <Button variant="primary" onClick={ toggleLock }> - Toggle Scroll Lock - </Button> - { isScrollLocked && <ScrollLock /> } - <p> - Scroll locked:{ ' ' } - <strong>{ isScrollLocked ? 'Yes' : 'No' }</strong> - </p> - </ToggleContainer> - </StripedBackground> - </div> - </div> - ); -}; diff --git a/packages/components/src/scrollable/component.tsx b/packages/components/src/scrollable/component.tsx index ffbcf8c697003a..902d315e9deee2 100644 --- a/packages/components/src/scrollable/component.tsx +++ b/packages/components/src/scrollable/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import { useScrollable } from './hook'; import type { ScrollableProps } from './types'; diff --git a/packages/components/src/scrollable/hook.ts b/packages/components/src/scrollable/hook.ts index 16e6074a1d196a..7d8002dc624dd5 100644 --- a/packages/components/src/scrollable/hook.ts +++ b/packages/components/src/scrollable/hook.ts @@ -6,7 +6,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import * as styles from './styles'; import { useCx } from '../utils/hooks/use-cx'; import type { ScrollableProps } from './types'; diff --git a/packages/components/src/scrollable/stories/index.story.tsx b/packages/components/src/scrollable/stories/index.story.tsx new file mode 100644 index 00000000000000..53d4919de3aabf --- /dev/null +++ b/packages/components/src/scrollable/stories/index.story.tsx @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { View } from '../../view'; +import { Scrollable } from '..'; + +const meta: Meta< typeof Scrollable > = { + component: Scrollable, + title: 'Components (Experimental)/Scrollable', + argTypes: { + as: { + control: { type: 'text' }, + }, + children: { + control: { type: null }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Scrollable > = ( { ...args } ) => { + const targetRef = useRef< HTMLInputElement >( null ); + + const onButtonClick = () => { + targetRef.current?.focus(); + }; + + const containerWidth = 300; + const containerHeight = 400; + + return ( + <Scrollable + style={ { height: containerHeight, width: containerWidth } } + { ...args } + > + <View + style={ { + backgroundColor: '#eee', + height: + args.scrollDirection === 'x' ? containerHeight : 1000, + width: args.scrollDirection === 'y' ? containerWidth : 1000, + position: 'relative', + } } + > + <button onClick={ onButtonClick }> + Move focus to an element out of view + </button> + <input + ref={ targetRef } + style={ { + position: 'absolute', + bottom: args.scrollDirection === 'x' ? 'initial' : 0, + right: 0, + } } + type="text" + value="Focus me" + /> + </View> + </Scrollable> + ); +}; + +export const Default: StoryFn< typeof Scrollable > = Template.bind( {} ); +Default.args = { + smoothScroll: false, + scrollDirection: 'y', +}; diff --git a/packages/components/src/scrollable/stories/index.tsx b/packages/components/src/scrollable/stories/index.tsx deleted file mode 100644 index d460dbd8e9c8cd..00000000000000 --- a/packages/components/src/scrollable/stories/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { View } from '../../view'; -import { Scrollable } from '..'; - -const meta: ComponentMeta< typeof Scrollable > = { - component: Scrollable, - title: 'Components (Experimental)/Scrollable', - argTypes: { - as: { - control: { type: 'text' }, - }, - children: { - control: { type: null }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Scrollable > = ( { ...args } ) => { - const targetRef = useRef< HTMLInputElement >( null ); - - const onButtonClick = () => { - targetRef.current?.focus(); - }; - - const containerWidth = 300; - const containerHeight = 400; - - return ( - <Scrollable - style={ { height: containerHeight, width: containerWidth } } - { ...args } - > - <View - style={ { - backgroundColor: '#eee', - height: - args.scrollDirection === 'x' ? containerHeight : 1000, - width: args.scrollDirection === 'y' ? containerWidth : 1000, - position: 'relative', - } } - > - <button onClick={ onButtonClick }> - Move focus to an element out of view - </button> - <input - ref={ targetRef } - style={ { - position: 'absolute', - bottom: args.scrollDirection === 'x' ? 'initial' : 0, - right: 0, - } } - type="text" - value="Focus me" - /> - </View> - </Scrollable> - ); -}; - -export const Default: ComponentStory< typeof Scrollable > = Template.bind( {} ); -Default.args = { - smoothScroll: false, - scrollDirection: 'y', -}; diff --git a/packages/components/src/search-control/README.md b/packages/components/src/search-control/README.md index 49d28a4e623e58..6f79d6cd9efd9a 100644 --- a/packages/components/src/search-control/README.md +++ b/packages/components/src/search-control/README.md @@ -15,6 +15,7 @@ SearchControl components let users display a search control. Render a user interface to input the name of an additional css class. ```jsx +import { __ } from '@wordpress/i18n'; import { SearchControl } from '@wordpress/components'; import { useState } from '@wordpress/element'; @@ -23,6 +24,7 @@ function MySearchControl( { className, setState } ) { return ( <SearchControl + label={ __( 'Search posts' ) } value={ searchInput } onChange={ setSearchInput } /> @@ -39,6 +41,9 @@ Props not included in this set will be applied to the input element. If this property is added, a label will be generated using label property as the content. +A label should always be provided as an accessibility best practice, even when a placeholder is defined +and `hideLabelFromVision` is `true`. + - Type: `String` - Required: Yes @@ -77,9 +82,10 @@ If this property is added, a help text will be generated using help property as - Type: `String|WPElement` - Required: No + ### hideLabelFromVision -If true, the label will only be visible to screen readers. +If true, the label will not be visible, but will be read by screen readers. Defaults to `true`. - Type: `Boolean` - Required: No diff --git a/packages/components/src/search-control/index.native.js b/packages/components/src/search-control/index.native.js index 6f640f9960a3a4..6cdabf966899f2 100644 --- a/packages/components/src/search-control/index.native.js +++ b/packages/components/src/search-control/index.native.js @@ -14,7 +14,13 @@ import { /** * WordPress dependencies */ -import { useState, useRef, useMemo, useEffect } from '@wordpress/element'; +import { + useState, + useRef, + useMemo, + useEffect, + useCallback, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Gridicons } from '@wordpress/components'; import { @@ -120,23 +126,44 @@ function SearchControl( { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isActive, isDark ] ); + const clearInput = useCallback( () => { + onChange( '' ); + }, [ onChange ] ); + + const onPress = useCallback( () => { + setIsActive( true ); + inputRef.current?.focus(); + }, [] ); + + const onFocus = useCallback( () => { + setIsActive( true ); + }, [] ); + + const onCancel = useCallback( () => { + clearTimeout( onCancelTimer.current ); + onCancelTimer.current = setTimeout( () => { + inputRef.current?.blur(); + clearInput(); + setIsActive( false ); + }, 0 ); + }, [ clearInput ] ); + + const onKeyboardDidHide = useCallback( () => { + if ( ! isIOS ) { + onCancel(); + } + }, [ isIOS, onCancel ] ); + useEffect( () => { const keyboardHideSubscription = Keyboard.addListener( 'keyboardDidHide', - () => { - if ( ! isIOS ) { - onCancel(); - } - } + onKeyboardDidHide ); return () => { clearTimeout( onCancelTimer.current ); keyboardHideSubscription.remove(); }; - // Disable reason: deferring this refactor to the native team. - // see https://github.com/WordPress/gutenberg/pull/41166 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); + }, [ onKeyboardDidHide ] ); const { 'search-control__container': containerStyle, @@ -153,18 +180,6 @@ function SearchControl( { 'search-control__right-icon': rightIconStyle, } = currentStyles; - function clearInput() { - onChange( '' ); - } - - function onCancel() { - onCancelTimer.current = setTimeout( () => { - inputRef.current.blur(); - clearInput(); - setIsActive( false ); - }, 0 ); - } - function renderLeftButton() { const button = ! isIOS && isActive ? ( @@ -234,10 +249,7 @@ function SearchControl( { return ( <TouchableOpacity style={ containerStyle } - onPress={ () => { - setIsActive( true ); - inputRef.current.focus(); - } } + onPress={ onPress } activeOpacity={ 1 } > <View style={ innerContainerStyle }> @@ -248,7 +260,7 @@ function SearchControl( { style={ formInputStyle } placeholderTextColor={ placeholderStyle?.color } onChangeText={ onChange } - onFocus={ () => setIsActive( true ) } + onFocus={ onFocus } value={ value } placeholder={ placeholder } /> diff --git a/packages/components/src/search-control/stories/index.story.tsx b/packages/components/src/search-control/stories/index.story.tsx new file mode 100644 index 00000000000000..1a6e58724ccb2d --- /dev/null +++ b/packages/components/src/search-control/stories/index.story.tsx @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SearchControl from '..'; + +const meta: Meta< typeof SearchControl > = { + title: 'Components/SearchControl', + component: SearchControl, + argTypes: { + onChange: { action: 'onChange' }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof SearchControl > = ( { + onChange, + ...props +} ) => { + const [ value, setValue ] = useState< string >(); + + return ( + <SearchControl + { ...props } + value={ value } + onChange={ ( ...changeArgs ) => { + setValue( ...changeArgs ); + onChange( ...changeArgs ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + label: 'Label Text', + help: 'Help text to explain the input.', +}; + +/** + * When an `onClose` callback is provided, the search control will render a close button + * that will trigger the given callback. + * + * Use this if you want the button to trigger your own logic to close the search field entirely, + * rather than just clearing the input value. + */ +export const WithOnClose = Template.bind( {} ); +WithOnClose.args = { + ...Default.args, +}; +WithOnClose.argTypes = { + onClose: { action: 'onClose' }, +}; diff --git a/packages/components/src/search-control/stories/index.tsx b/packages/components/src/search-control/stories/index.tsx deleted file mode 100644 index fc04b670e86eaa..00000000000000 --- a/packages/components/src/search-control/stories/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SearchControl from '..'; - -const meta: ComponentMeta< typeof SearchControl > = { - title: 'Components/SearchControl', - component: SearchControl, - argTypes: { - onChange: { action: 'onChange' }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof SearchControl > = ( { - onChange, - ...props -} ) => { - const [ value, setValue ] = useState< string >(); - - return ( - <SearchControl - { ...props } - value={ value } - onChange={ ( ...changeArgs ) => { - setValue( ...changeArgs ); - onChange( ...changeArgs ); - } } - /> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - label: 'Label Text', - help: 'Help text to explain the input.', -}; - -/** - * When an `onClose` callback is provided, the search control will render a close button - * that will trigger the given callback. - * - * Use this if you want the button to trigger your own logic to close the search field entirely, - * rather than just clearing the input value. - */ -export const WithOnClose = Template.bind( {} ); -WithOnClose.args = { - ...Default.args, -}; -WithOnClose.argTypes = { - onClose: { action: 'onClose' }, -}; diff --git a/packages/components/src/select-control/index.tsx b/packages/components/src/select-control/index.tsx index 57af9bee92b044..769e2ab4f7ebd9 100644 --- a/packages/components/src/select-control/index.tsx +++ b/packages/components/src/select-control/index.tsx @@ -135,6 +135,7 @@ function UnforwardedSelectControl( key={ key } value={ option.value } disabled={ option.disabled } + hidden={ option.hidden } > { option.label } </option> @@ -150,7 +151,7 @@ function UnforwardedSelectControl( * `SelectControl` allows users to select from a single or multiple option menu. * It functions as a wrapper around the browser's native `<select>` element. * - * @example + * ```jsx * import { SelectControl } from '@wordpress/components'; * import { useState } from '@wordpress/element'; * @@ -170,6 +171,7 @@ function UnforwardedSelectControl( * /> * ); * }; + * ``` */ export const SelectControl = forwardRef( UnforwardedSelectControl ); diff --git a/packages/components/src/select-control/stories/index.story.tsx b/packages/components/src/select-control/stories/index.story.tsx new file mode 100644 index 00000000000000..42966ef4f05f47 --- /dev/null +++ b/packages/components/src/select-control/stories/index.story.tsx @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SelectControl from '../'; + +const meta: Meta< typeof SelectControl > = { + title: 'Components/SelectControl', + component: SelectControl, + argTypes: { + help: { control: { type: 'text' } }, + label: { control: { type: 'text' } }, + prefix: { control: { type: 'text' } }, + suffix: { control: { type: 'text' } }, + value: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const SelectControlWithState: StoryFn< typeof SelectControl > = ( props ) => { + const [ selection, setSelection ] = useState< string[] >(); + + if ( props.multiple ) { + return ( + <SelectControl + { ...props } + multiple + value={ selection } + onChange={ ( value ) => { + setSelection( value ); + props.onChange?.( value ); + } } + /> + ); + } + + return ( + <SelectControl + { ...props } + multiple={ false } + value={ selection?.[ 0 ] } + onChange={ ( value ) => { + setSelection( [ value ] ); + props.onChange?.( value ); + } } + /> + ); +}; + +export const Default = SelectControlWithState.bind( {} ); +Default.args = { + options: [ + { value: '', label: 'Select an Option', disabled: true }, + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + { value: 'c', label: 'Option C' }, + ], +}; + +export const WithLabelAndHelpText = SelectControlWithState.bind( {} ); +WithLabelAndHelpText.args = { + ...Default.args, + help: 'Help text to explain the select control.', + label: 'Value', +}; + +/** + * As an alternative to the `options` prop, `optgroup`s and `options` can be + * passed in as `children` for more customizeability. + */ +export const WithCustomChildren: StoryFn< typeof SelectControl > = ( args ) => { + return ( + <SelectControlWithState { ...args }> + <option value="option-1">Option 1</option> + <option value="option-2" disabled> + Option 2 - Disabled + </option> + <optgroup label="Option Group 1"> + <option value="option-group-1-option-1"> + Option Group 1 - Option 1 + </option> + <option value="option-group-1-option-2" disabled> + Option Group 1 - Option 2 - Disabled + </option> + </optgroup> + </SelectControlWithState> + ); +}; diff --git a/packages/components/src/select-control/stories/index.tsx b/packages/components/src/select-control/stories/index.tsx deleted file mode 100644 index 8560ffe49fbabf..00000000000000 --- a/packages/components/src/select-control/stories/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SelectControl from '../'; - -const meta: ComponentMeta< typeof SelectControl > = { - title: 'Components/SelectControl', - component: SelectControl, - argTypes: { - help: { control: { type: 'text' } }, - label: { control: { type: 'text' } }, - prefix: { control: { type: 'text' } }, - suffix: { control: { type: 'text' } }, - value: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const SelectControlWithState: ComponentStory< typeof SelectControl > = ( - props -) => { - const [ selection, setSelection ] = useState< string[] >(); - - if ( props.multiple ) { - return ( - <SelectControl - { ...props } - multiple - value={ selection } - onChange={ ( value ) => { - setSelection( value ); - props.onChange?.( value ); - } } - /> - ); - } - - return ( - <SelectControl - { ...props } - multiple={ false } - value={ selection?.[ 0 ] } - onChange={ ( value ) => { - setSelection( [ value ] ); - props.onChange?.( value ); - } } - /> - ); -}; - -export const Default = SelectControlWithState.bind( {} ); -Default.args = { - options: [ - { value: '', label: 'Select an Option', disabled: true }, - { value: 'a', label: 'Option A' }, - { value: 'b', label: 'Option B' }, - { value: 'c', label: 'Option C' }, - ], -}; - -export const WithLabelAndHelpText = SelectControlWithState.bind( {} ); -WithLabelAndHelpText.args = { - ...Default.args, - help: 'Help text to explain the select control.', - label: 'Value', -}; - -/** - * As an alternative to the `options` prop, `optgroup`s and `options` can be - * passed in as `children` for more customizeability. - */ -export const WithCustomChildren: ComponentStory< typeof SelectControl > = ( - args -) => { - return ( - <SelectControlWithState { ...args }> - <option value="option-1">Option 1</option> - <option value="option-2" disabled> - Option 2 - Disabled - </option> - <optgroup label="Option Group 1"> - <option value="option-group-1-option-1"> - Option Group 1 - Option 1 - </option> - <option value="option-group-1-option-2" disabled> - Option Group 1 - Option 2 - Disabled - </option> - </optgroup> - </SelectControlWithState> - ); -}; diff --git a/packages/components/src/select-control/types.ts b/packages/components/src/select-control/types.ts index d052f81203a7ef..a5699bc7f5e04e 100644 --- a/packages/components/src/select-control/types.ts +++ b/packages/components/src/select-control/types.ts @@ -40,6 +40,12 @@ type SelectControlBaseProps = Pick< * @default false */ disabled?: boolean; + /** + * Whether or not the option should be hidden. + * + * @default false + */ + hidden?: boolean; }[]; /** * As an alternative to the `options` prop, `optgroup`s and `options` can be diff --git a/packages/components/src/shortcut/index.tsx b/packages/components/src/shortcut/index.tsx index dbdaa955384f55..5ba7c7efdfff02 100644 --- a/packages/components/src/shortcut/index.tsx +++ b/packages/components/src/shortcut/index.tsx @@ -3,6 +3,19 @@ */ import type { ShortcutProps } from './types'; +/** + * Shortcut component is used to display keyboard shortcuts, and it can be customized with a custom display and aria label if needed. + * + * ```jsx + * import { Shortcut } from '@wordpress/components'; + * + * const MyShortcut = () => { + * return ( + * <Shortcut shortcut={{ display: 'Ctrl + S', ariaLabel: 'Save' }} /> + * ); + * }; + * ``` + */ function Shortcut( props: ShortcutProps ) { const { shortcut, className } = props; diff --git a/packages/components/src/shortcut/stories/index.story.tsx b/packages/components/src/shortcut/stories/index.story.tsx new file mode 100644 index 00000000000000..27e96e3f6d1984 --- /dev/null +++ b/packages/components/src/shortcut/stories/index.story.tsx @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Shortcut from '../'; + +const meta: Meta< typeof Shortcut > = { + component: Shortcut, + title: 'Components/Shortcut', + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Shortcut > = ( props ) => { + return <Shortcut shortcut="Ctrl + S" { ...props } />; +}; + +export const Default: StoryFn< typeof Shortcut > = Template.bind( {} ); + +export const WithAriaLabel = Template.bind( {} ); +WithAriaLabel.args = { + ...Default.args, + shortcut: { display: 'Ctrl + L', ariaLabel: 'Load' }, +}; diff --git a/packages/components/src/slot-fill/README.md b/packages/components/src/slot-fill/README.md index 5c7455f6a555e8..a04416bdee50d9 100644 --- a/packages/components/src/slot-fill/README.md +++ b/packages/components/src/slot-fill/README.md @@ -70,7 +70,7 @@ Both `Slot` and `Fill` accept a `name` string prop, where a `Slot` with a given `Slot` with `bubblesVirtually` set to true also accept an optional `className` to add to the slot container. -`Slot` also accepts optional `children` function prop, which takes `fills` as a param. It allows to perform additional processing and wrap `fills` conditionally. +`Slot` **without** `bubblesVirtually` accepts an optional `children` function prop, which takes `fills` as a param. It allows you to perform additional processing and wrap `fills` conditionally. _Example_: @@ -89,3 +89,28 @@ const Toolbar = ( { isMobile } ) => ( </div> ); ``` + +Props can also be passed from a `Slot` to a `Fill` by using the prop `fillProps` on the `Slot`: + +```jsx +const { Fill, Slot } = createSlotFill( 'Toolbar' ); + +const ToolbarItem = () => ( + <Fill> + { ( { hideToolbar } ) => { + <Button onClick={ hideToolbar }>Hide</Button>; + } } + </Fill> +); + +const Toolbar = () => { + const hideToolbar = () => { + console.log( 'Hide toolbar' ); + }; + return ( + <div className="toolbar"> + <Slot fillProps={ { hideToolbar } } /> + </div> + ); +}; +``` diff --git a/packages/components/src/slot-fill/bubbles-virtually/slot-fill-context.js b/packages/components/src/slot-fill/bubbles-virtually/slot-fill-context.js index cd1576fd19c3ac..e3c6652f22e94e 100644 --- a/packages/components/src/slot-fill/bubbles-virtually/slot-fill-context.js +++ b/packages/components/src/slot-fill/bubbles-virtually/slot-fill-context.js @@ -22,6 +22,9 @@ const SlotFillContext = createContext( { unregisterSlot: () => {}, registerFill: () => {}, unregisterFill: () => {}, + + // This helps the provider know if it's using the default context value or not. + isDefault: true, } ); export default SlotFillContext; diff --git a/packages/components/src/slot-fill/bubbles-virtually/slot.js b/packages/components/src/slot-fill/bubbles-virtually/slot.js index ef7ad56cc68bac..be6fde0c8e6b7b 100644 --- a/packages/components/src/slot-fill/bubbles-virtually/slot.js +++ b/packages/components/src/slot-fill/bubbles-virtually/slot.js @@ -13,12 +13,20 @@ import { useMergeRefs } from '@wordpress/compose'; /** * Internal dependencies */ +import { View } from '../../view'; import SlotFillContext from './slot-fill-context'; -function Slot( - { name, fillProps = {}, as: Component = 'div', ...props }, - forwardedRef -) { +function Slot( props, forwardedRef ) { + const { + name, + fillProps = {}, + as, + // `children` is not allowed. However, if it is passed, + // it will be displayed as is, so remove `children`. + children, + ...restProps + } = props; + const { registerSlot, unregisterSlot, ...registry } = useContext( SlotFillContext ); const ref = useRef(); @@ -41,7 +49,11 @@ function Slot( } ); return ( - <Component ref={ useMergeRefs( [ forwardedRef, ref ] ) } { ...props } /> + <View + as={ as } + ref={ useMergeRefs( [ forwardedRef, ref ] ) } + { ...restProps } + /> ); } diff --git a/packages/components/src/slot-fill/index.js b/packages/components/src/slot-fill/index.js index 8deaa180492a7f..34216fd347c053 100644 --- a/packages/components/src/slot-fill/index.js +++ b/packages/components/src/slot-fill/index.js @@ -2,7 +2,7 @@ /** * WordPress dependencies */ -import { forwardRef } from '@wordpress/element'; +import { forwardRef, useContext } from '@wordpress/element'; /** * Internal dependencies @@ -13,6 +13,7 @@ import BubblesVirtuallyFill from './bubbles-virtually/fill'; import BubblesVirtuallySlot from './bubbles-virtually/slot'; import BubblesVirtuallySlotFillProvider from './bubbles-virtually/slot-fill-provider'; import SlotFillProvider from './provider'; +import SlotFillContext from './bubbles-virtually/slot-fill-context'; export { default as useSlot } from './bubbles-virtually/use-slot'; export { default as useSlotFills } from './bubbles-virtually/use-slot-fills'; @@ -35,6 +36,10 @@ export const Slot = forwardRef( ( { bubblesVirtually, ...props }, ref ) => { } ); export function Provider( { children, ...props } ) { + const parent = useContext( SlotFillContext ); + if ( ! parent.isDefault ) { + return children; + } return ( <SlotFillProvider { ...props }> <BubblesVirtuallySlotFillProvider> diff --git a/packages/components/src/slot-fill/stories/index.js b/packages/components/src/slot-fill/stories/index.js deleted file mode 100644 index 1f8dbedeba3fae..00000000000000 --- a/packages/components/src/slot-fill/stories/index.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * WordPress dependencies - */ -import { createContext, useContext } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { Slot, Fill, Provider as SlotFillProvider } from '../'; - -export default { - title: 'Components/SlotFill', - subcomponents: { Fill, SlotFillProvider }, - component: Slot, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; - -export const _default = () => { - return ( - <SlotFillProvider> - <h2>Profile</h2> - <p> - Name: <Slot bubblesVirtually as="span" name="name" /> - </p> - <p> - Age: <Slot bubblesVirtually as="span" name="age" /> - </p> - <Fill name="name">Grace</Fill> - <Fill name="age">33</Fill> - </SlotFillProvider> - ); -}; - -export const withFillProps = () => { - return ( - <SlotFillProvider> - <h2>Profile</h2> - <p> - Name:{ ' ' } - <Slot - bubblesVirtually - as="span" - name="name" - fillProps={ { name: 'Grace' } } - /> - </p> - <p> - Age:{ ' ' } - <Slot - bubblesVirtually - as="span" - name="age" - fillProps={ { age: 33 } } - /> - </p> - <Fill name="name">{ ( fillProps ) => fillProps.name }</Fill> - <Fill name="age">{ ( fillProps ) => fillProps.age }</Fill> - </SlotFillProvider> - ); -}; - -export const withContext = () => { - const Context = createContext(); - const ContextFill = ( { name } ) => { - const value = useContext( Context ); - return <Fill name={ name }>{ value }</Fill>; - }; - return ( - <SlotFillProvider> - <h2>Profile</h2> - <p> - Name: <Slot bubblesVirtually as="span" name="name" /> - </p> - <p> - Age: <Slot bubblesVirtually as="span" name="age" /> - </p> - <Context.Provider value="Grace"> - <ContextFill name="name" /> - </Context.Provider> - <Context.Provider value={ 33 }> - <ContextFill name="age" /> - </Context.Provider> - </SlotFillProvider> - ); -}; diff --git a/packages/components/src/slot-fill/stories/index.story.js b/packages/components/src/slot-fill/stories/index.story.js new file mode 100644 index 00000000000000..c1ab26162497e9 --- /dev/null +++ b/packages/components/src/slot-fill/stories/index.story.js @@ -0,0 +1,89 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Slot, Fill, Provider as SlotFillProvider } from '../'; + +export default { + title: 'Components/SlotFill', + component: Slot, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { Fill, SlotFillProvider }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export const _default = () => { + return ( + <SlotFillProvider> + <h2>Profile</h2> + <p> + Name: <Slot bubblesVirtually as="span" name="name" /> + </p> + <p> + Age: <Slot bubblesVirtually as="span" name="age" /> + </p> + <Fill name="name">Grace</Fill> + <Fill name="age">33</Fill> + </SlotFillProvider> + ); +}; + +export const WithFillProps = () => { + return ( + <SlotFillProvider> + <h2>Profile</h2> + <p> + Name:{ ' ' } + <Slot + bubblesVirtually + as="span" + name="name" + fillProps={ { name: 'Grace' } } + /> + </p> + <p> + Age:{ ' ' } + <Slot + bubblesVirtually + as="span" + name="age" + fillProps={ { age: 33 } } + /> + </p> + <Fill name="name">{ ( fillProps ) => fillProps.name }</Fill> + <Fill name="age">{ ( fillProps ) => fillProps.age }</Fill> + </SlotFillProvider> + ); +}; + +export const WithContext = () => { + const Context = createContext(); + const ContextFill = ( { name } ) => { + const value = useContext( Context ); + return <Fill name={ name }>{ value }</Fill>; + }; + return ( + <SlotFillProvider> + <h2>Profile</h2> + <p> + Name: <Slot bubblesVirtually as="span" name="name" /> + </p> + <p> + Age: <Slot bubblesVirtually as="span" name="age" /> + </p> + <Context.Provider value="Grace"> + <ContextFill name="name" /> + </Context.Provider> + <Context.Provider value={ 33 }> + <ContextFill name="age" /> + </Context.Provider> + </SlotFillProvider> + ); +}; diff --git a/packages/components/src/slot-fill/test/__snapshots__/slot.js.snap b/packages/components/src/slot-fill/test/__snapshots__/slot.js.snap index d57954b0444f9b..b9379eda7171a2 100644 --- a/packages/components/src/slot-fill/test/__snapshots__/slot.js.snap +++ b/packages/components/src/slot-fill/test/__snapshots__/slot.js.snap @@ -42,12 +42,16 @@ exports[`Slot bubblesVirtually true should subsume another slot by the same name <div data-position="first" > - <div /> + <div + class="emotion-0 emotion-1" + /> </div> <div data-position="second" > - <div> + <div + class="emotion-0 emotion-1" + > Content </div> </div> @@ -62,7 +66,9 @@ exports[`Slot bubblesVirtually true should subsume another slot by the same name <div data-position="second" > - <div> + <div + class="emotion-0 emotion-1" + > Content </div> </div> @@ -187,7 +193,9 @@ exports[`Slot should render in expected order when fills unmounted 1`] = ` exports[`Slot should warn without a Provider 1`] = ` <div> <div> - <div /> + <div + class="emotion-0 emotion-1" + /> </div> </div> `; diff --git a/packages/components/src/snackbar/list.tsx b/packages/components/src/snackbar/list.tsx index 5b3a82737dcd59..631cf48d953a23 100644 --- a/packages/components/src/snackbar/list.tsx +++ b/packages/components/src/snackbar/list.tsx @@ -29,13 +29,21 @@ const SNACKBAR_VARIANTS = { height: 'auto', opacity: 1, transition: { - height: { stiffness: 1000, velocity: -100 }, + height: { type: 'tween', duration: 0.3, ease: [ 0, 0, 0.2, 1 ] }, + opacity: { + type: 'tween', + duration: 0.25, + delay: 0.05, + ease: [ 0, 0, 0.2, 1 ], + }, }, }, exit: { opacity: 0, transition: { - duration: 0.5, + type: 'tween', + duration: 0.1, + ease: [ 0, 0, 0.2, 1 ], }, }, }; @@ -73,6 +81,7 @@ export function SnackbarList( { return ( <motion.div + layout={ ! isReducedMotion } // See https://www.framer.com/docs/animation/#layout-animations initial={ 'init' } animate={ 'open' } exit={ 'exit' } diff --git a/packages/components/src/snackbar/stories/index.story.tsx b/packages/components/src/snackbar/stories/index.story.tsx new file mode 100644 index 00000000000000..953b33d273b3fc --- /dev/null +++ b/packages/components/src/snackbar/stories/index.story.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Snackbar from '..'; + +const meta: Meta< typeof Snackbar > = { + title: 'Components/Snackbar', + component: Snackbar, + argTypes: { + as: { control: { type: null } }, + onRemove: { + action: 'onRemove', + control: { type: null }, + }, + onDismiss: { + action: 'onDismiss', + control: { type: null }, + }, + listRef: { + control: { type: null }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const DefaultTemplate: StoryFn< typeof Snackbar > = ( { + children, + ...props +} ) => { + return <Snackbar { ...props }>{ children }</Snackbar>; +}; + +export const Default: StoryFn< typeof Snackbar > = DefaultTemplate.bind( {} ); +Default.args = { + children: + 'Use Snackbars to communicate low priority, non-interruptive messages to the user.', +}; + +export const WithActions: StoryFn< typeof Snackbar > = DefaultTemplate.bind( + {} +); +WithActions.args = { + actions: [ + { + label: 'Open WP.org', + url: 'https://wordpress.org', + }, + ], + children: 'Use Snackbars with an action link to an external page.', +}; + +export const WithIcon: StoryFn< typeof Snackbar > = DefaultTemplate.bind( {} ); +WithIcon.args = { + children: 'Add an icon to make your snackbar stand out', + icon: ( + <span role="img" aria-label="Icon" style={ { fontSize: 21 } }> + 🌮 + </span> + ), +}; + +export const WithExplicitDismiss: StoryFn< typeof Snackbar > = + DefaultTemplate.bind( {} ); +WithExplicitDismiss.args = { + children: + 'Add a cross to explicitly close the snackbar, and do not hide it automatically', + explicitDismiss: true, +}; + +export const WithActionAndExplicitDismiss: StoryFn< typeof Snackbar > = + DefaultTemplate.bind( {} ); +WithActionAndExplicitDismiss.args = { + actions: [ + { + label: 'Open WP.org', + url: 'https://wordpress.org', + }, + ], + children: + 'Add an action and a cross to explicitly close the snackbar, and do not hide it automatically', + explicitDismiss: true, +}; diff --git a/packages/components/src/snackbar/stories/index.tsx b/packages/components/src/snackbar/stories/index.tsx deleted file mode 100644 index 0d226f321e72a0..00000000000000 --- a/packages/components/src/snackbar/stories/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Snackbar from '..'; - -const meta: ComponentMeta< typeof Snackbar > = { - title: 'Components/Snackbar', - component: Snackbar, - argTypes: { - as: { control: { type: null } }, - onRemove: { - action: 'onRemove', - control: { type: null }, - }, - onDismiss: { - action: 'onDismiss', - control: { type: null }, - }, - listRef: { - control: { type: null }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const DefaultTemplate: ComponentStory< typeof Snackbar > = ( { - children, - ...props -} ) => { - return <Snackbar { ...props }>{ children }</Snackbar>; -}; - -export const Default: ComponentStory< typeof Snackbar > = DefaultTemplate.bind( - {} -); -Default.args = { - children: - 'Use Snackbars to communicate low priority, non-interruptive messages to the user.', -}; - -export const WithActions: ComponentStory< typeof Snackbar > = - DefaultTemplate.bind( {} ); -WithActions.args = { - actions: [ - { - label: 'Open WP.org', - url: 'https://wordpress.org', - }, - ], - children: 'Use Snackbars with an action link to an external page.', -}; - -export const WithIcon: ComponentStory< typeof Snackbar > = DefaultTemplate.bind( - {} -); -WithIcon.args = { - children: 'Add an icon to make your snackbar stand out', - icon: ( - <span role="img" aria-label="Icon" style={ { fontSize: 21 } }> - 🌮 - </span> - ), -}; - -export const WithExplicitDismiss: ComponentStory< typeof Snackbar > = - DefaultTemplate.bind( {} ); -WithExplicitDismiss.args = { - children: - 'Add a cross to explicitly close the snackbar, and do not hide it automatically', - explicitDismiss: true, -}; - -export const WithActionAndExplicitDismiss: ComponentStory< typeof Snackbar > = - DefaultTemplate.bind( {} ); -WithActionAndExplicitDismiss.args = { - actions: [ - { - label: 'Open WP.org', - url: 'https://wordpress.org', - }, - ], - children: - 'Add an action and a cross to explicitly close the snackbar, and do not hide it automatically', - explicitDismiss: true, -}; diff --git a/packages/components/src/snackbar/stories/list.story.tsx b/packages/components/src/snackbar/stories/list.story.tsx new file mode 100644 index 00000000000000..5a759ddc661bfa --- /dev/null +++ b/packages/components/src/snackbar/stories/list.story.tsx @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SnackbarList from '../list'; + +const meta: Meta< typeof SnackbarList > = { + title: 'Components/SnackbarList', + component: SnackbarList, + argTypes: { + as: { control: { type: null } }, + onRemove: { + action: 'onRemove', + control: { type: null }, + }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof SnackbarList > = ( { + children, + notices: noticesProp, + ...props +} ) => { + const [ notices, setNotices ] = useState( noticesProp ); + + const onRemove = ( id: string ) => { + const matchIndex = notices.findIndex( ( n ) => n.id === id ); + if ( matchIndex > -1 ) { + setNotices( [ + ...notices.slice( 0, matchIndex ), + ...notices.slice( matchIndex + 1 ), + ] ); + } + }; + + return ( + <SnackbarList { ...props } notices={ notices } onRemove={ onRemove }> + { children } + </SnackbarList> + ); +}; + +Default.args = { + children: + 'Use SnackbarList to communicate multiple low priority, non-interruptive messages to the user.', + notices: [ + { + id: 'SAVE_POST_NOTICE_ID_1', + spokenMessage: 'Post published.', + actions: [ + { + label: 'View Post', + url: 'https://example.com/?p=522', + }, + ], + content: 'Post published.', + isDismissible: true, + explicitDismiss: false, + }, + { + id: 'SAVE_POST_NOTICE_ID_2', + spokenMessage: 'Post updated', + actions: [ + { + label: 'View Post', + url: 'https://example.com/?p=522', + }, + ], + content: 'Post updated.', + isDismissible: true, + explicitDismiss: false, + }, + { + id: 'global1', + spokenMessage: 'All content copied.', + actions: [], + content: 'All content copied.', + isDismissible: true, + explicitDismiss: false, + }, + ], +}; diff --git a/packages/components/src/snackbar/stories/list.tsx b/packages/components/src/snackbar/stories/list.tsx deleted file mode 100644 index a12b2c171ae7a5..00000000000000 --- a/packages/components/src/snackbar/stories/list.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SnackbarList from '../list'; - -const meta: ComponentMeta< typeof SnackbarList > = { - title: 'Components/SnackbarList', - component: SnackbarList, - argTypes: { - as: { control: { type: null } }, - onRemove: { - action: 'onRemove', - control: { type: null }, - }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof SnackbarList > = ( { - children, - notices: noticesProp, - ...props -} ) => { - const [ notices, setNotices ] = useState( noticesProp ); - - const onRemove = ( id: string ) => { - const matchIndex = notices.findIndex( ( n ) => n.id === id ); - if ( matchIndex > -1 ) { - setNotices( [ - ...notices.slice( 0, matchIndex ), - ...notices.slice( matchIndex + 1 ), - ] ); - } - }; - - return ( - <SnackbarList { ...props } notices={ notices } onRemove={ onRemove }> - { children } - </SnackbarList> - ); -}; - -Default.args = { - children: - 'Use SnackbarList to communicate multiple low priority, non-interruptive messages to the user.', - notices: [ - { - id: 'SAVE_POST_NOTICE_ID_1', - spokenMessage: 'Post published.', - actions: [ - { - label: 'View Post', - url: 'https://example.com/?p=522', - }, - ], - content: 'Post published.', - isDismissible: true, - explicitDismiss: false, - }, - { - id: 'SAVE_POST_NOTICE_ID_2', - spokenMessage: 'Post updated', - actions: [ - { - label: 'View Post', - url: 'https://example.com/?p=522', - }, - ], - content: 'Post updated.', - isDismissible: true, - explicitDismiss: false, - }, - { - id: 'global1', - spokenMessage: 'All content copied.', - actions: [], - content: 'All content copied.', - isDismissible: true, - explicitDismiss: false, - }, - ], -}; diff --git a/packages/components/src/snackbar/style.scss b/packages/components/src/snackbar/style.scss index 9e9acebb635bf4..3cae9c8ef65d49 100644 --- a/packages/components/src/snackbar/style.scss +++ b/packages/components/src/snackbar/style.scss @@ -1,11 +1,12 @@ .components-snackbar { font-family: $default-font; font-size: $default-font-size; - background-color: $gray-900; + background: rgba($black, 0.85); // Emulates #1e1e1e closely. + backdrop-filter: blur($grid-unit-20) saturate(180%); border-radius: $radius-block-ui; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + box-shadow: $shadow-popover; color: $white; - padding: 16px 24px; + padding: $grid-unit-15 ($grid-unit-05 * 5); width: 100%; max-width: 600px; box-sizing: border-box; @@ -20,9 +21,7 @@ } &:focus { - box-shadow: - 0 0 0 1px $white, - 0 0 0 3px $components-color-accent; + box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent; } &.components-snackbar-explicit-dismiss { @@ -30,7 +29,7 @@ } .components-snackbar__content-with-icon { - margin-left: 24px; + margin-left: $grid-unit-30; } .components-snackbar__icon { @@ -40,7 +39,7 @@ } .components-snackbar__dismiss-button { - margin-left: 32px; + margin-left: $grid-unit-30; cursor: pointer; } } @@ -91,5 +90,5 @@ .components-snackbar-list__notice-container { position: relative; - padding-top: 8px; + padding-top: $grid-unit-10; } diff --git a/packages/components/src/spacer/component.tsx b/packages/components/src/spacer/component.tsx index cce8f0b54905e4..b4f532a0fd4787 100644 --- a/packages/components/src/spacer/component.tsx +++ b/packages/components/src/spacer/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import { useSpacer } from './hook'; import type { SpacerProps } from './types'; diff --git a/packages/components/src/spacer/hook.ts b/packages/components/src/spacer/hook.ts index 1ddf4dd7ae428b..e43d45cb31f531 100644 --- a/packages/components/src/spacer/hook.ts +++ b/packages/components/src/spacer/hook.ts @@ -6,7 +6,8 @@ import { css } from '@emotion/react'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import { space } from '../ui/utils/space'; import { rtl, useCx } from '../utils'; import type { SpacerProps } from './types'; diff --git a/packages/components/src/spacer/stories/index.story.tsx b/packages/components/src/spacer/stories/index.story.tsx new file mode 100644 index 00000000000000..586658ac0f01f5 --- /dev/null +++ b/packages/components/src/spacer/stories/index.story.tsx @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Spacer } from '..'; + +const controls = [ + 'margin', + 'marginY', + 'marginX', + 'marginTop', + 'marginBottom', + 'marginLeft', + 'marginRight', + + 'padding', + 'paddingY', + 'paddingX', + 'paddingTop', + 'paddingBottom', + 'paddingLeft', + 'paddingRight', +].reduce( + ( acc, prop ) => ( { ...acc, [ prop ]: { control: { type: 'text' } } } ), + {} +); + +const meta: Meta< typeof Spacer > = { + component: Spacer, + title: 'Components (Experimental)/Spacer', + argTypes: { + as: { control: { type: 'text' } }, + children: { + control: { type: 'text' }, + }, + ...controls, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const BlackBox = () => ( + <div + style={ { backgroundColor: 'black', width: '100px', height: '100px' } } + /> +); + +const Template: StoryFn< typeof Spacer > = ( { onChange, ...args } ) => { + return ( + <> + <BlackBox /> + <Spacer { ...args } /> + <BlackBox /> + </> + ); +}; + +export const Default: StoryFn< typeof Spacer > = Template.bind( {} ); +Default.args = { + children: 'This is the spacer', +}; diff --git a/packages/components/src/spacer/stories/index.tsx b/packages/components/src/spacer/stories/index.tsx deleted file mode 100644 index 46520e2069bce5..00000000000000 --- a/packages/components/src/spacer/stories/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Spacer } from '..'; - -const controls = [ - 'margin', - 'marginY', - 'marginX', - 'marginTop', - 'marginBottom', - 'marginLeft', - 'marginRight', - - 'padding', - 'paddingY', - 'paddingX', - 'paddingTop', - 'paddingBottom', - 'paddingLeft', - 'paddingRight', -].reduce( - ( acc, prop ) => ( { ...acc, [ prop ]: { control: { type: 'text' } } } ), - {} -); - -const meta: ComponentMeta< typeof Spacer > = { - component: Spacer, - title: 'Components (Experimental)/Spacer', - argTypes: { - as: { control: { type: 'text' } }, - children: { - control: { type: 'text' }, - }, - ...controls, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const BlackBox = () => ( - <div - style={ { backgroundColor: 'black', width: '100px', height: '100px' } } - /> -); - -const Template: ComponentStory< typeof Spacer > = ( { onChange, ...args } ) => { - return ( - <> - <BlackBox /> - <Spacer { ...args } /> - <BlackBox /> - </> - ); -}; - -export const Default: ComponentStory< typeof Spacer > = Template.bind( {} ); -Default.args = { - children: 'This is the spacer', -}; diff --git a/packages/components/src/spinner/index.tsx b/packages/components/src/spinner/index.tsx index 8fa34b9b230d31..9eee9dde18ef23 100644 --- a/packages/components/src/spinner/index.tsx +++ b/packages/components/src/spinner/index.tsx @@ -50,7 +50,6 @@ export function UnforwardedSpinner( /** * `Spinner` is a component used to notify users that their action is being processed. * - * @example * ```js * import { Spinner } from '@wordpress/components'; * diff --git a/packages/components/src/spinner/stories/index.story.tsx b/packages/components/src/spinner/stories/index.story.tsx new file mode 100644 index 00000000000000..dfd6fb26f25ae9 --- /dev/null +++ b/packages/components/src/spinner/stories/index.story.tsx @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Spinner from '../'; +import { space } from '../../ui/utils/space'; + +const meta: Meta< typeof Spinner > = { + title: 'Components/Spinner', + component: Spinner, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Spinner > = ( args ) => { + return <Spinner { ...args } />; +}; + +export const Default: StoryFn< typeof Spinner > = Template.bind( {} ); + +// The Spinner can be resized to any size, but the stroke width will remain unchanged. +export const CustomSize: StoryFn< typeof Spinner > = Template.bind( {} ); +CustomSize.args = { style: { width: space( 20 ), height: space( 20 ) } }; diff --git a/packages/components/src/spinner/stories/index.tsx b/packages/components/src/spinner/stories/index.tsx deleted file mode 100644 index 7afaa35d3871fe..00000000000000 --- a/packages/components/src/spinner/stories/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory, ComponentMeta } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Spinner from '../'; -import { space } from '../../ui/utils/space'; - -const meta: ComponentMeta< typeof Spinner > = { - title: 'Components/Spinner', - component: Spinner, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Spinner > = ( args ) => { - return <Spinner { ...args } />; -}; - -export const Default: ComponentStory< typeof Spinner > = Template.bind( {} ); - -// The Spinner can be resized to any size, but the stroke width will remain unchanged. -export const CustomSize: ComponentStory< typeof Spinner > = Template.bind( {} ); -CustomSize.args = { style: { width: space( 20 ), height: space( 20 ) } }; diff --git a/packages/components/src/spinner/styles.ts b/packages/components/src/spinner/styles.ts index 12c108801bc797..e51fd29470541c 100644 --- a/packages/components/src/spinner/styles.ts +++ b/packages/components/src/spinner/styles.ts @@ -24,7 +24,7 @@ export const StyledSpinner = styled.svg` display: inline-block; margin: 5px 11px 0; position: relative; - color: ${ COLORS.ui.theme }; + color: ${ COLORS.theme.accent }; overflow: visible; opacity: 1; background-color: transparent; diff --git a/packages/components/src/style-provider/index.tsx b/packages/components/src/style-provider/index.tsx index 0fa2c5b6ca008a..1c1b55589dedcc 100644 --- a/packages/components/src/style-provider/index.tsx +++ b/packages/components/src/style-provider/index.tsx @@ -3,7 +3,6 @@ */ import { CacheProvider } from '@emotion/react'; import createCache from '@emotion/cache'; -import memoize from 'memize'; import * as uuid from 'uuid'; /** @@ -12,19 +11,27 @@ import * as uuid from 'uuid'; import type { StyleProviderProps } from './types'; const uuidCache = new Set(); +// Use a weak map so that when the container is detached it's automatically +// dereferenced to avoid memory leak. +const containerCacheMap = new WeakMap(); -const memoizedCreateCacheWithContainer = memoize( - ( container: HTMLElement ) => { - // Emotion only accepts alphabetical and hyphenated keys so we just - // strip the numbers from the UUID. It _should_ be fine. - let key = uuid.v4().replace( /[0-9]/g, '' ); - while ( uuidCache.has( key ) ) { - key = uuid.v4().replace( /[0-9]/g, '' ); - } - uuidCache.add( key ); - return createCache( { container, key } ); +const memoizedCreateCacheWithContainer = ( container: HTMLElement ) => { + if ( containerCacheMap.has( container ) ) { + return containerCacheMap.get( container ); } -); + + // Emotion only accepts alphabetical and hyphenated keys so we just + // strip the numbers from the UUID. It _should_ be fine. + let key = uuid.v4().replace( /[0-9]/g, '' ); + while ( uuidCache.has( key ) ) { + key = uuid.v4().replace( /[0-9]/g, '' ); + } + uuidCache.add( key ); + + const cache = createCache( { container, key } ); + containerCacheMap.set( container, cache ); + return cache; +}; export function StyleProvider( props: StyleProviderProps ) { const { children, document } = props; diff --git a/packages/components/src/surface/stories/index.story.tsx b/packages/components/src/surface/stories/index.story.tsx new file mode 100644 index 00000000000000..7f6790d09c848e --- /dev/null +++ b/packages/components/src/surface/stories/index.story.tsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Surface } from '..'; +import { Text } from '../../text'; + +const meta: Meta< typeof Surface > = { + component: Surface, + title: 'Components (Experimental)/Surface', + argTypes: { + children: { control: { type: null } }, + as: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Surface > = ( args ) => { + return ( + <Surface + { ...args } + style={ { padding: 20, maxWidth: 400, margin: '20vh auto' } } + > + <Text>Code is Poetry</Text> + </Surface> + ); +}; + +export const Default: StoryFn< typeof Surface > = Template.bind( {} ); +Default.args = {}; diff --git a/packages/components/src/surface/stories/index.tsx b/packages/components/src/surface/stories/index.tsx deleted file mode 100644 index aa26cf29882f86..00000000000000 --- a/packages/components/src/surface/stories/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Surface } from '..'; -import { Text } from '../../text'; - -const meta: ComponentMeta< typeof Surface > = { - component: Surface, - title: 'Components (Experimental)/Surface', - argTypes: { - children: { control: { type: null } }, - as: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Surface > = ( args ) => { - return ( - <Surface - { ...args } - style={ { padding: 20, maxWidth: 400, margin: '20vh auto' } } - > - <Text>Code is Poetry</Text> - </Surface> - ); -}; - -export const Default: ComponentStory< typeof Surface > = Template.bind( {} ); -Default.args = {}; diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index 4ed4dc76da22de..4fff7dc306b023 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -1,44 +1,39 @@ /** * External dependencies */ +import * as Ariakit from '@ariakit/react'; import classnames from 'classnames'; +import type { ForwardedRef } from 'react'; /** * WordPress dependencies */ import { - useState, + forwardRef, useEffect, useLayoutEffect, useCallback, } from '@wordpress/element'; -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ -import { NavigableMenu } from '../navigable-container'; + import Button from '../button'; -import type { TabButtonProps, TabPanelProps } from './types'; +import type { TabPanelProps } from './types'; import type { WordPressComponentProps } from '../ui/context'; -const TabButton = ( { - tabId, - children, - selected, - ...rest -}: TabButtonProps ) => ( - <Button - role="tab" - tabIndex={ selected ? undefined : -1 } - aria-selected={ selected } - id={ tabId } - __experimentalIsFocusable - { ...rest } - > - { children } - </Button> -); +// Separate the actual tab name from the instance ID. This is +// necessary because Ariakit internally uses the element ID when +// a new tab is selected, but our implementation looks specifically +// for the tab name to be passed to the `onSelect` callback. +const extractTabName = ( id: string | undefined | null ) => { + if ( typeof id === 'undefined' || id === null ) { + return; + } + return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; +}; /** * TabPanel is an ARIA-compliant tabpanel. @@ -76,37 +71,79 @@ const TabButton = ( { * ); * ``` */ -export function TabPanel( { - className, - children, - tabs, - selectOnMove = true, - initialTabName, - orientation = 'horizontal', - activeClass = 'is-active', - onSelect, -}: WordPressComponentProps< TabPanelProps, 'div', false > ) { +const UnforwardedTabPanel = ( + { + className, + children, + tabs, + selectOnMove = true, + initialTabName, + orientation = 'horizontal', + activeClass = 'is-active', + onSelect, + }: WordPressComponentProps< TabPanelProps, 'div', false >, + ref: ForwardedRef< any > +) => { const instanceId = useInstanceId( TabPanel, 'tab-panel' ); - const [ selected, setSelected ] = useState< string >(); - const handleTabSelection = useCallback( - ( tabKey: string ) => { - setSelected( tabKey ); - onSelect?.( tabKey ); + const prependInstanceId = useCallback( + ( tabName: string | undefined ) => { + if ( typeof tabName === 'undefined' ) { + return; + } + return `${ instanceId }-${ tabName }`; + }, + [ instanceId ] + ); + + const tabStore = Ariakit.useTabStore( { + setSelectedId: ( newTabValue ) => { + if ( typeof newTabValue === 'undefined' || newTabValue === null ) { + return; + } + + const newTab = tabs.find( + ( t ) => prependInstanceId( t.name ) === newTabValue + ); + if ( newTab?.disabled || newTab === selectedTab ) { + return; + } + + const simplifiedTabName = extractTabName( newTabValue ); + if ( typeof simplifiedTabName === 'undefined' ) { + return; + } + + onSelect?.( simplifiedTabName ); + }, + orientation, + selectOnMove, + defaultSelectedId: prependInstanceId( initialTabName ), + } ); + + const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) ); + + const setTabStoreSelectedId = useCallback( + ( tabName: string ) => { + tabStore.setState( 'selectedId', prependInstanceId( tabName ) ); }, - [ onSelect ] + [ prependInstanceId, tabStore ] ); - // Simulate a click on the newly focused tab, which causes the component - // to show the `tab-panel` associated with the clicked tab. - const activateTabAutomatically = ( - _childIndex: number, - child: HTMLElement - ) => { - child.click(); - }; - const selectedTab = tabs.find( ( { name } ) => name === selected ); - const selectedId = `${ instanceId }-${ selectedTab?.name ?? 'none' }`; + const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); + + const previousSelectedTabName = usePrevious( selectedTabName ); + + // Ensure `onSelect` is called when the initial tab is selected. + useEffect( () => { + if ( + previousSelectedTabName !== selectedTabName && + selectedTabName === initialTabName && + !! selectedTabName + ) { + onSelect?.( selectedTabName ); + } + }, [ selectedTabName, initialTabName, onSelect, previousSelectedTabName ] ); // Handle selecting the initial tab. useLayoutEffect( () => { @@ -114,25 +151,31 @@ export function TabPanel( { if ( selectedTab ) { return; } - const initialTab = tabs.find( ( tab ) => tab.name === initialTabName ); - // Wait for the denoted initial tab to be declared before making a // selection. This ensures that if a tab is declared lazily it can // still receive initial selection. if ( initialTabName && ! initialTab ) { return; } - if ( initialTab && ! initialTab.disabled ) { // Select the initial tab if it's not disabled. - handleTabSelection( initialTab.name ); + setTabStoreSelectedId( initialTab.name ); } else { - // Fallback to the first enabled tab when the initial is disabled. + // Fallback to the first enabled tab when the initial tab is + // disabled or it can't be found. const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); - if ( firstEnabledTab ) handleTabSelection( firstEnabledTab.name ); + if ( firstEnabledTab ) { + setTabStoreSelectedId( firstEnabledTab.name ); + } } - }, [ tabs, selectedTab, initialTabName, handleTabSelection ] ); + }, [ + tabs, + selectedTab, + initialTabName, + instanceId, + setTabStoreSelectedId, + ] ); // Handle the currently selected tab becoming disabled. useEffect( () => { @@ -140,62 +183,62 @@ export function TabPanel( { if ( ! selectedTab?.disabled ) { return; } - const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); - // If the currently selected tab becomes disabled, select the first enabled tab. // (if there is one). if ( firstEnabledTab ) { - handleTabSelection( firstEnabledTab.name ); + setTabStoreSelectedId( firstEnabledTab.name ); } - }, [ tabs, selectedTab?.disabled, handleTabSelection ] ); - + }, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); return ( - <div className={ className }> - <NavigableMenu - role="tablist" - orientation={ orientation } - onNavigate={ - selectOnMove ? activateTabAutomatically : undefined - } + <div className={ className } ref={ ref }> + <Ariakit.TabList + store={ tabStore } className="components-tab-panel__tabs" > - { tabs.map( ( tab ) => ( - <TabButton - className={ classnames( - 'components-tab-panel__tabs-item', - tab.className, - { - [ activeClass ]: tab.name === selected, + { tabs.map( ( tab ) => { + return ( + <Ariakit.Tab + key={ tab.name } + id={ prependInstanceId( tab.name ) } + className={ classnames( + 'components-tab-panel__tabs-item', + tab.className, + { + [ activeClass ]: + tab.name === selectedTabName, + } + ) } + disabled={ tab.disabled } + aria-controls={ `${ prependInstanceId( + tab.name + ) }-view` } + render={ + <Button + icon={ tab.icon } + label={ tab.icon && tab.title } + showTooltip={ !! tab.icon } + /> } - ) } - tabId={ `${ instanceId }-${ tab.name }` } - aria-controls={ `${ instanceId }-${ tab.name }-view` } - selected={ tab.name === selected } - key={ tab.name } - onClick={ () => handleTabSelection( tab.name ) } - disabled={ tab.disabled } - label={ tab.icon && tab.title } - icon={ tab.icon } - showTooltip={ !! tab.icon } - > - { ! tab.icon && tab.title } - </TabButton> - ) ) } - </NavigableMenu> + > + { ! tab.icon && tab.title } + </Ariakit.Tab> + ); + } ) } + </Ariakit.TabList> { selectedTab && ( - <div - key={ selectedId } - aria-labelledby={ selectedId } - role="tabpanel" - id={ `${ selectedId }-view` } - className="components-tab-panel__tab-content" + <Ariakit.TabPanel + id={ `${ prependInstanceId( selectedTab.name ) }-view` } + store={ tabStore } + tabId={ prependInstanceId( selectedTab.name ) } + className={ 'components-tab-panel__tab-content' } > { children( selectedTab ) } - </div> + </Ariakit.TabPanel> ) } </div> ); -} +}; +export const TabPanel = forwardRef( UnforwardedTabPanel ); export default TabPanel; diff --git a/packages/components/src/tab-panel/stories/index.story.tsx b/packages/components/src/tab-panel/stories/index.story.tsx new file mode 100644 index 00000000000000..e2f146d55865cb --- /dev/null +++ b/packages/components/src/tab-panel/stories/index.story.tsx @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { link, more, wordpress } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import TabPanel from '..'; + +const meta: Meta< typeof TabPanel > = { + title: 'Components/TabPanel', + component: TabPanel, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof TabPanel > = ( props ) => { + return <TabPanel { ...props } />; +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: ( tab ) => <p>Selected tab: { tab.title }</p>, + tabs: [ + { + name: 'tab1', + title: 'Tab 1', + }, + { + name: 'tab2', + title: 'Tab 2', + }, + ], +}; + +export const DisabledTab = Template.bind( {} ); +DisabledTab.args = { + children: ( tab ) => <p>Selected tab: { tab.title }</p>, + tabs: [ + { + name: 'tab1', + title: 'Tab 1', + disabled: true, + }, + { + name: 'tab2', + title: 'Tab 2', + }, + { + name: 'tab3', + title: 'Tab 3', + }, + ], +}; + +const SlotFillTemplate: StoryFn< typeof TabPanel > = ( props ) => { + return <TabPanel { ...props } />; +}; + +export const WithTabIconsAndTooltips = SlotFillTemplate.bind( {} ); +WithTabIconsAndTooltips.args = { + children: ( tab ) => <p>Selected tab: { tab.title }</p>, + tabs: [ + { + name: 'tab1', + title: 'Tab 1', + icon: wordpress, + }, + { + name: 'tab2', + title: 'Tab 2', + icon: link, + }, + { + name: 'tab3', + title: 'Tab 3', + icon: more, + }, + ], +}; + +export const ManualActivation = Template.bind( {} ); +ManualActivation.args = { + ...Default.args, + selectOnMove: false, +}; diff --git a/packages/components/src/tab-panel/stories/index.tsx b/packages/components/src/tab-panel/stories/index.tsx deleted file mode 100644 index 07c076e1ce621a..00000000000000 --- a/packages/components/src/tab-panel/stories/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { link, more, wordpress } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import TabPanel from '..'; -import Popover from '../../popover'; -import { Provider as SlotFillProvider } from '../../slot-fill'; - -const meta: ComponentMeta< typeof TabPanel > = { - title: 'Components/TabPanel', - component: TabPanel, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof TabPanel > = ( props ) => { - return <TabPanel { ...props } />; -}; - -export const Default = Template.bind( {} ); -Default.args = { - children: ( tab ) => <p>Selected tab: { tab.title }</p>, - tabs: [ - { - name: 'tab1', - title: 'Tab 1', - }, - { - name: 'tab2', - title: 'Tab 2', - }, - ], -}; - -export const DisabledTab = Template.bind( {} ); -DisabledTab.args = { - children: ( tab ) => <p>Selected tab: { tab.title }</p>, - tabs: [ - { - name: 'tab1', - title: 'Tab 1', - disabled: true, - }, - { - name: 'tab2', - title: 'Tab 2', - }, - { - name: 'tab3', - title: 'Tab 3', - }, - ], -}; - -const SlotFillTemplate: ComponentStory< typeof TabPanel > = ( props ) => { - return ( - <SlotFillProvider> - <TabPanel { ...props } /> - { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } - <Popover.Slot /> - </SlotFillProvider> - ); -}; - -export const WithTabIconsAndTooltips = SlotFillTemplate.bind( {} ); -WithTabIconsAndTooltips.args = { - children: ( tab ) => <p>Selected tab: { tab.title }</p>, - tabs: [ - { - name: 'tab1', - title: 'Tab 1', - icon: wordpress, - }, - { - name: 'tab2', - title: 'Tab 2', - icon: link, - }, - { - name: 'tab3', - title: 'Tab 3', - icon: more, - }, - ], -}; diff --git a/packages/components/src/tab-panel/test/index.tsx b/packages/components/src/tab-panel/test/index.tsx index 16f88ee8a41e98..723ed4d17ff2d7 100644 --- a/packages/components/src/tab-panel/test/index.tsx +++ b/packages/components/src/tab-panel/test/index.tsx @@ -13,8 +13,6 @@ import { wordpress, category, media } from '@wordpress/icons'; * Internal dependencies */ import TabPanel from '..'; -import Popover from '../../popover'; -import { Provider as SlotFillProvider } from '../../slot-fill'; const TABS = [ { @@ -34,7 +32,8 @@ const TABS = [ }, ]; -const getSelectedTab = () => screen.getByRole( 'tab', { selected: true } ); +const getSelectedTab = async () => + await screen.findByRole( 'tab', { selected: true } ); let originalGetClientRects: () => DOMRectList; @@ -62,7 +61,7 @@ describe.each( [ } ); describe( 'Accessibility and semantics', () => { - test( 'should use the correct aria attributes', () => { + it( 'should use the correct aria attributes', async () => { const panelRenderFunction = jest.fn(); render( @@ -71,7 +70,7 @@ describe.each( [ const tabList = screen.getByRole( 'tablist' ); const allTabs = screen.getAllByRole( 'tab' ); - const selectedTabPanel = screen.getByRole( 'tabpanel' ); + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); expect( tabList ).toBeVisible(); expect( tabList ).toHaveAttribute( @@ -94,7 +93,7 @@ describe.each( [ ); } ); - test( 'should display a tooltip when hovering tabs provided with an icon', async () => { + it( 'should display a tooltip when hovering tabs provided with an icon', async () => { const user = userEvent.setup(); const panelRenderFunction = jest.fn(); @@ -106,17 +105,10 @@ describe.each( [ ]; render( - // In order for the tooltip to display properly, there needs to be - // `Popover.Slot` in which the `Popover` renders outside of the - // `TabPanel` component, otherwise the tooltip renders inline. - <SlotFillProvider> - <Component - tabs={ TABS_WITH_ICON } - children={ panelRenderFunction } - /> - { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } - <Popover.Slot /> - </SlotFillProvider> + <Component + tabs={ TABS_WITH_ICON } + children={ panelRenderFunction } + /> ); const allTabs = screen.getAllByRole( 'tab' ); @@ -138,7 +130,7 @@ describe.each( [ } } ); - test( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => { + it( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => { const user = userEvent.setup(); const mockOnSelect = jest.fn(); @@ -151,24 +143,17 @@ describe.each( [ ]; render( - // In order for the tooltip to display properly, there needs to be - // `Popover.Slot` in which the `Popover` renders outside of the - // `TabPanel` component, otherwise the tooltip renders inline. - <SlotFillProvider> - <Component - tabs={ TABS_WITH_ICON } - children={ panelRenderFunction } - onSelect={ mockOnSelect } - /> - { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } - <Popover.Slot /> - </SlotFillProvider> - ); - - expect( getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); + <Component + tabs={ TABS_WITH_ICON } + children={ panelRenderFunction } + onSelect={ mockOnSelect } + /> + ); + + expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).not.toHaveFocus(); // Tab to focus the tablist. Make sure alpha is focused, and that the // corresponding tooltip is shown. @@ -176,7 +161,7 @@ describe.each( [ await user.keyboard( '[Tab]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -185,7 +170,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure gamma is focused, and that // the corresponding tooltip is shown. @@ -194,7 +179,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -203,7 +188,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); } ); } ); @@ -215,11 +200,10 @@ describe.each( [ <Component tabs={ TABS } children={ panelRenderFunction } /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); } ); it( 'should fall back to first enabled tab if the active tab is removed', async () => { @@ -239,12 +223,12 @@ describe.each( [ onSelect={ mockOnSelect } /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); } ); describe( 'With `initialTabName`', () => { - it( 'should render the tab set by initialTabName prop', () => { + it( 'should render the tab set by initialTabName prop', async () => { render( <Component initialTabName="beta" @@ -253,7 +237,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); it( 'should not select a tab when `initialTabName` does not match any known tab', () => { @@ -273,8 +257,7 @@ describe.each( [ // No tabpanel should be rendered either expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); } ); - - it( 'should not change tabs when initialTabName is changed', () => { + it( 'should not change tabs when initialTabName is changed', async () => { const { rerender } = render( <Component initialTabName="beta" @@ -291,7 +274,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); it( 'should fall back to the tab associated to `initialTabName` if the currently active tab is removed', async () => { @@ -307,13 +290,11 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); rerender( @@ -325,12 +306,11 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); - it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', () => { + it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -342,7 +322,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -362,7 +342,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); - it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', () => { + it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( <Component @@ -394,7 +374,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Delta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); } ); } ); @@ -433,7 +413,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); - it( 'should select first enabled tab when the initial tab is disabled', () => { + it( 'should select first enabled tab when the initial tab is disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -452,7 +432,7 @@ describe.each( [ // As alpha (first tab) is disabled, // the first enabled tab should be gamma. - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); // Re-enable all tabs rerender( @@ -465,10 +445,10 @@ describe.each( [ // Even if the initial tab becomes enabled again, the selected tab doesn't // change. - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); - it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', () => { + it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -487,7 +467,7 @@ describe.each( [ // As alpha (first tab), and beta (the initial tab), are both // disabled the first enabled tab should be gamma. - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); // Re-enable all tabs rerender( @@ -501,10 +481,10 @@ describe.each( [ // Even if the initial tab becomes enabled again, the selected tab doesn't // change. - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); } ); - it( 'should select the first enabled tab when the selected tab becomes disabled', () => { + it( 'should select the first enabled tab when the selected tab becomes disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( <Component @@ -514,7 +494,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -531,7 +511,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -543,12 +523,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); } ); - it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', () => { + it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -560,7 +540,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -577,7 +557,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -590,7 +570,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); } ); } ); @@ -610,31 +590,28 @@ describe.each( [ ); // Alpha is the initially selected tab - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Click on Beta, make sure beta is the selected tab await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( screen.getByRole( 'tabpanel', { name: 'Beta' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 1 ] ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Click on Alpha, make sure beta is the selected tab await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( screen.getByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -654,24 +631,24 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. await user.keyboard( '[ArrowRight]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. await user.keyboard( '[ArrowLeft]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -692,24 +669,24 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure Alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Navigate backwards with arrow keys and make sure that the Gamma tab // (the last tab) is selected automatically. await user.keyboard( '[ArrowLeft]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); // Navigate forward with arrow keys. Make sure alpha (the first tab) is // selected automatically. await user.keyboard( '[ArrowRight]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -730,22 +707,22 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Press the arrow up key, nothing happens. await user.keyboard( '[ArrowUp]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Press the arrow down key, nothing happens await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -766,38 +743,38 @@ describe.each( [ ); // Make sure alpha is still focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. await user.keyboard( '[ArrowUp]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. await user.keyboard( '[ArrowUp]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -826,10 +803,10 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure Alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Press the right arrow key three times. Since the delta tab is disabled: @@ -838,26 +815,48 @@ describe.each( [ // `mockOnSelect` function gets called only twice (and not three times) // - it will receive focus, when using arrow keys await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( screen.getByRole( 'tab', { name: 'Delta' } ) ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); // Navigate backwards with arrow keys. The gamma tab receives focus. + // The `mockOnSelect` callback doesn't fire, since the gamma tab was + // already selected. await user.keyboard( '[ArrowLeft]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - // Click on on the disabled tab. Compared to using arrow keys to move the + // Click on the disabled tab. Compared to using arrow keys to move the // focus, disabled tabs ignore pointer clicks — and therefore, they don't // receive focus, nor they cause the `mockOnSelect` function to fire. await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'should not focus the next tab when the Tab key is pressed', async () => { + const user = userEvent.setup(); + + render( <Component tabs={ TABS } children={ () => undefined } /> ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + + // Because all other tabs should have `tabindex=-1`, pressing Tab + // should NOT move the focus to the next tab, which is Beta. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).not.toHaveFocus(); } ); it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { @@ -873,47 +872,51 @@ describe.each( [ /> ); - // onSelect gets called on the initial render. + // onSelect gets called on the initial render with the default + // selected tab. expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Click on Alpha and make sure it is selected. + // onSelect shouldn't fire since the selected tab didn't change. await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Navigate forward with arrow keys. Make sure Beta is focused, but // that the tab selection happens only when pressing the spacebar // or enter key. await user.keyboard( '[ArrowRight]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); await user.keyboard( '[Enter]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Navigate forward with arrow keys. Make sure Gamma (last tab) is // focused, but that tab selection happens only when pressing the // spacebar or enter key. await user.keyboard( '[ArrowRight]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); await user.keyboard( '[Space]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); } ); describe( 'Tab Attributes', () => { - it( "should apply the tab's `className` to the tab button", () => { + it( "should apply the tab's `className` to the tab button", async () => { render( <Component tabs={ TABS } children={ () => undefined } /> ); - expect( screen.getByRole( 'tab', { name: 'Alpha' } ) ).toHaveClass( - 'alpha-class' - ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( 'beta-class' ); @@ -935,8 +938,8 @@ describe.each( [ ); // Make sure that only the selected tab has the active class - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( getSelectedTab() ).toHaveClass( activeClass ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); screen .getAllByRole( 'tab', { selected: false } ) .forEach( ( unselectedTab ) => { @@ -947,8 +950,8 @@ describe.each( [ await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); // Make sure that only the selected tab has the active class - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( getSelectedTab() ).toHaveClass( activeClass ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); screen .getAllByRole( 'tab', { selected: false } ) .forEach( ( unselectedTab ) => { diff --git a/packages/components/src/tab-panel/types.ts b/packages/components/src/tab-panel/types.ts index 4bef866923ebca..1f4dc7c677483a 100644 --- a/packages/components/src/tab-panel/types.ts +++ b/packages/components/src/tab-panel/types.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { MouseEvent, ReactNode } from 'react'; +import type { ReactNode } from 'react'; /** * Internal dependencies @@ -31,15 +31,6 @@ type Tab = { disabled?: boolean; } & Record< any, any >; -export type TabButtonProps = { - children: ReactNode; - label?: string; - onClick: ( event: MouseEvent ) => void; - selected: boolean; - showTooltip?: boolean; - tabId: string; -} & Pick< Tab, 'className' | 'icon' | 'disabled' >; - export type TabPanelProps = { /** * The class name to add to the active tab. diff --git a/packages/components/src/text-control/index.tsx b/packages/components/src/text-control/index.tsx index 15d792489ba99e..34c9028c1cb8bc 100644 --- a/packages/components/src/text-control/index.tsx +++ b/packages/components/src/text-control/index.tsx @@ -26,13 +26,13 @@ function UnforwardedTextControl( hideLabelFromVision, value, help, + id: idProp, className, onChange, type = 'text', ...additionalProps } = props; - const instanceId = useInstanceId( TextControl ); - const id = `inspector-text-control-${ instanceId }`; + const id = useInstanceId( TextControl, 'inspector-text-control', idProp ); const onChangeValue = ( event: ChangeEvent< HTMLInputElement > ) => onChange( event.target.value ); diff --git a/packages/components/src/text-control/stories/index.story.tsx b/packages/components/src/text-control/stories/index.story.tsx new file mode 100644 index 00000000000000..ddc8af7a9f2b3a --- /dev/null +++ b/packages/components/src/text-control/stories/index.story.tsx @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TextControl from '..'; + +const meta: Meta< typeof TextControl > = { + component: TextControl, + title: 'Components/TextControl', + argTypes: { + help: { control: { type: 'text' } }, + label: { control: { type: 'text' } }, + onChange: { action: 'onChange' }, + value: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const DefaultTemplate: StoryFn< typeof TextControl > = ( { + onChange, + ...args +} ) => { + const [ value, setValue ] = useState( '' ); + + return ( + <TextControl + { ...args } + value={ value } + onChange={ ( v ) => { + setValue( v ); + onChange( v ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof TextControl > = DefaultTemplate.bind( + {} +); +Default.args = {}; + +export const WithLabelAndHelpText: StoryFn< typeof TextControl > = + DefaultTemplate.bind( {} ); +WithLabelAndHelpText.args = { + ...Default.args, + label: 'Label Text', + help: 'Help text to explain the input.', +}; diff --git a/packages/components/src/text-control/stories/index.tsx b/packages/components/src/text-control/stories/index.tsx deleted file mode 100644 index d2b08835d631e5..00000000000000 --- a/packages/components/src/text-control/stories/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import TextControl from '..'; - -const meta: ComponentMeta< typeof TextControl > = { - component: TextControl, - title: 'Components/TextControl', - argTypes: { - help: { control: { type: 'text' } }, - label: { control: { type: 'text' } }, - onChange: { action: 'onChange' }, - value: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const DefaultTemplate: ComponentStory< typeof TextControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState( '' ); - - return ( - <TextControl - { ...args } - value={ value } - onChange={ ( v ) => { - setValue( v ); - onChange( v ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof TextControl > = - DefaultTemplate.bind( {} ); -Default.args = {}; - -export const WithLabelAndHelpText: ComponentStory< typeof TextControl > = - DefaultTemplate.bind( {} ); -WithLabelAndHelpText.args = { - ...Default.args, - label: 'Label Text', - help: 'Help text to explain the input.', -}; diff --git a/packages/components/src/text-control/test/text-control.tsx b/packages/components/src/text-control/test/text-control.tsx new file mode 100644 index 00000000000000..fc048b93992f08 --- /dev/null +++ b/packages/components/src/text-control/test/text-control.tsx @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import TextControl from '..'; + +const noop = () => {}; + +describe( 'TextControl', () => { + describe( 'When no ID prop is provided', () => { + it( 'should generate an ID', () => { + render( <TextControl onChange={ noop } value="" /> ); + + expect( screen.getByRole( 'textbox' ) ).toHaveAttribute( + 'id', + expect.stringMatching( /^inspector-text-control-/ ) + ); + } ); + + it( 'should be labelled correctly', () => { + const labelValue = 'Test Label'; + render( + <TextControl label={ labelValue } onChange={ noop } value="" /> + ); + + expect( + screen.getByRole( 'textbox', { name: labelValue } ) + ).toBeVisible(); + } ); + } ); + + describe( 'When an ID prop is provided', () => { + const id = 'test-id'; + + it( 'should use the passed ID prop if provided', () => { + render( <TextControl id={ id } onChange={ noop } value="" /> ); + + expect( screen.getByRole( 'textbox' ) ).toHaveAttribute( 'id', id ); + } ); + + it( 'should be labelled correctly', () => { + const labelValue = 'Test Label'; + render( + <TextControl + label={ labelValue } + id={ id } + onChange={ noop } + value="" + /> + ); + + expect( + screen.getByRole( 'textbox', { name: labelValue } ) + ).toBeVisible(); + } ); + } ); +} ); diff --git a/packages/components/src/text-highlight/stories/index.story.tsx b/packages/components/src/text-highlight/stories/index.story.tsx new file mode 100644 index 00000000000000..d54149d8e19d3c --- /dev/null +++ b/packages/components/src/text-highlight/stories/index.story.tsx @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import TextHighlight from '..'; + +const meta: Meta< typeof TextHighlight > = { + component: TextHighlight, + title: 'Components/TextHighlight', + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof TextHighlight > = ( args ) => { + return <TextHighlight { ...args } />; +}; + +export const Default: StoryFn< typeof TextHighlight > = Template.bind( {} ); +Default.args = { + text: 'We call the new editor Gutenberg. The entire editing experience has been rebuilt for media rich pages and posts.', + highlight: 'Gutenberg', +}; diff --git a/packages/components/src/text-highlight/stories/index.tsx b/packages/components/src/text-highlight/stories/index.tsx deleted file mode 100644 index e844d402f5d6eb..00000000000000 --- a/packages/components/src/text-highlight/stories/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import TextHighlight from '..'; - -const meta: ComponentMeta< typeof TextHighlight > = { - component: TextHighlight, - title: 'Components/TextHighlight', - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof TextHighlight > = ( args ) => { - return <TextHighlight { ...args } />; -}; - -export const Default: ComponentStory< typeof TextHighlight > = Template.bind( - {} -); -Default.args = { - text: 'We call the new editor Gutenberg. The entire editing experience has been rebuilt for media rich pages and posts.', - highlight: 'Gutenberg', -}; diff --git a/packages/components/src/text/stories/index.js b/packages/components/src/text/stories/index.js deleted file mode 100644 index a1cdf917b98c9e..00000000000000 --- a/packages/components/src/text/stories/index.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Internal dependencies - */ -import { Text } from '..'; - -export default { - component: Text, - title: 'Components (Experimental)/Text', -}; - -export const _default = () => { - return <Text>Hello</Text>; -}; - -export const truncate = () => { - return ( - <Text numberOfLines={ 2 } truncate> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut - facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt. - Duis semper dui id augue malesuada, ut feugiat nisi aliquam. - Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla - facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel. - In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis - arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque - eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada - ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat - risus. Vivamus iaculis dui aliquet ante ultricies feugiat. - Vestibulum ante ipsum primis in faucibus orci luctus et ultrices - posuere cubilia curae; Vivamus nec pretium velit, sit amet - consectetur ante. Praesent porttitor ex eget fermentum mattis. - </Text> - ); -}; - -export const highlight = () => { - return ( - <Text highlightWords={ [ 'con' ] }> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut - facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt. - Duis semper dui id augue malesuada, ut feugiat nisi aliquam. - Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla - facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel. - In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis - arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque - eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada - ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat - risus. Vivamus iaculis dui aliquet ante ultricies feugiat. - Vestibulum ante ipsum primis in faucibus orci luctus et ultrices - posuere cubilia curae; Vivamus nec pretium velit, sit amet - consectetur ante. Praesent porttitor ex eget fermentum mattis. - </Text> - ); -}; diff --git a/packages/components/src/text/stories/index.story.js b/packages/components/src/text/stories/index.story.js new file mode 100644 index 00000000000000..b1b4e3f455536b --- /dev/null +++ b/packages/components/src/text/stories/index.story.js @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ +import { Text } from '..'; + +export default { + component: Text, + title: 'Components (Experimental)/Text', +}; + +export const _default = () => { + return <Text>Hello</Text>; +}; + +export const Truncate = () => { + return ( + <Text numberOfLines={ 2 } truncate> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut + facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt. + Duis semper dui id augue malesuada, ut feugiat nisi aliquam. + Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla + facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel. + In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis + arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque + eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada + ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat + risus. Vivamus iaculis dui aliquet ante ultricies feugiat. + Vestibulum ante ipsum primis in faucibus orci luctus et ultrices + posuere cubilia curae; Vivamus nec pretium velit, sit amet + consectetur ante. Praesent porttitor ex eget fermentum mattis. + </Text> + ); +}; + +export const Highlight = () => { + return ( + <Text highlightWords={ [ 'con' ] }> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut + facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt. + Duis semper dui id augue malesuada, ut feugiat nisi aliquam. + Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla + facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel. + In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis + arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque + eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada + ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat + risus. Vivamus iaculis dui aliquet ante ultricies feugiat. + Vestibulum ante ipsum primis in faucibus orci luctus et ultrices + posuere cubilia curae; Vivamus nec pretium velit, sit amet + consectetur ante. Praesent porttitor ex eget fermentum mattis. + </Text> + ); +}; diff --git a/packages/components/src/text/test/index.tsx b/packages/components/src/text/test/index.tsx index ac7dc907211ea3..7147c887970cdc 100644 --- a/packages/components/src/text/test/index.tsx +++ b/packages/components/src/text/test/index.tsx @@ -104,7 +104,7 @@ describe( 'Text', () => { </Text> ); expect( screen.getByRole( 'heading' ) ).toHaveStyle( { - color: 'orange', + color: 'rgb(255, 165, 0)', } ); } ); diff --git a/packages/components/src/textarea-control/stories/index.story.tsx b/packages/components/src/textarea-control/stories/index.story.tsx new file mode 100644 index 00000000000000..e227519a069383 --- /dev/null +++ b/packages/components/src/textarea-control/stories/index.story.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TextareaControl from '..'; + +const meta: Meta< typeof TextareaControl > = { + component: TextareaControl, + title: 'Components/TextareaControl', + argTypes: { + onChange: { action: 'onChange' }, + label: { control: { type: 'text' } }, + help: { control: { type: 'text' } }, + value: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof TextareaControl > = ( { + onChange, + ...args +} ) => { + const [ value, setValue ] = useState( '' ); + + return ( + <TextareaControl + { ...args } + value={ value } + onChange={ ( v ) => { + setValue( v ); + onChange( v ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof TextareaControl > = Template.bind( {} ); +Default.args = { + label: 'Text', + help: 'Enter some text', +}; diff --git a/packages/components/src/textarea-control/stories/index.tsx b/packages/components/src/textarea-control/stories/index.tsx deleted file mode 100644 index 4d6a5f9ceaacbf..00000000000000 --- a/packages/components/src/textarea-control/stories/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import TextareaControl from '..'; - -const meta: ComponentMeta< typeof TextareaControl > = { - component: TextareaControl, - title: 'Components/TextareaControl', - argTypes: { - onChange: { action: 'onChange' }, - label: { control: { type: 'text' } }, - help: { control: { type: 'text' } }, - value: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof TextareaControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState( '' ); - - return ( - <TextareaControl - { ...args } - value={ value } - onChange={ ( v ) => { - setValue( v ); - onChange( v ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof TextareaControl > = Template.bind( - {} -); -Default.args = { - label: 'Text', - help: 'Enter some text', -}; diff --git a/packages/components/src/theme/README.md b/packages/components/src/theme/README.md index 2cc8487dd687e1..d1bfe237c9608f 100644 --- a/packages/components/src/theme/README.md +++ b/packages/components/src/theme/README.md @@ -8,23 +8,6 @@ This feature is still experimental. “Experimental” means this is an early im Multiple `Theme` components can be nested in order to override specific theme variables. -## Usage - -```jsx -import { __experimentalTheme as Theme } from '@wordpress/components'; - -const Example = () => { - return ( - <Theme accent="red"> - <Button variant="primary">I'm red</Button> - <Theme accent="blue" background="black"> - <Button variant="primary">I'm blue</Button> - </Theme> - </Theme> - ); -}; -``` - ## Props ### `accent`: `string` diff --git a/packages/components/src/theme/index.tsx b/packages/components/src/theme/index.tsx index 591da45e7c14d9..ce1e11246e0d3c 100644 --- a/packages/components/src/theme/index.tsx +++ b/packages/components/src/theme/index.tsx @@ -18,10 +18,7 @@ import { useCx } from '../utils'; * Multiple `Theme` components can be nested in order to override specific theme variables. * * - * @example * ```jsx - * import { __experimentalTheme as Theme } from '@wordpress/components'; - * * const Example = () => { * return ( * <Theme accent="red"> diff --git a/packages/components/src/theme/stories/index.story.tsx b/packages/components/src/theme/stories/index.story.tsx new file mode 100644 index 00000000000000..3d52dea7fba57b --- /dev/null +++ b/packages/components/src/theme/stories/index.story.tsx @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Theme from '../index'; +import Button from '../../button'; +import { generateThemeVariables, checkContrasts } from '../color-algorithms'; +import { HStack } from '../../h-stack'; + +const meta: Meta< typeof Theme > = { + component: Theme, + title: 'Components (Experimental)/Theme', + argTypes: { + accent: { control: { type: 'color' } }, + background: { control: { type: 'color' } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Theme > = ( args ) => ( + <Theme { ...args }> + <Button variant="primary">Hello</Button> + </Theme> +); + +export const Default = Template.bind( {} ); +Default.args = {}; + +export const Nested: StoryFn< typeof Theme > = ( args ) => ( + <Theme accent="tomato"> + <Button variant="primary">Outer theme (hardcoded)</Button> + + <Theme { ...args }> + <Button variant="primary"> + Inner theme (set via Storybook controls) + </Button> + </Theme> + </Theme> +); +Nested.args = { + accent: 'blue', +}; + +/** + * The rest of the required colors are generated based on the given accent and background colors. + */ +export const ColorScheme: StoryFn< typeof Theme > = ( { + accent, + background, +} ) => { + const { colors } = generateThemeVariables( { accent, background } ); + const { gray, ...otherColors } = colors; + const contrastIssues = Object.entries( + checkContrasts( { accent, background }, colors ) + ).filter( ( [ _, error ] ) => !! error ); + + const Chip = ( { color, name }: { color: string; name: string } ) => ( + <HStack justify="flex-start"> + <div + style={ { + backgroundColor: color, + height: '1.25em', + width: 40, + } } + /> + <div style={ { fontSize: 14 } }>{ name }</div> + </HStack> + ); + + return ( + <> + { Object.entries( otherColors ).map( ( [ key, value ] ) => ( + <Chip color={ value } name={ key } key={ key } /> + ) ) } + { Object.entries( gray as NonNullable< typeof gray > ).map( + ( [ key, value ] ) => ( + <Chip + color={ value } + name={ `gray ${ key }` } + key={ key } + /> + ) + ) } + { !! contrastIssues.length && ( + <> + <h2>Contrast issues</h2> + <ul> + { contrastIssues.map( ( [ key, error ] ) => ( + <li key={ key }>{ error }</li> + ) ) } + </ul> + </> + ) } + </> + ); +}; +ColorScheme.args = { + accent: '#3858e9', + background: '#fff', +}; +ColorScheme.argTypes = { + children: { table: { disable: true } }, +}; +ColorScheme.parameters = { + docs: { canvas: { sourceState: 'hidden' } }, +}; diff --git a/packages/components/src/theme/stories/index.tsx b/packages/components/src/theme/stories/index.tsx deleted file mode 100644 index c9a3f495e48350..00000000000000 --- a/packages/components/src/theme/stories/index.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Theme from '../index'; -import Button from '../../button'; -import { generateThemeVariables, checkContrasts } from '../color-algorithms'; -import { HStack } from '../../h-stack'; - -const meta: ComponentMeta< typeof Theme > = { - component: Theme, - title: 'Components (Experimental)/Theme', - argTypes: { - accent: { control: { type: 'color' } }, - background: { control: { type: 'color' } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Theme > = ( args ) => ( - <Theme { ...args }> - <Button variant="primary">Hello</Button> - </Theme> -); - -export const Default = Template.bind( {} ); -Default.args = {}; - -export const Nested: ComponentStory< typeof Theme > = ( args ) => ( - <Theme accent="tomato"> - <Button variant="primary">Outer theme (hardcoded)</Button> - - <Theme { ...args }> - <Button variant="primary"> - Inner theme (set via Storybook controls) - </Button> - </Theme> - </Theme> -); -Nested.args = { - accent: 'blue', -}; - -/** - * The rest of the required colors are generated based on the given accent and background colors. - */ -export const ColorScheme: ComponentStory< typeof Theme > = ( { - accent, - background, -} ) => { - const { colors } = generateThemeVariables( { accent, background } ); - const { gray, ...otherColors } = colors; - const contrastIssues = Object.entries( - checkContrasts( { accent, background }, colors ) - ).filter( ( [ _, error ] ) => !! error ); - - const Chip = ( { color, name }: { color: string; name: string } ) => ( - <HStack justify="flex-start"> - <div - style={ { - backgroundColor: color, - height: '1.25em', - width: 40, - } } - /> - <div style={ { fontSize: 14 } }>{ name }</div> - </HStack> - ); - - return ( - <> - { Object.entries( otherColors ).map( ( [ key, value ] ) => ( - <Chip color={ value } name={ key } key={ key } /> - ) ) } - { Object.entries( gray as NonNullable< typeof gray > ).map( - ( [ key, value ] ) => ( - <Chip - color={ value } - name={ `gray ${ key }` } - key={ key } - /> - ) - ) } - { !! contrastIssues.length && ( - <> - <h2>Contrast issues</h2> - <ul> - { contrastIssues.map( ( [ key, error ] ) => ( - <li key={ key }>{ error }</li> - ) ) } - </ul> - </> - ) } - </> - ); -}; -ColorScheme.args = { - accent: '#3858e9', - background: '#fff', -}; -ColorScheme.argTypes = { - children: { table: { disable: true } }, -}; -ColorScheme.parameters = { - docs: { source: { state: 'closed' } }, -}; diff --git a/packages/components/src/tip/stories/index.story.tsx b/packages/components/src/tip/stories/index.story.tsx new file mode 100644 index 00000000000000..3999c6b9be45f4 --- /dev/null +++ b/packages/components/src/tip/stories/index.story.tsx @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Tip from '..'; + +const meta: Meta< typeof Tip > = { + component: Tip, + title: 'Components/Tip', + argTypes: { + children: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Tip > = ( args ) => { + return <Tip { ...args } />; +}; + +export const Default: StoryFn< typeof Tip > = Template.bind( {} ); +Default.args = { + children: 'An example tip', +}; diff --git a/packages/components/src/tip/stories/index.tsx b/packages/components/src/tip/stories/index.tsx deleted file mode 100644 index 4a251a34b20465..00000000000000 --- a/packages/components/src/tip/stories/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import Tip from '..'; - -const meta: ComponentMeta< typeof Tip > = { - component: Tip, - title: 'Components/Tip', - argTypes: { - children: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof Tip > = ( args ) => { - return <Tip { ...args } />; -}; - -export const Default: ComponentStory< typeof Tip > = Template.bind( {} ); -Default.args = { - children: 'An example tip', -}; diff --git a/packages/components/src/toggle-control/stories/index.story.tsx b/packages/components/src/toggle-control/stories/index.story.tsx new file mode 100644 index 00000000000000..b8043b8f48e523 --- /dev/null +++ b/packages/components/src/toggle-control/stories/index.story.tsx @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ToggleControl from '..'; + +const meta: Meta< typeof ToggleControl > = { + title: 'Components/ToggleControl', + component: ToggleControl, + argTypes: { + checked: { control: { type: null } }, + help: { control: { type: 'text' } }, + label: { control: { type: 'text' } }, + onChange: { action: 'onChange' }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ToggleControl > = ( { + onChange, + ...props +} ) => { + const [ checked, setChecked ] = useState( true ); + return ( + <ToggleControl + { ...props } + checked={ checked } + onChange={ ( ...changeArgs ) => { + setChecked( ...changeArgs ); + onChange( ...changeArgs ); + } } + /> + ); +}; + +export const Default = Template.bind( {} ); +Default.args = { + label: 'Enable something', +}; + +export const WithHelpText = Template.bind( {} ); +WithHelpText.args = { + ...Default.args, + help: 'This is some help text.', +}; diff --git a/packages/components/src/toggle-control/stories/index.tsx b/packages/components/src/toggle-control/stories/index.tsx deleted file mode 100644 index 07cb9303dcba76..00000000000000 --- a/packages/components/src/toggle-control/stories/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import ToggleControl from '..'; - -const meta: ComponentMeta< typeof ToggleControl > = { - title: 'Components/ToggleControl', - component: ToggleControl, - argTypes: { - checked: { control: { type: null } }, - help: { control: { type: 'text' } }, - label: { control: { type: 'text' } }, - onChange: { action: 'onChange' }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ToggleControl > = ( { - onChange, - ...props -} ) => { - const [ checked, setChecked ] = useState( true ); - return ( - <ToggleControl - { ...props } - checked={ checked } - onChange={ ( ...changeArgs ) => { - setChecked( ...changeArgs ); - onChange( ...changeArgs ); - } } - /> - ); -}; - -export const Default = Template.bind( {} ); -Default.args = { - label: 'Enable something', -}; - -export const WithHelpText = Template.bind( {} ); -WithHelpText.args = { - ...Default.args, - help: 'This is some help text.', -}; diff --git a/packages/components/src/toggle-group-control/stories/index.story.tsx b/packages/components/src/toggle-group-control/stories/index.story.tsx new file mode 100644 index 00000000000000..92f1e6076248bd --- /dev/null +++ b/packages/components/src/toggle-group-control/stories/index.story.tsx @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { formatLowercase, formatUppercase } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { + ToggleGroupControl, + ToggleGroupControlOption, + ToggleGroupControlOptionIcon, +} from '../index'; +import type { + ToggleGroupControlOptionProps, + ToggleGroupControlOptionIconProps, + ToggleGroupControlProps, +} from '../types'; + +const meta: Meta< typeof ToggleGroupControl > = { + component: ToggleGroupControl, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { ToggleGroupControlOption, ToggleGroupControlOptionIcon }, + title: 'Components (Experimental)/ToggleGroupControl', + argTypes: { + help: { control: { type: 'text' } }, + onChange: { action: 'onChange' }, + value: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof ToggleGroupControl > = ( { + onChange, + ...props +} ) => { + const [ value, setValue ] = + useState< ToggleGroupControlProps[ 'value' ] >(); + + return ( + <ToggleGroupControl + __nextHasNoMarginBottom + { ...props } + onChange={ ( ...changeArgs ) => { + setValue( ...changeArgs ); + onChange?.( ...changeArgs ); + } } + value={ value } + /> + ); +}; + +const mapPropsToOptionComponent = ( { + value, + ...props +}: ToggleGroupControlOptionProps ) => ( + <ToggleGroupControlOption value={ value } key={ value } { ...props } /> +); + +const mapPropsToOptionIconComponent = ( { + value, + ...props +}: ToggleGroupControlOptionIconProps ) => ( + <ToggleGroupControlOptionIcon value={ value } key={ value } { ...props } /> +); + +export const Default: StoryFn< typeof ToggleGroupControl > = Template.bind( + {} +); +Default.args = { + children: [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, + { value: 'justify', label: 'Justify' }, + ].map( mapPropsToOptionComponent ), + isBlock: true, + label: 'Label', +}; + +/** + * A tooltip can be shown for each option by enabling the `showTooltip` prop. + * The `aria-label` will be used in the tooltip if provided. Otherwise, the + * `label` will be used. + */ +export const WithTooltip: StoryFn< typeof ToggleGroupControl > = Template.bind( + {} +); +WithTooltip.args = { + ...Default.args, + children: [ + { + value: 'asc', + label: 'A→Z', + 'aria-label': 'Ascending', + showTooltip: true, + }, + { + value: 'desc', + label: 'Z→A', + 'aria-label': 'Descending', + showTooltip: true, + }, + ].map( mapPropsToOptionComponent ), +}; + +/** + * The `ToggleGroupControlOptionIcon` component can be used for icon options. A `label` is required + * on each option for accessibility, which will be shown in a tooltip. + */ +export const WithIcons: StoryFn< typeof ToggleGroupControl > = Template.bind( + {} +); +WithIcons.args = { + ...Default.args, + children: [ + { value: 'uppercase', label: 'Uppercase', icon: formatUppercase }, + { value: 'lowercase', label: 'Lowercase', icon: formatLowercase }, + ].map( mapPropsToOptionIconComponent ), + isBlock: false, +}; + +/** + * When the `isDeselectable` prop is true, the option can be deselected by clicking on it again. + */ +export const Deselectable: StoryFn< typeof ToggleGroupControl > = Template.bind( + {} +); +Deselectable.args = { + ...WithIcons.args, + isDeselectable: true, +}; diff --git a/packages/components/src/toggle-group-control/stories/index.tsx b/packages/components/src/toggle-group-control/stories/index.tsx deleted file mode 100644 index 3aee2f9f150d60..00000000000000 --- a/packages/components/src/toggle-group-control/stories/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -import { formatLowercase, formatUppercase } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import { - ToggleGroupControl, - ToggleGroupControlOption, - ToggleGroupControlOptionIcon, -} from '../index'; -import type { - ToggleGroupControlOptionProps, - ToggleGroupControlOptionIconProps, - ToggleGroupControlProps, -} from '../types'; - -const meta: ComponentMeta< typeof ToggleGroupControl > = { - component: ToggleGroupControl, - title: 'Components (Experimental)/ToggleGroupControl', - subcomponents: { ToggleGroupControlOption, ToggleGroupControlOptionIcon }, - argTypes: { - help: { control: { type: 'text' } }, - onChange: { action: 'onChange' }, - value: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof ToggleGroupControl > = ( { - onChange, - ...props -} ) => { - const [ value, setValue ] = - useState< ToggleGroupControlProps[ 'value' ] >(); - - return ( - <ToggleGroupControl - __nextHasNoMarginBottom - { ...props } - onChange={ ( ...changeArgs ) => { - setValue( ...changeArgs ); - onChange?.( ...changeArgs ); - } } - value={ value } - /> - ); -}; - -const mapPropsToOptionComponent = ( { - value, - ...props -}: ToggleGroupControlOptionProps ) => ( - <ToggleGroupControlOption value={ value } key={ value } { ...props } /> -); - -const mapPropsToOptionIconComponent = ( { - value, - ...props -}: ToggleGroupControlOptionIconProps ) => ( - <ToggleGroupControlOptionIcon value={ value } key={ value } { ...props } /> -); - -export const Default: ComponentStory< typeof ToggleGroupControl > = - Template.bind( {} ); -Default.args = { - children: [ - { value: 'left', label: 'Left' }, - { value: 'center', label: 'Center' }, - { value: 'right', label: 'Right' }, - { value: 'justify', label: 'Justify' }, - ].map( mapPropsToOptionComponent ), - isBlock: true, - label: 'Label', -}; - -/** - * A tooltip can be shown for each option by enabling the `showTooltip` prop. - * The `aria-label` will be used in the tooltip if provided. Otherwise, the - * `label` will be used. - */ -export const WithTooltip: ComponentStory< typeof ToggleGroupControl > = - Template.bind( {} ); -WithTooltip.args = { - ...Default.args, - children: [ - { - value: 'asc', - label: 'A→Z', - 'aria-label': 'Ascending', - showTooltip: true, - }, - { - value: 'desc', - label: 'Z→A', - 'aria-label': 'Descending', - showTooltip: true, - }, - ].map( mapPropsToOptionComponent ), -}; - -/** - * The `ToggleGroupControlOptionIcon` component can be used for icon options. A `label` is required - * on each option for accessibility, which will be shown in a tooltip. - */ -export const WithIcons: ComponentStory< typeof ToggleGroupControl > = - Template.bind( {} ); -WithIcons.args = { - ...Default.args, - children: [ - { value: 'uppercase', label: 'Uppercase', icon: formatUppercase }, - { value: 'lowercase', label: 'Lowercase', icon: formatLowercase }, - ].map( mapPropsToOptionIconComponent ), - isBlock: false, -}; - -/** - * When the `isDeselectable` prop is true, the option can be deselected by clicking on it again. - */ -export const Deselectable: ComponentStory< typeof ToggleGroupControl > = - Template.bind( {} ); -Deselectable.args = { - ...WithIcons.args, - isDeselectable: true, -}; diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx index c0ab8f95409f76..eb36f06022eed7 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx @@ -13,11 +13,8 @@ import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ -import { - contextConnect, - useContextSystem, - WordPressComponentProps, -} from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect, useContextSystem } from '../../ui/context'; import type { ToggleGroupControlOptionBaseProps, WithToolTipProps, 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 d4b5320edff616..c3abc1fad24165 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 @@ -83,7 +83,7 @@ const deselectable = css` &:focus { box-shadow: inset 0 0 0 1px ${ COLORS.white }, - 0 0 0 ${ CONFIG.borderWidthFocus } ${ COLORS.ui.theme }; + 0 0 0 ${ CONFIG.borderWidthFocus } ${ COLORS.theme.accent }; outline: 2px solid transparent; } `; 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 ebd4893e37fb2f..f9a65e0aacacd7 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 @@ -11,11 +11,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { - contextConnect, - useContextSystem, - WordPressComponentProps, -} from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect, useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks'; import BaseControl from '../../base-control'; import type { ToggleGroupControlProps } from '../types'; diff --git a/packages/components/src/toolbar/stories/index.story.tsx b/packages/components/src/toolbar/stories/index.story.tsx new file mode 100644 index 00000000000000..643c32b6909e0e --- /dev/null +++ b/packages/components/src/toolbar/stories/index.story.tsx @@ -0,0 +1,185 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { + alignCenter, + alignLeft, + alignRight, + code, + formatBold, + formatItalic, + formatStrikethrough, + link, + more, + paragraph, + arrowUp, + arrowDown, + arrowLeft, + arrowRight, + chevronDown, +} from '@wordpress/icons'; +import { SVG, Path } from '@wordpress/primitives'; + +/** + * Internal dependencies + */ +import { + Toolbar, + ToolbarButton, + ToolbarGroup, + ToolbarItem, + ToolbarDropdownMenu, +} from '..'; +import DropdownMenu from '../../dropdown-menu'; + +const meta: Meta< typeof Toolbar > = { + title: 'Components/Toolbar', + component: Toolbar, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + ToolbarButton, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + ToolbarGroup, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + ToolbarItem, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + ToolbarDropdownMenu, + }, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +function InlineImageIcon() { + return ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M4 18.5h16V17H4v1.5zM16 13v1.5h4V13h-4zM5.1 15h7.8c.6 0 1.1-.5 1.1-1.1V6.1c0-.6-.5-1.1-1.1-1.1H5.1C4.5 5 4 5.5 4 6.1v7.8c0 .6.5 1.1 1.1 1.1zm.4-8.5h7V10l-1-1c-.3-.3-.8-.3-1 0l-1.6 1.5-1.2-.7c-.3-.2-.6-.2-.9 0l-1.3 1V6.5zm0 6.1l1.8-1.3 1.3.8c.3.2.7.2.9-.1l1.5-1.4 1.5 1.4v1.5h-7v-.9z" /> + </SVG> + ); +} + +const Template: StoryFn< typeof Toolbar > = ( props ) => ( + <div style={ { height: 280 } }> + <Toolbar { ...props } /> + </div> +); + +export const Default = Template.bind( {} ); +Default.args = { + label: 'Options', + id: 'options-toolbar', + children: ( + <> + <ToolbarGroup> + <ToolbarButton icon={ paragraph } text="Paragraph" /> + </ToolbarGroup> + <ToolbarGroup> + <ToolbarItem> + { ( toggleProps ) => ( + <DropdownMenu + icon={ alignLeft } + label="Align" + controls={ [ + { + icon: alignLeft, + title: 'Align left', + isActive: true, + }, + { + icon: alignCenter, + title: 'Align center', + }, + { + icon: alignRight, + title: 'Align right', + }, + ] } + toggleProps={ toggleProps } + /> + ) } + </ToolbarItem> + </ToolbarGroup> + <ToolbarGroup> + <ToolbarButton>Text</ToolbarButton> + <ToolbarButton icon={ formatBold } label="Bold" isPressed /> + <ToolbarButton icon={ formatItalic } label="Italic" /> + <ToolbarButton icon={ link } label="Link" /> + <ToolbarGroup + isCollapsed + // @ts-expect-error TODO: Remove when ToolbarGroup is typed + icon={ false } + label="More rich text controls" + controls={ [ + { icon: code, title: 'Inline code' }, + { icon: <InlineImageIcon />, title: 'Inline image' }, + { + icon: formatStrikethrough, + title: 'Strikethrough', + }, + ] } + /> + </ToolbarGroup> + <ToolbarGroup + // @ts-expect-error TODO: Remove when ToolbarGroup is typed + icon={ more } + label="Align" + isCollapsed + controls={ [ + { + icon: alignLeft, + title: 'Align left', + isActive: true, + }, + { icon: alignCenter, title: 'Align center' }, + { icon: alignRight, title: 'Align right' }, + ] } + /> + <ToolbarDropdownMenu + icon={ chevronDown } + label="Select a direction" + controls={ [ + { + title: 'Up', + icon: arrowUp, + }, + { + title: 'Right', + icon: arrowRight, + }, + { + title: 'Down', + icon: arrowDown, + }, + { + title: 'Left', + icon: arrowLeft, + }, + ] } + /> + </> + ), +}; + +export const WithoutGroup = Template.bind( {} ); +WithoutGroup.args = { + label: 'Options', + id: 'options-toolbar-without-group', + children: ( + <> + <ToolbarButton icon={ formatBold } label="Bold" isPressed /> + <ToolbarButton icon={ formatItalic } label="Italic" /> + <ToolbarButton icon={ link } label="Link" /> + </> + ), +}; diff --git a/packages/components/src/toolbar/stories/index.tsx b/packages/components/src/toolbar/stories/index.tsx deleted file mode 100644 index 17a8e64111eb23..00000000000000 --- a/packages/components/src/toolbar/stories/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { - alignCenter, - alignLeft, - alignRight, - code, - formatBold, - formatItalic, - formatStrikethrough, - link, - more, - paragraph, - arrowUp, - arrowDown, - arrowLeft, - arrowRight, - chevronDown, -} from '@wordpress/icons'; -import { SVG, Path } from '@wordpress/primitives'; - -/** - * Internal dependencies - */ -import { - Toolbar, - ToolbarButton, - ToolbarGroup, - ToolbarItem, - ToolbarDropdownMenu, -} from '..'; -import DropdownMenu from '../../dropdown-menu'; - -const meta: ComponentMeta< typeof Toolbar > = { - title: 'Components/Toolbar', - component: Toolbar, - subcomponents: { - ToolbarButton, - ToolbarGroup, - ToolbarItem, - ToolbarDropdownMenu, - }, - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -function InlineImageIcon() { - return ( - <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M4 18.5h16V17H4v1.5zM16 13v1.5h4V13h-4zM5.1 15h7.8c.6 0 1.1-.5 1.1-1.1V6.1c0-.6-.5-1.1-1.1-1.1H5.1C4.5 5 4 5.5 4 6.1v7.8c0 .6.5 1.1 1.1 1.1zm.4-8.5h7V10l-1-1c-.3-.3-.8-.3-1 0l-1.6 1.5-1.2-.7c-.3-.2-.6-.2-.9 0l-1.3 1V6.5zm0 6.1l1.8-1.3 1.3.8c.3.2.7.2.9-.1l1.5-1.4 1.5 1.4v1.5h-7v-.9z" /> - </SVG> - ); -} - -const Template: ComponentStory< typeof Toolbar > = ( props ) => ( - <div style={ { height: 280 } }> - <Toolbar { ...props } /> - </div> -); - -export const Default = Template.bind( {} ); -Default.args = { - label: 'Options', - id: 'options-toolbar', - children: ( - <> - <ToolbarGroup> - <ToolbarButton icon={ paragraph } text="Paragraph" /> - </ToolbarGroup> - <ToolbarGroup> - <ToolbarItem> - { /* There is an issue here with TS not recognizing the - * `RenderProp` being passed. - * @ts-expect-error */ } - { ( toggleProps ) => ( - <DropdownMenu - icon={ alignLeft } - label="Align" - controls={ [ - { - icon: alignLeft, - title: 'Align left', - isActive: true, - }, - { - icon: alignCenter, - title: 'Align center', - }, - { - icon: alignRight, - title: 'Align right', - }, - ] } - toggleProps={ toggleProps } - /> - ) } - </ToolbarItem> - </ToolbarGroup> - <ToolbarGroup> - <ToolbarButton>Text</ToolbarButton> - <ToolbarButton icon={ formatBold } label="Bold" isPressed /> - <ToolbarButton icon={ formatItalic } label="Italic" /> - <ToolbarButton icon={ link } label="Link" /> - <ToolbarGroup - isCollapsed - // @ts-expect-error TODO: Remove when ToolbarGroup is typed - icon={ false } - label="More rich text controls" - controls={ [ - { icon: code, title: 'Inline code' }, - { icon: <InlineImageIcon />, title: 'Inline image' }, - { - icon: formatStrikethrough, - title: 'Strikethrough', - }, - ] } - /> - </ToolbarGroup> - <ToolbarGroup - // @ts-expect-error TODO: Remove when ToolbarGroup is typed - icon={ more } - label="Align" - isCollapsed - controls={ [ - { - icon: alignLeft, - title: 'Align left', - isActive: true, - }, - { icon: alignCenter, title: 'Align center' }, - { icon: alignRight, title: 'Align right' }, - ] } - /> - <ToolbarDropdownMenu - icon={ chevronDown } - label="Select a direction" - controls={ [ - { - title: 'Up', - icon: arrowUp, - }, - { - title: 'Right', - icon: arrowRight, - }, - { - title: 'Down', - icon: arrowDown, - }, - { - title: 'Left', - icon: arrowLeft, - }, - ] } - /> - </> - ), -}; - -export const WithoutGroup = Template.bind( {} ); -WithoutGroup.args = { - label: 'Options', - id: 'options-toolbar-without-group', - children: ( - <> - <ToolbarButton icon={ formatBold } label="Bold" isPressed /> - <ToolbarButton icon={ formatItalic } label="Italic" /> - <ToolbarButton icon={ link } label="Link" /> - </> - ), -}; diff --git a/packages/components/src/toolbar/toolbar-button/index.tsx b/packages/components/src/toolbar/toolbar-button/index.tsx index 578867313a4294..35696d80e73eb0 100644 --- a/packages/components/src/toolbar/toolbar-button/index.tsx +++ b/packages/components/src/toolbar/toolbar-button/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import type { ForwardedRef } from 'react'; +import type { ForwardedRef, MouseEvent as ReactMouseEvent } from 'react'; /** * WordPress dependencies @@ -18,7 +18,6 @@ import ToolbarContext from '../toolbar-context'; import ToolbarButtonContainer from './toolbar-button-container'; import type { ToolbarButtonProps } from './types'; import type { WordPressComponentProps } from '../../ui/context'; -import type React from 'react'; function UnforwardedToolbarButton( { @@ -45,7 +44,7 @@ function UnforwardedToolbarButton( shortcut={ props.shortcut } data-subscript={ props.subscript } onClick={ ( - event: React.MouseEvent< + event: ReactMouseEvent< HTMLButtonElement & HTMLAnchorElement, MouseEvent > diff --git a/packages/components/src/toolbar/toolbar-context/index.ts b/packages/components/src/toolbar/toolbar-context/index.ts index 17674798edc31c..462c3c83b46a87 100644 --- a/packages/components/src/toolbar/toolbar-context/index.ts +++ b/packages/components/src/toolbar/toolbar-context/index.ts @@ -1,15 +1,13 @@ /** * External dependencies */ -import type { ToolbarStateReturn } from 'reakit/Toolbar'; +import type { ToolbarStore } from '@ariakit/react/toolbar'; /** * WordPress dependencies */ import { createContext } from '@wordpress/element'; -const ToolbarContext = createContext< ToolbarStateReturn | undefined >( - undefined -); +const ToolbarContext = createContext< ToolbarStore | undefined >( undefined ); export default ToolbarContext; diff --git a/packages/components/src/toolbar/toolbar-dropdown-menu/index.js b/packages/components/src/toolbar/toolbar-dropdown-menu/index.js index 5185b61b16d86f..5a99fe00a6d507 100644 --- a/packages/components/src/toolbar/toolbar-dropdown-menu/index.js +++ b/packages/components/src/toolbar/toolbar-dropdown-menu/index.js @@ -28,7 +28,6 @@ function ToolbarDropdownMenu( props, ref ) { <DropdownMenu { ...props } popoverProps={ { - variant: 'toolbar', ...props.popoverProps, } } toggleProps={ toolbarItemProps } diff --git a/packages/components/src/toolbar/toolbar-group/style.native.scss b/packages/components/src/toolbar/toolbar-group/style.native.scss index e218aa37363e37..f45797de3a625a 100644 --- a/packages/components/src/toolbar/toolbar-group/style.native.scss +++ b/packages/components/src/toolbar/toolbar-group/style.native.scss @@ -1,11 +1,10 @@ .container { flex-direction: row; - border-left-width: 1px; - border-color: #e9eff3; + border-color: $light-quaternary; padding-left: 5px; padding-right: 5px; } .containerDark { - border-color: #525354; + border-color: $dark-quaternary; } diff --git a/packages/components/src/toolbar/toolbar-group/toolbar-group-container.native.js b/packages/components/src/toolbar/toolbar-group/toolbar-group-container.native.js index 6eaf0dc0c3c74a..53c2c59bda9548 100644 --- a/packages/components/src/toolbar/toolbar-group/toolbar-group-container.native.js +++ b/packages/components/src/toolbar/toolbar-group/toolbar-group-container.native.js @@ -1,31 +1,26 @@ /** * External dependencies */ -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; /** * WordPress dependencies */ -import { withPreferredColorScheme } from '@wordpress/compose'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; /** * Internal dependencies */ import styles from './style.scss'; -const ToolbarGroupContainer = ( { - getStylesFromColorScheme, - passedStyle, - children, -} ) => ( - <View - style={ [ - getStylesFromColorScheme( styles.container, styles.containerDark ), - passedStyle, - ] } - > - { children } - </View> -); +const ToolbarGroupContainer = ( { passedStyle, children } ) => { + const groupStyles = [ + usePreferredColorSchemeStyle( styles.container, styles.containerDark ), + { borderLeftWidth: StyleSheet.hairlineWidth }, + passedStyle, + ]; -export default withPreferredColorScheme( ToolbarGroupContainer ); + return <View style={ groupStyles }>{ children }</View>; +}; + +export default ToolbarGroupContainer; diff --git a/packages/components/src/toolbar/toolbar-item/index.tsx b/packages/components/src/toolbar/toolbar-item/index.tsx index 6fc9bf01009672..ccfe5084545577 100644 --- a/packages/components/src/toolbar/toolbar-item/index.tsx +++ b/packages/components/src/toolbar/toolbar-item/index.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { ToolbarItem as BaseToolbarItem } from 'reakit/Toolbar'; +import { ToolbarItem as BaseToolbarItem } from '@ariakit/react/toolbar'; import type { ForwardedRef } from 'react'; /** @@ -14,18 +14,16 @@ import warning from '@wordpress/warning'; * Internal dependencies */ import ToolbarContext from '../toolbar-context'; +import type { ToolbarItemProps } from './types'; function ToolbarItem( - { - children, - as: Component, - ...props - }: React.ComponentPropsWithoutRef< typeof BaseToolbarItem >, + { children, as: Component, ...props }: ToolbarItemProps, ref: ForwardedRef< any > ) { - const accessibleToolbarState = useContext( ToolbarContext ); + const accessibleToolbarStore = useContext( ToolbarContext ); + const isRenderProp = typeof children === 'function'; - if ( typeof children !== 'function' && ! Component ) { + if ( ! isRenderProp && ! Component ) { warning( '`ToolbarItem` is a generic headless component. You must pass either a `children` prop as a function or an `as` prop as a component. ' + 'See https://developer.wordpress.org/block-editor/components/toolbar-item/' @@ -35,24 +33,24 @@ function ToolbarItem( const allProps = { ...props, ref, 'data-toolbar-item': true }; - if ( ! accessibleToolbarState ) { + if ( ! accessibleToolbarStore ) { if ( Component ) { return <Component { ...allProps }>{ children }</Component>; } - if ( typeof children !== 'function' ) { + if ( ! isRenderProp ) { return null; } return children( allProps ); } + const render = isRenderProp ? children : Component && <Component />; + return ( <BaseToolbarItem - { ...accessibleToolbarState } { ...allProps } - as={ Component } - > - { children } - </BaseToolbarItem> + store={ accessibleToolbarStore } + render={ render } + /> ); } diff --git a/packages/components/src/toolbar/toolbar-item/types.ts b/packages/components/src/toolbar/toolbar-item/types.ts new file mode 100644 index 00000000000000..a285afea2c6dae --- /dev/null +++ b/packages/components/src/toolbar/toolbar-item/types.ts @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import type { + ReactElement, + ReactNode, + ElementType, + HTMLAttributes, + RefAttributes, +} from 'react'; + +export type ToolbarItemProps = Omit< HTMLAttributes< any >, 'children' > & { + /** + * Component type that will be used to render the toolbar item. + */ + as?: ElementType; + /** + * A function that receives the props that should be spread onto the element + * that will be rendered as a toolbar item. If the `as` prop is not provided, + * this prop will accept a ReactNode instead. + */ + children?: + | ReactNode + | ( ( + props: HTMLAttributes< any > & RefAttributes< any > + ) => ReactElement | null ); +}; diff --git a/packages/components/src/toolbar/toolbar/index.tsx b/packages/components/src/toolbar/toolbar/index.tsx index db5ced5a382a21..96e35d399df944 100644 --- a/packages/components/src/toolbar/toolbar/index.tsx +++ b/packages/components/src/toolbar/toolbar/index.tsx @@ -17,6 +17,16 @@ import ToolbarGroup from '../toolbar-group'; import ToolbarContainer from './toolbar-container'; import type { ToolbarProps } from './types'; import type { WordPressComponentProps } from '../../ui/context'; +import { ContextSystemProvider } from '../../ui/context'; + +const CONTEXT_SYSTEM_VALUE = { + DropdownMenu: { + variant: 'toolbar', + }, + Dropdown: { + variant: 'toolbar', + }, +}; function UnforwardedToolbar( { @@ -40,12 +50,14 @@ function UnforwardedToolbar( className ); return ( - <ToolbarContainer - className={ finalClassName } - label={ label } - ref={ ref } - { ...props } - /> + <ContextSystemProvider value={ CONTEXT_SYSTEM_VALUE }> + <ToolbarContainer + className={ finalClassName } + label={ label } + ref={ ref } + { ...props } + /> + </ContextSystemProvider> ); } diff --git a/packages/components/src/toolbar/toolbar/toolbar-container.tsx b/packages/components/src/toolbar/toolbar/toolbar-container.tsx index 496fb9281651fa..f27b443fdce309 100644 --- a/packages/components/src/toolbar/toolbar/toolbar-container.tsx +++ b/packages/components/src/toolbar/toolbar/toolbar-container.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useToolbarState, Toolbar } from 'reakit/Toolbar'; +import { useToolbarStore, Toolbar } from '@ariakit/react/toolbar'; import type { ForwardedRef } from 'react'; /** @@ -21,22 +21,18 @@ function UnforwardedToolbarContainer( { label, ...props }: WordPressComponentProps< ToolbarProps, 'div', false >, ref: ForwardedRef< any > ) { - // https://reakit.io/docs/basic-concepts/#state-hooks - // Passing baseId for server side rendering (which includes snapshots) - // If an id prop is passed to Toolbar, toolbar items will use it as a base for their ids - const toolbarState = useToolbarState( { - loop: true, - baseId: props.id, + const toolbarStore = useToolbarStore( { + focusLoop: true, rtl: isRTL(), } ); return ( // This will provide state for `ToolbarButton`'s - <ToolbarContext.Provider value={ toolbarState }> + <ToolbarContext.Provider value={ toolbarStore }> <Toolbar ref={ ref } aria-label={ label } - { ...toolbarState } + store={ toolbarStore } { ...props } /> </ToolbarContext.Provider> diff --git a/packages/components/src/tools-panel/stories/index.story.tsx b/packages/components/src/tools-panel/stories/index.story.tsx new file mode 100644 index 00000000000000..62b8a22ee447e8 --- /dev/null +++ b/packages/components/src/tools-panel/stories/index.story.tsx @@ -0,0 +1,619 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import styled from '@emotion/styled'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + ToggleGroupControl, + ToggleGroupControlOption, +} from '../../toggle-group-control'; + +/** + * Internal dependencies + */ +import { ToolsPanel, ToolsPanelItem } from '..'; +import Panel from '../../panel'; +import UnitControl from '../../unit-control'; +import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill'; + +const meta: Meta< typeof ToolsPanel > = { + title: 'Components (Experimental)/ToolsPanel', + component: ToolsPanel, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { ToolsPanelItem }, + argTypes: { + as: { control: { type: null } }, + children: { control: { type: null } }, + panelId: { control: { type: null } }, + resetAll: { action: 'resetAll' }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof ToolsPanel > = ( { + resetAll: resetAllProp, + ...props +} ) => { + const [ height, setHeight ] = useState< string | undefined >(); + const [ minHeight, setMinHeight ] = useState< string | undefined >(); + const [ width, setWidth ] = useState< string | undefined >(); + const [ scale, setScale ] = useState< React.ReactText | undefined >(); + + const resetAll: typeof resetAllProp = ( filters ) => { + setHeight( undefined ); + setWidth( undefined ); + setMinHeight( undefined ); + setScale( undefined ); + resetAllProp( filters ); + }; + + return ( + <PanelWrapperView> + <Panel> + <ToolsPanel { ...props } resetAll={ resetAll }> + <SingleColumnItem + hasValue={ () => !! width } + label="Width" + onDeselect={ () => setWidth( undefined ) } + isShownByDefault={ true } + > + <UnitControl + label="Width" + value={ width } + onChange={ ( next ) => setWidth( next ) } + /> + </SingleColumnItem> + <SingleColumnItem + hasValue={ () => !! height } + label="Height" + onDeselect={ () => setHeight( undefined ) } + isShownByDefault={ true } + > + <UnitControl + label="Height" + value={ height } + onChange={ ( next ) => setHeight( next ) } + /> + </SingleColumnItem> + <ToolsPanelItem + hasValue={ () => !! minHeight } + label="Minimum height" + onDeselect={ () => setMinHeight( undefined ) } + isShownByDefault={ true } + > + <UnitControl + label="Minimum height" + value={ minHeight } + onChange={ ( next ) => setMinHeight( next ) } + /> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => !! scale } + label="Scale" + onDeselect={ () => setScale( undefined ) } + > + <ToggleGroupControl + __nextHasNoMarginBottom + label="Scale" + value={ scale } + onChange={ ( next ) => setScale( next ) } + isBlock + > + <ToggleGroupControlOption + value="cover" + label="Cover" + /> + <ToggleGroupControlOption + value="contain" + label="Contain" + /> + <ToggleGroupControlOption + value="fill" + label="Fill" + /> + </ToggleGroupControl> + </ToolsPanelItem> + </ToolsPanel> + </Panel> + </PanelWrapperView> + ); +}; +Default.args = { + label: 'Tools Panel (default example)', +}; + +export const WithNonToolsPanelItems: StoryFn< typeof ToolsPanel > = ( { + resetAll: resetAllProp, + ...props +} ) => { + const [ height, setHeight ] = useState< string | undefined >(); + const [ width, setWidth ] = useState< string | undefined >(); + + const resetAll: typeof resetAllProp = ( filters ) => { + setHeight( undefined ); + setWidth( undefined ); + resetAllProp( filters ); + }; + + return ( + <PanelWrapperView> + <Panel> + <ToolsPanel { ...props } resetAll={ resetAll }> + <IntroText> + This text illustrates not all items must be wrapped in a + ToolsPanelItem and represented in the panel menu. + </IntroText> + <SingleColumnItem + hasValue={ () => !! width } + label="Width" + onDeselect={ () => setWidth( undefined ) } + isShownByDefault={ true } + > + <UnitControl + label="Width" + value={ width } + onChange={ ( next ) => setWidth( next ) } + /> + </SingleColumnItem> + <SingleColumnItem + hasValue={ () => !! height } + label="Height" + onDeselect={ () => setHeight( undefined ) } + isShownByDefault={ true } + > + <UnitControl + label="Height" + value={ height } + onChange={ ( next ) => setHeight( next ) } + /> + </SingleColumnItem> + </ToolsPanel> + </Panel> + </PanelWrapperView> + ); +}; +WithNonToolsPanelItems.args = { + ...Default.args, + label: 'ToolsPanel (with non-menu items)', +}; + +export const WithOptionalItemsPlusIcon: StoryFn< typeof ToolsPanel > = ( { + resetAll: resetAllProp, + ...props +} ) => { + const [ + isFirstToolsPanelItemShownByDefault, + setIsFirstToolsPanelItemShownByDefault, + ] = useState( false ); + const [ height, setHeight ] = useState< string | undefined >(); + const [ width, setWidth ] = useState< string | undefined >(); + const [ minWidth, setMinWidth ] = useState< string | undefined >(); + + const resetAll: typeof resetAllProp = ( filters ) => { + setHeight( undefined ); + setWidth( undefined ); + setMinWidth( undefined ); + resetAllProp( filters ); + }; + + return ( + <> + <PanelWrapperView> + <Panel> + <ToolsPanel + { ...props } + resetAll={ resetAll } + // `key` property here is used as a hack to force `ToolsPanel` to re-render + // See https://github.com/WordPress/gutenberg/pull/38262/files#r793422991 + key={ + isFirstToolsPanelItemShownByDefault + ? 'true' + : 'false' + } + > + <SingleColumnItem + hasValue={ () => !! minWidth } + label="Minimum width" + onDeselect={ () => setMinWidth( undefined ) } + isShownByDefault={ + isFirstToolsPanelItemShownByDefault + } + > + <UnitControl + label="Minimum width" + value={ minWidth } + onChange={ ( next ) => setMinWidth( next ) } + /> + </SingleColumnItem> + <SingleColumnItem + hasValue={ () => !! width } + label="Width" + onDeselect={ () => setWidth( undefined ) } + isShownByDefault={ false } + > + <UnitControl + label="Width" + value={ width } + onChange={ ( next ) => setWidth( next ) } + /> + </SingleColumnItem> + <SingleColumnItem + hasValue={ () => !! height } + label="Height" + onDeselect={ () => setHeight( undefined ) } + isShownByDefault={ false } + > + <UnitControl + label="Height" + value={ height } + onChange={ ( next ) => setHeight( next ) } + /> + </SingleColumnItem> + </ToolsPanel> + </Panel> + </PanelWrapperView> + + <button + onClick={ () => + setIsFirstToolsPanelItemShownByDefault( + ! isFirstToolsPanelItemShownByDefault + ) + } + aria-pressed={ + isFirstToolsPanelItemShownByDefault ? 'true' : 'false' + } + style={ { + marginTop: '2rem', + } } + > + { isFirstToolsPanelItemShownByDefault + ? 'Make first PanelItem hidden by default' + : 'Make first PanelItem shown by default' } + </button> + </> + ); +}; + +WithOptionalItemsPlusIcon.args = { + ...Default.args, + label: 'Tools Panel (optional items only)', +}; + +const { Fill: ToolsPanelItems, Slot } = createSlotFill( 'ToolsPanelSlot' ); + +export const WithSlotFillItems: StoryFn< typeof ToolsPanel > = ( { + resetAll: resetAllProp, + panelId, + ...props +} ) => { + const [ attributes, setAttributes ] = useState< { + width?: string; + height?: string; + } >( {} ); + const { width, height } = attributes; + + const resetAll: typeof resetAllProp = ( resetFilters = [] ) => { + let newAttributes: typeof attributes = {}; + + resetFilters.forEach( ( resetFilter ) => { + newAttributes = { + ...newAttributes, + ...resetFilter( newAttributes ), + }; + } ); + + setAttributes( newAttributes ); + resetAllProp( resetFilters ); + }; + + const updateAttribute = ( name: string, value?: any ) => { + setAttributes( { + ...attributes, + [ name ]: value, + } ); + }; + + return ( + <SlotFillProvider> + <ToolsPanelItems> + <SingleColumnItem + hasValue={ () => !! width } + label="Injected Width" + onDeselect={ () => updateAttribute( 'width', undefined ) } + resetAllFilter={ () => ( { width: undefined } ) } + panelId={ panelId } + > + <UnitControl + label="Injected Width" + value={ width } + onChange={ ( next ) => + updateAttribute( 'width', next ) + } + /> + </SingleColumnItem> + <SingleColumnItem + hasValue={ () => !! height } + label="Injected Height" + onDeselect={ () => updateAttribute( 'height', undefined ) } + resetAllFilter={ () => ( { height: undefined } ) } + panelId={ panelId } + > + <UnitControl + label="Injected Height" + value={ height } + onChange={ ( next ) => + updateAttribute( 'height', next ) + } + /> + </SingleColumnItem> + <ToolsPanelItem + hasValue={ () => true } + label="Item for alternate panel" + onDeselect={ () => undefined } + resetAllFilter={ () => undefined } + panelId={ 'intended-for-another-panel-via-shared-slot' } + > + <p> + This panel item will not be displayed in the demo as its + panelId does not match the panel being rendered. + </p> + </ToolsPanelItem> + </ToolsPanelItems> + <PanelWrapperView> + <Panel> + <ToolsPanel + { ...props } + resetAll={ resetAll } + panelId={ panelId } + > + <Slot /> + </ToolsPanel> + </Panel> + </PanelWrapperView> + </SlotFillProvider> + ); +}; +WithSlotFillItems.args = { + ...Default.args, + label: 'Tools Panel With SlotFill Items', + panelId: 'unique-tools-panel-id', +}; + +export const WithConditionalDefaultControl: StoryFn< typeof ToolsPanel > = ( { + resetAll: resetAllProp, + panelId, + ...props +} ) => { + const [ attributes, setAttributes ] = useState< { + height?: string; + scale?: React.ReactText; + } >( {} ); + const { height, scale } = attributes; + + const resetAll: typeof resetAllProp = ( resetFilters = [] ) => { + let newAttributes: typeof attributes = {}; + + resetFilters.forEach( ( resetFilter ) => { + newAttributes = { + ...newAttributes, + ...resetFilter( newAttributes ), + }; + } ); + + setAttributes( newAttributes ); + + resetAllProp( resetFilters ); + }; + + const updateAttribute = ( name: string, value?: any ) => { + setAttributes( { + ...attributes, + [ name ]: value, + } ); + }; + + return ( + <SlotFillProvider> + <ToolsPanelItems> + <SingleColumnItem + hasValue={ () => !! height } + label="Injected Height" + onDeselect={ () => updateAttribute( 'height', undefined ) } + resetAllFilter={ () => ( { height: undefined } ) } + panelId={ panelId } + isShownByDefault={ true } + > + <UnitControl + label="Injected Height" + value={ height } + onChange={ ( next ) => + updateAttribute( 'height', next ) + } + /> + </SingleColumnItem> + <ToolsPanelItem + hasValue={ () => !! scale } + label="Scale" + onDeselect={ () => updateAttribute( 'scale', undefined ) } + resetAllFilter={ () => ( { scale: undefined } ) } + panelId={ panelId } + isShownByDefault={ !! height } + > + <ToggleGroupControl + __nextHasNoMarginBottom + label="Scale" + value={ scale } + onChange={ ( next ) => + updateAttribute( 'scale', next ) + } + isBlock + > + <ToggleGroupControlOption value="cover" label="Cover" /> + <ToggleGroupControlOption + value="contain" + label="Contain" + /> + <ToggleGroupControlOption value="fill" label="Fill" /> + </ToggleGroupControl> + </ToolsPanelItem> + </ToolsPanelItems> + <PanelWrapperView> + <Panel> + <ToolsPanel + { ...props } + resetAll={ resetAll } + panelId={ panelId } + > + <Slot /> + </ToolsPanel> + </Panel> + </PanelWrapperView> + </SlotFillProvider> + ); +}; +WithConditionalDefaultControl.args = { + ...Default.args, + label: 'Tools Panel With Conditional Default via SlotFill', + panelId: 'unique-tools-panel-id', +}; + +export const WithConditionallyRenderedControl: StoryFn< + typeof ToolsPanel +> = ( { resetAll: resetAllProp, panelId, ...props } ) => { + const [ attributes, setAttributes ] = useState< { + height?: string; + scale?: React.ReactText; + } >( {} ); + const { height, scale } = attributes; + + const resetAll: typeof resetAllProp = ( resetFilters = [] ) => { + let newAttributes: typeof attributes = {}; + + resetFilters.forEach( ( resetFilter ) => { + newAttributes = { + ...newAttributes, + ...resetFilter( newAttributes ), + }; + } ); + + setAttributes( newAttributes ); + + resetAllProp( resetFilters ); + }; + + const updateAttribute = ( name: string, value?: any ) => { + setAttributes( { + ...attributes, + [ name ]: value, + } ); + }; + + return ( + <SlotFillProvider> + <ToolsPanelItems> + <SingleColumnItem + hasValue={ () => !! height } + label="Injected Height" + onDeselect={ () => { + updateAttribute( 'scale', undefined ); + updateAttribute( 'height', undefined ); + } } + resetAllFilter={ () => ( { height: undefined } ) } + panelId={ panelId } + isShownByDefault={ true } + > + <UnitControl + label="Injected Height" + value={ height } + onChange={ ( next ) => + updateAttribute( 'height', next ) + } + /> + </SingleColumnItem> + { !! height && ( + <ToolsPanelItem + hasValue={ () => !! scale } + label="Scale" + onDeselect={ () => + updateAttribute( 'scale', undefined ) + } + resetAllFilter={ () => ( { scale: undefined } ) } + panelId={ panelId } + isShownByDefault={ true } + > + <ToggleGroupControl + __nextHasNoMarginBottom + label="Scale" + value={ scale } + onChange={ ( next ) => + updateAttribute( 'scale', next ) + } + isBlock + > + <ToggleGroupControlOption + value="cover" + label="Cover" + /> + <ToggleGroupControlOption + value="contain" + label="Contain" + /> + <ToggleGroupControlOption + value="fill" + label="Fill" + /> + </ToggleGroupControl> + </ToolsPanelItem> + ) } + </ToolsPanelItems> + <PanelWrapperView> + <Panel> + <ToolsPanel + { ...props } + resetAll={ resetAll } + panelId={ panelId } + > + <Slot /> + </ToolsPanel> + </Panel> + </PanelWrapperView> + </SlotFillProvider> + ); +}; +WithConditionallyRenderedControl.args = { + ...Default.args, + label: 'Tools Panel With Conditionally Rendered Item via SlotFill', + panelId: 'unique-tools-panel-id', +}; + +const PanelWrapperView = styled.div` + font-size: 13px; + + .components-dropdown-menu__menu { + max-width: 220px; + } +`; + +const SingleColumnItem = styled( ToolsPanelItem )` + grid-column: span 1; +`; + +const IntroText = styled.div` + grid-column: span 2; +`; diff --git a/packages/components/src/tools-panel/stories/index.tsx b/packages/components/src/tools-panel/stories/index.tsx deleted file mode 100644 index 52c89a6ece5d66..00000000000000 --- a/packages/components/src/tools-panel/stories/index.tsx +++ /dev/null @@ -1,617 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import styled from '@emotion/styled'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { - ToggleGroupControl, - ToggleGroupControlOption, -} from '../../toggle-group-control'; - -/** - * Internal dependencies - */ -import { ToolsPanel, ToolsPanelItem } from '..'; -import Panel from '../../panel'; -import UnitControl from '../../unit-control'; -import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill'; - -const meta: ComponentMeta< typeof ToolsPanel > = { - title: 'Components (Experimental)/ToolsPanel', - component: ToolsPanel, - subcomponents: { - ToolsPanelItem, - }, - argTypes: { - as: { control: { type: null } }, - children: { control: { type: null } }, - panelId: { control: { type: null } }, - resetAll: { action: 'resetAll' }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof ToolsPanel > = ( { - resetAll: resetAllProp, - ...props -} ) => { - const [ height, setHeight ] = useState< string | undefined >(); - const [ minHeight, setMinHeight ] = useState< string | undefined >(); - const [ width, setWidth ] = useState< string | undefined >(); - const [ scale, setScale ] = useState< React.ReactText | undefined >(); - - const resetAll: typeof resetAllProp = ( filters ) => { - setHeight( undefined ); - setWidth( undefined ); - setMinHeight( undefined ); - setScale( undefined ); - resetAllProp( filters ); - }; - - return ( - <PanelWrapperView> - <Panel> - <ToolsPanel { ...props } resetAll={ resetAll }> - <SingleColumnItem - hasValue={ () => !! width } - label="Width" - onDeselect={ () => setWidth( undefined ) } - isShownByDefault={ true } - > - <UnitControl - label="Width" - value={ width } - onChange={ ( next ) => setWidth( next ) } - /> - </SingleColumnItem> - <SingleColumnItem - hasValue={ () => !! height } - label="Height" - onDeselect={ () => setHeight( undefined ) } - isShownByDefault={ true } - > - <UnitControl - label="Height" - value={ height } - onChange={ ( next ) => setHeight( next ) } - /> - </SingleColumnItem> - <ToolsPanelItem - hasValue={ () => !! minHeight } - label="Minimum height" - onDeselect={ () => setMinHeight( undefined ) } - isShownByDefault={ true } - > - <UnitControl - label="Minimum height" - value={ minHeight } - onChange={ ( next ) => setMinHeight( next ) } - /> - </ToolsPanelItem> - <ToolsPanelItem - hasValue={ () => !! scale } - label="Scale" - onDeselect={ () => setScale( undefined ) } - > - <ToggleGroupControl - __nextHasNoMarginBottom - label="Scale" - value={ scale } - onChange={ ( next ) => setScale( next ) } - isBlock - > - <ToggleGroupControlOption - value="cover" - label="Cover" - /> - <ToggleGroupControlOption - value="contain" - label="Contain" - /> - <ToggleGroupControlOption - value="fill" - label="Fill" - /> - </ToggleGroupControl> - </ToolsPanelItem> - </ToolsPanel> - </Panel> - </PanelWrapperView> - ); -}; -Default.args = { - label: 'Tools Panel (default example)', -}; - -export const WithNonToolsPanelItems: ComponentStory< typeof ToolsPanel > = ( { - resetAll: resetAllProp, - ...props -} ) => { - const [ height, setHeight ] = useState< string | undefined >(); - const [ width, setWidth ] = useState< string | undefined >(); - - const resetAll: typeof resetAllProp = ( filters ) => { - setHeight( undefined ); - setWidth( undefined ); - resetAllProp( filters ); - }; - - return ( - <PanelWrapperView> - <Panel> - <ToolsPanel { ...props } resetAll={ resetAll }> - <IntroText> - This text illustrates not all items must be wrapped in a - ToolsPanelItem and represented in the panel menu. - </IntroText> - <SingleColumnItem - hasValue={ () => !! width } - label="Width" - onDeselect={ () => setWidth( undefined ) } - isShownByDefault={ true } - > - <UnitControl - label="Width" - value={ width } - onChange={ ( next ) => setWidth( next ) } - /> - </SingleColumnItem> - <SingleColumnItem - hasValue={ () => !! height } - label="Height" - onDeselect={ () => setHeight( undefined ) } - isShownByDefault={ true } - > - <UnitControl - label="Height" - value={ height } - onChange={ ( next ) => setHeight( next ) } - /> - </SingleColumnItem> - </ToolsPanel> - </Panel> - </PanelWrapperView> - ); -}; -WithNonToolsPanelItems.args = { - ...Default.args, - label: 'ToolsPanel (with non-menu items)', -}; - -export const WithOptionalItemsPlusIcon: ComponentStory< - typeof ToolsPanel -> = ( { resetAll: resetAllProp, ...props } ) => { - const [ - isFirstToolsPanelItemShownByDefault, - setIsFirstToolsPanelItemShownByDefault, - ] = useState( false ); - const [ height, setHeight ] = useState< string | undefined >(); - const [ width, setWidth ] = useState< string | undefined >(); - const [ minWidth, setMinWidth ] = useState< string | undefined >(); - - const resetAll: typeof resetAllProp = ( filters ) => { - setHeight( undefined ); - setWidth( undefined ); - setMinWidth( undefined ); - resetAllProp( filters ); - }; - - return ( - <> - <PanelWrapperView> - <Panel> - <ToolsPanel - { ...props } - resetAll={ resetAll } - // `key` property here is used as a hack to force `ToolsPanel` to re-render - // See https://github.com/WordPress/gutenberg/pull/38262/files#r793422991 - key={ - isFirstToolsPanelItemShownByDefault - ? 'true' - : 'false' - } - > - <SingleColumnItem - hasValue={ () => !! minWidth } - label="Minimum width" - onDeselect={ () => setMinWidth( undefined ) } - isShownByDefault={ - isFirstToolsPanelItemShownByDefault - } - > - <UnitControl - label="Minimum width" - value={ minWidth } - onChange={ ( next ) => setMinWidth( next ) } - /> - </SingleColumnItem> - <SingleColumnItem - hasValue={ () => !! width } - label="Width" - onDeselect={ () => setWidth( undefined ) } - isShownByDefault={ false } - > - <UnitControl - label="Width" - value={ width } - onChange={ ( next ) => setWidth( next ) } - /> - </SingleColumnItem> - <SingleColumnItem - hasValue={ () => !! height } - label="Height" - onDeselect={ () => setHeight( undefined ) } - isShownByDefault={ false } - > - <UnitControl - label="Height" - value={ height } - onChange={ ( next ) => setHeight( next ) } - /> - </SingleColumnItem> - </ToolsPanel> - </Panel> - </PanelWrapperView> - - <button - onClick={ () => - setIsFirstToolsPanelItemShownByDefault( - ! isFirstToolsPanelItemShownByDefault - ) - } - aria-pressed={ - isFirstToolsPanelItemShownByDefault ? 'true' : 'false' - } - style={ { - marginTop: '2rem', - } } - > - { isFirstToolsPanelItemShownByDefault - ? 'Make first PanelItem hidden by default' - : 'Make first PanelItem shown by default' } - </button> - </> - ); -}; - -WithOptionalItemsPlusIcon.args = { - ...Default.args, - label: 'Tools Panel (optional items only)', -}; - -const { Fill: ToolsPanelItems, Slot } = createSlotFill( 'ToolsPanelSlot' ); - -export const WithSlotFillItems: ComponentStory< typeof ToolsPanel > = ( { - resetAll: resetAllProp, - panelId, - ...props -} ) => { - const [ attributes, setAttributes ] = useState< { - width?: string; - height?: string; - } >( {} ); - const { width, height } = attributes; - - const resetAll: typeof resetAllProp = ( resetFilters = [] ) => { - let newAttributes: typeof attributes = {}; - - resetFilters.forEach( ( resetFilter ) => { - newAttributes = { - ...newAttributes, - ...resetFilter( newAttributes ), - }; - } ); - - setAttributes( newAttributes ); - resetAllProp( resetFilters ); - }; - - const updateAttribute = ( name: string, value?: any ) => { - setAttributes( { - ...attributes, - [ name ]: value, - } ); - }; - - return ( - <SlotFillProvider> - <ToolsPanelItems> - <SingleColumnItem - hasValue={ () => !! width } - label="Injected Width" - onDeselect={ () => updateAttribute( 'width', undefined ) } - resetAllFilter={ () => ( { width: undefined } ) } - panelId={ panelId } - > - <UnitControl - label="Injected Width" - value={ width } - onChange={ ( next ) => - updateAttribute( 'width', next ) - } - /> - </SingleColumnItem> - <SingleColumnItem - hasValue={ () => !! height } - label="Injected Height" - onDeselect={ () => updateAttribute( 'height', undefined ) } - resetAllFilter={ () => ( { height: undefined } ) } - panelId={ panelId } - > - <UnitControl - label="Injected Height" - value={ height } - onChange={ ( next ) => - updateAttribute( 'height', next ) - } - /> - </SingleColumnItem> - <ToolsPanelItem - hasValue={ () => true } - label="Item for alternate panel" - onDeselect={ () => undefined } - resetAllFilter={ () => undefined } - panelId={ 'intended-for-another-panel-via-shared-slot' } - > - <p> - This panel item will not be displayed in the demo as its - panelId does not match the panel being rendered. - </p> - </ToolsPanelItem> - </ToolsPanelItems> - <PanelWrapperView> - <Panel> - <ToolsPanel - { ...props } - resetAll={ resetAll } - panelId={ panelId } - > - <Slot /> - </ToolsPanel> - </Panel> - </PanelWrapperView> - </SlotFillProvider> - ); -}; -WithSlotFillItems.args = { - ...Default.args, - label: 'Tools Panel With SlotFill Items', - panelId: 'unique-tools-panel-id', -}; - -export const WithConditionalDefaultControl: ComponentStory< - typeof ToolsPanel -> = ( { resetAll: resetAllProp, panelId, ...props } ) => { - const [ attributes, setAttributes ] = useState< { - height?: string; - scale?: React.ReactText; - } >( {} ); - const { height, scale } = attributes; - - const resetAll: typeof resetAllProp = ( resetFilters = [] ) => { - let newAttributes: typeof attributes = {}; - - resetFilters.forEach( ( resetFilter ) => { - newAttributes = { - ...newAttributes, - ...resetFilter( newAttributes ), - }; - } ); - - setAttributes( newAttributes ); - - resetAllProp( resetFilters ); - }; - - const updateAttribute = ( name: string, value?: any ) => { - setAttributes( { - ...attributes, - [ name ]: value, - } ); - }; - - return ( - <SlotFillProvider> - <ToolsPanelItems> - <SingleColumnItem - hasValue={ () => !! height } - label="Injected Height" - onDeselect={ () => updateAttribute( 'height', undefined ) } - resetAllFilter={ () => ( { height: undefined } ) } - panelId={ panelId } - isShownByDefault={ true } - > - <UnitControl - label="Injected Height" - value={ height } - onChange={ ( next ) => - updateAttribute( 'height', next ) - } - /> - </SingleColumnItem> - <ToolsPanelItem - hasValue={ () => !! scale } - label="Scale" - onDeselect={ () => updateAttribute( 'scale', undefined ) } - resetAllFilter={ () => ( { scale: undefined } ) } - panelId={ panelId } - isShownByDefault={ !! height } - > - <ToggleGroupControl - __nextHasNoMarginBottom - label="Scale" - value={ scale } - onChange={ ( next ) => - updateAttribute( 'scale', next ) - } - isBlock - > - <ToggleGroupControlOption value="cover" label="Cover" /> - <ToggleGroupControlOption - value="contain" - label="Contain" - /> - <ToggleGroupControlOption value="fill" label="Fill" /> - </ToggleGroupControl> - </ToolsPanelItem> - </ToolsPanelItems> - <PanelWrapperView> - <Panel> - <ToolsPanel - { ...props } - resetAll={ resetAll } - panelId={ panelId } - > - <Slot /> - </ToolsPanel> - </Panel> - </PanelWrapperView> - </SlotFillProvider> - ); -}; -WithConditionalDefaultControl.args = { - ...Default.args, - label: 'Tools Panel With Conditional Default via SlotFill', - panelId: 'unique-tools-panel-id', -}; - -export const WithConditionallyRenderedControl: ComponentStory< - typeof ToolsPanel -> = ( { resetAll: resetAllProp, panelId, ...props } ) => { - const [ attributes, setAttributes ] = useState< { - height?: string; - scale?: React.ReactText; - } >( {} ); - const { height, scale } = attributes; - - const resetAll: typeof resetAllProp = ( resetFilters = [] ) => { - let newAttributes: typeof attributes = {}; - - resetFilters.forEach( ( resetFilter ) => { - newAttributes = { - ...newAttributes, - ...resetFilter( newAttributes ), - }; - } ); - - setAttributes( newAttributes ); - - resetAllProp( resetFilters ); - }; - - const updateAttribute = ( name: string, value?: any ) => { - setAttributes( { - ...attributes, - [ name ]: value, - } ); - }; - - return ( - <SlotFillProvider> - <ToolsPanelItems> - <SingleColumnItem - hasValue={ () => !! height } - label="Injected Height" - onDeselect={ () => { - updateAttribute( 'scale', undefined ); - updateAttribute( 'height', undefined ); - } } - resetAllFilter={ () => ( { height: undefined } ) } - panelId={ panelId } - isShownByDefault={ true } - > - <UnitControl - label="Injected Height" - value={ height } - onChange={ ( next ) => - updateAttribute( 'height', next ) - } - /> - </SingleColumnItem> - { !! height && ( - <ToolsPanelItem - hasValue={ () => !! scale } - label="Scale" - onDeselect={ () => - updateAttribute( 'scale', undefined ) - } - resetAllFilter={ () => ( { scale: undefined } ) } - panelId={ panelId } - isShownByDefault={ true } - > - <ToggleGroupControl - __nextHasNoMarginBottom - label="Scale" - value={ scale } - onChange={ ( next ) => - updateAttribute( 'scale', next ) - } - isBlock - > - <ToggleGroupControlOption - value="cover" - label="Cover" - /> - <ToggleGroupControlOption - value="contain" - label="Contain" - /> - <ToggleGroupControlOption - value="fill" - label="Fill" - /> - </ToggleGroupControl> - </ToolsPanelItem> - ) } - </ToolsPanelItems> - <PanelWrapperView> - <Panel> - <ToolsPanel - { ...props } - resetAll={ resetAll } - panelId={ panelId } - > - <Slot /> - </ToolsPanel> - </Panel> - </PanelWrapperView> - </SlotFillProvider> - ); -}; -WithConditionallyRenderedControl.args = { - ...Default.args, - label: 'Tools Panel With Conditionally Rendered Item via SlotFill', - panelId: 'unique-tools-panel-id', -}; - -const PanelWrapperView = styled.div` - font-size: 13px; - - .components-dropdown-menu__menu { - max-width: 220px; - } -`; - -const SingleColumnItem = styled( ToolsPanelItem )` - grid-column: span 1; -`; - -const IntroText = styled.div` - grid-column: span 2; -`; diff --git a/packages/components/src/tools-panel/styles.ts b/packages/components/src/tools-panel/styles.ts index 85ad1cb278d9b0..f3c5d77fe27cc4 100644 --- a/packages/components/src/tools-panel/styles.ts +++ b/packages/components/src/tools-panel/styles.ts @@ -148,7 +148,7 @@ export const DropdownMenu = css` `; export const ResetLabel = styled.span` - color: ${ COLORS.ui.themeDark10 }; + color: ${ COLORS.theme.accentDarker10 }; font-size: 11px; font-weight: 500; line-height: 1.4; diff --git a/packages/components/src/tools-panel/test/index.tsx b/packages/components/src/tools-panel/test/index.tsx index 04f93c2f0ba814..812a21ec0d76ea 100644 --- a/packages/components/src/tools-panel/test/index.tsx +++ b/packages/components/src/tools-panel/test/index.tsx @@ -286,8 +286,8 @@ describe( 'ToolsPanel', () => { const menuItems = await screen.findAllByRole( 'menuitemcheckbox' ); expect( menuItems.length ).toEqual( 2 ); - expect( menuItems[ 0 ] ).toHaveAttribute( 'aria-checked', 'true' ); - expect( menuItems[ 1 ] ).toHaveAttribute( 'aria-checked', 'false' ); + expect( menuItems[ 0 ] ).toBeChecked(); + expect( menuItems[ 1 ] ).not.toBeChecked(); } ); it( 'should render panel label as header text', () => { diff --git a/packages/components/src/tools-panel/tools-panel-header/component.tsx b/packages/components/src/tools-panel/tools-panel-header/component.tsx index 281f63728f6596..35f27983c18135 100644 --- a/packages/components/src/tools-panel/tools-panel-header/component.tsx +++ b/packages/components/src/tools-panel/tools-panel-header/component.tsx @@ -19,7 +19,8 @@ import MenuItem from '../../menu-item'; import { HStack } from '../../h-stack'; import { Heading } from '../../heading'; import { useToolsPanelHeader } from './hook'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import { ResetLabel } from '../styles'; import type { ToolsPanelControlsGroupProps, @@ -111,7 +112,7 @@ const OptionalControlsGroup = ( { return ( <MenuItem key={ label } - icon={ isSelected && check } + icon={ isSelected ? check : null } isSelected={ isSelected } label={ itemLabel } onClick={ () => { @@ -213,6 +214,9 @@ const ToolsPanelHeader = ( <MenuGroup> <MenuItem aria-disabled={ ! canResetAll } + // @ts-expect-error - TODO: If this "tertiary" style is something we really want to allow on MenuItem, + // we should rename it and explicitly allow it as an official API. All the other Button variants + // don't make sense in a MenuItem context, and should be disallowed. variant={ 'tertiary' } onClick={ () => { if ( canResetAll ) { diff --git a/packages/components/src/tools-panel/tools-panel-header/hook.ts b/packages/components/src/tools-panel/tools-panel-header/hook.ts index 6682328810e331..056258eed5d009 100644 --- a/packages/components/src/tools-panel/tools-panel-header/hook.ts +++ b/packages/components/src/tools-panel/tools-panel-header/hook.ts @@ -8,7 +8,8 @@ import { useMemo } from '@wordpress/element'; */ import * as styles from '../styles'; import { useToolsPanelContext } from '../context'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { ToolsPanelHeaderProps } from '../types'; diff --git a/packages/components/src/tools-panel/tools-panel-item/component.tsx b/packages/components/src/tools-panel/tools-panel-item/component.tsx index f66d3845ff0832..fb4d1c4fae295a 100644 --- a/packages/components/src/tools-panel/tools-panel-item/component.tsx +++ b/packages/components/src/tools-panel/tools-panel-item/component.tsx @@ -8,7 +8,8 @@ import type { ForwardedRef } from 'react'; */ import { useToolsPanelItem } from './hook'; import { View } from '../../view'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import type { ToolsPanelItemProps } from '../types'; // This wraps controls to be conditionally displayed within a tools panel. It diff --git a/packages/components/src/tools-panel/tools-panel-item/hook.ts b/packages/components/src/tools-panel/tools-panel-item/hook.ts index 9acee8ee1d52c0..bfd59e5b953aa7 100644 --- a/packages/components/src/tools-panel/tools-panel-item/hook.ts +++ b/packages/components/src/tools-panel/tools-panel-item/hook.ts @@ -9,7 +9,8 @@ import { useCallback, useEffect, useMemo } from '@wordpress/element'; */ import * as styles from '../styles'; import { useToolsPanelContext } from '../context'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { ToolsPanelItemProps } from '../types'; diff --git a/packages/components/src/tools-panel/tools-panel/component.tsx b/packages/components/src/tools-panel/tools-panel/component.tsx index c6f3a9ce5469d6..42028b48f862bf 100644 --- a/packages/components/src/tools-panel/tools-panel/component.tsx +++ b/packages/components/src/tools-panel/tools-panel/component.tsx @@ -10,7 +10,8 @@ import ToolsPanelHeader from '../tools-panel-header'; import { ToolsPanelContext } from '../context'; import { useToolsPanel } from './hook'; import { Grid } from '../../grid'; -import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { contextConnect } from '../../ui/context'; import type { ToolsPanelProps } from '../types'; const UnconnectedToolsPanel = ( @@ -47,7 +48,6 @@ const UnconnectedToolsPanel = ( * by a header. The header includes a dropdown menu which is automatically * generated from the panel's inner `ToolsPanelItems`. * - * @example * ```jsx * import { __ } from '@wordpress/i18n'; * import { diff --git a/packages/components/src/tools-panel/tools-panel/hook.ts b/packages/components/src/tools-panel/tools-panel/hook.ts index 35d4c06279419d..8377376a861a59 100644 --- a/packages/components/src/tools-panel/tools-panel/hook.ts +++ b/packages/components/src/tools-panel/tools-panel/hook.ts @@ -13,7 +13,8 @@ import { * Internal dependencies */ import * as styles from '../styles'; -import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import type { WordPressComponentProps } from '../../ui/context'; +import { useContextSystem } from '../../ui/context'; import { useCx } from '../../utils/hooks/use-cx'; import type { ToolsPanelItem, diff --git a/packages/components/src/tooltip/README.md b/packages/components/src/tooltip/README.md index e4d030681240ca..101d642f39eb2e 100644 --- a/packages/components/src/tooltip/README.md +++ b/packages/components/src/tooltip/README.md @@ -28,7 +28,7 @@ The direction in which the tooltip should open relative to its parent node. Spec - Type: `String` - Required: No -- Default: `"top center"` +- Default: `"bottom"` ### children diff --git a/packages/components/src/tooltip/stories/index.js b/packages/components/src/tooltip/stories/index.js deleted file mode 100644 index dc573a189bf930..00000000000000 --- a/packages/components/src/tooltip/stories/index.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Internal dependencies - */ -import Tooltip from '../'; - -export default { - title: 'Components/ToolTip', - component: Tooltip, - argTypes: { - delay: { control: 'number' }, - position: { - control: { - type: 'select', - options: [ - 'top left', - 'top center', - 'top right', - 'bottom left', - 'bottom center', - 'bottom right', - ], - }, - }, - text: { control: 'text' }, - }, - parameters: { - docs: { source: { state: 'open' } }, - }, -}; - -const Template = ( args ) => { - return <Tooltip { ...args } />; -}; - -export const Default = Template.bind( {} ); -Default.args = { - text: 'More information', - children: ( - <div - style={ { - margin: '50px auto', - width: '200px', - padding: '20px', - textAlign: 'center', - border: '1px solid #ccc', - } } - > - Hover for more information - </div> - ), -}; - -export const SmallTarget = Template.bind( {} ); -SmallTarget.args = { - ...Default.args, - children: ( - <div - style={ { - margin: '50px auto', - width: 'min-content', - padding: '4px', - textAlign: 'center', - border: '1px solid #ccc', - } } - > - Small target - </div> - ), -}; - -export const DisabledChild = Template.bind( {} ); -DisabledChild.args = { - ...Default.args, - children: ( - <button - disabled - onClick={ () => - // eslint-disable-next-line no-alert - window.alert( 'This alert should not be triggered' ) - } - > - Hover me, but I am disabled - </button> - ), -}; diff --git a/packages/components/src/tooltip/stories/index.story.js b/packages/components/src/tooltip/stories/index.story.js new file mode 100644 index 00000000000000..a808dc3d4cb82f --- /dev/null +++ b/packages/components/src/tooltip/stories/index.story.js @@ -0,0 +1,85 @@ +/** + * Internal dependencies + */ +import Tooltip from '../'; + +export default { + title: 'Components/ToolTip', + component: Tooltip, + argTypes: { + delay: { control: 'number' }, + position: { + control: { + type: 'select', + options: [ + 'top left', + 'top center', + 'top right', + 'bottom left', + 'bottom center', + 'bottom right', + ], + }, + }, + text: { control: 'text' }, + }, + parameters: { + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +const Template = ( args ) => { + return <Tooltip { ...args } />; +}; + +export const Default = Template.bind( {} ); +Default.args = { + text: 'More information', + children: ( + <div + style={ { + margin: '50px auto', + width: '200px', + padding: '20px', + textAlign: 'center', + border: '1px solid #ccc', + } } + > + Hover for more information + </div> + ), +}; + +export const SmallTarget = Template.bind( {} ); +SmallTarget.args = { + ...Default.args, + children: ( + <div + style={ { + margin: '50px auto', + width: 'min-content', + padding: '4px', + textAlign: 'center', + border: '1px solid #ccc', + } } + > + Small target + </div> + ), +}; + +export const DisabledChild = Template.bind( {} ); +DisabledChild.args = { + ...Default.args, + children: ( + <button + disabled + onClick={ () => + // eslint-disable-next-line no-alert + window.alert( 'This alert should not be triggered' ) + } + > + Hover me, but I am disabled + </button> + ), +}; diff --git a/packages/components/src/tooltip/test/index.native.js b/packages/components/src/tooltip/test/index.native.js index d8047f66054cd1..bad45978f6a8ce 100644 --- a/packages/components/src/tooltip/test/index.native.js +++ b/packages/components/src/tooltip/test/index.native.js @@ -53,9 +53,7 @@ it( 'displays the message', () => { expect( screen.getByText( 'A helpful message' ) ).toBeTruthy(); } ); -// Skipped until `pointerEvents: 'box-none'` no longer erroneously prevents -// triggering `onTouch*` on the element: https://github.com/callstack/react-native-testing-library/issues/897 -it.skip( 'dismisses when the screen is tapped', () => { +it( 'dismisses when the screen is tapped', () => { const screen = render( <TooltipSlot> <Tooltip visible={ true } text="A helpful message"> diff --git a/packages/components/src/tree-grid/stories/index.story.tsx b/packages/components/src/tree-grid/stories/index.story.tsx new file mode 100644 index 00000000000000..44af154c685b2f --- /dev/null +++ b/packages/components/src/tree-grid/stories/index.story.tsx @@ -0,0 +1,145 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TreeGrid, { TreeGridRow, TreeGridCell } from '..'; +import { Button } from '../../button'; +import InputControl from '../../input-control'; + +const meta: Meta< typeof TreeGrid > = { + title: 'Components (Experimental)/TreeGrid', + component: TreeGrid, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + subcomponents: { TreeGridRow, TreeGridCell }, + argTypes: { + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + }, +}; +export default meta; + +const groceries = [ + { + name: 'Fruit', + types: [ + { + name: 'Apple', + }, + { + name: 'Orange', + }, + { + name: 'Pear', + }, + ], + }, + { + name: 'Vegetable', + types: [ + { + name: 'Cucumber', + }, + { + name: 'Parsnip', + }, + { + name: 'Pumpkin', + }, + ], + }, +]; + +const Descender = ( { level }: { level: number } ) => { + if ( level === 1 ) { + return null; + } + const indentation = '\u00A0'.repeat( ( level - 1 ) * 4 ); + + return <span aria-hidden="true">{ indentation + '├ ' }</span>; +}; + +type Item = { + name: string; + types?: Item[]; +}; + +const Rows = ( { + items = [], + level = 1, +}: { + items?: Item[]; + level?: number; +} ) => { + return ( + <> + { items.map( ( item, index ) => { + const hasChildren = !! item.types && !! item.types.length; + return ( + <Fragment key={ item.name }> + <TreeGridRow + positionInSet={ index + 1 } + setSize={ items.length } + level={ level } + isExpanded + > + <TreeGridCell> + { ( props ) => ( + <> + <Descender level={ level } /> + <Button variant="primary" { ...props }> + { item.name } + </Button> + </> + ) } + </TreeGridCell> + <TreeGridCell> + { ( props ) => ( + <InputControl + label="Description" + hideLabelFromVision + placeholder="Description" + { ...props } + /> + ) } + </TreeGridCell> + <TreeGridCell> + { ( props ) => ( + <InputControl + label="Notes" + hideLabelFromVision + placeholder="Notes" + { ...props } + /> + ) } + </TreeGridCell> + </TreeGridRow> + { hasChildren && ( + <Rows items={ item.types } level={ level + 1 } /> + ) } + </Fragment> + ); + } ) } + </> + ); +}; + +const Template: StoryFn< typeof TreeGrid > = ( args ) => ( + <TreeGrid { ...args } /> +); + +export const Default = Template.bind( {} ); +Default.args = { + children: <Rows items={ groceries } />, +}; diff --git a/packages/components/src/tree-grid/stories/index.tsx b/packages/components/src/tree-grid/stories/index.tsx deleted file mode 100644 index 6ae39be6e7caf0..00000000000000 --- a/packages/components/src/tree-grid/stories/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { Fragment } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import TreeGrid, { TreeGridRow, TreeGridCell } from '..'; -import { Button } from '../../button'; -import InputControl from '../../input-control'; - -const meta: ComponentMeta< typeof TreeGrid > = { - title: 'Components (Experimental)/TreeGrid', - component: TreeGrid, - subcomponents: { TreeGridRow, TreeGridCell }, - argTypes: { - children: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - }, -}; -export default meta; - -const groceries = [ - { - name: 'Fruit', - types: [ - { - name: 'Apple', - }, - { - name: 'Orange', - }, - { - name: 'Pear', - }, - ], - }, - { - name: 'Vegetable', - types: [ - { - name: 'Cucumber', - }, - { - name: 'Parsnip', - }, - { - name: 'Pumpkin', - }, - ], - }, -]; - -const Descender = ( { level }: { level: number } ) => { - if ( level === 1 ) { - return null; - } - const indentation = '\u00A0'.repeat( ( level - 1 ) * 4 ); - - return <span aria-hidden="true">{ indentation + '├ ' }</span>; -}; - -type Item = { - name: string; - types?: Item[]; -}; - -const Rows = ( { - items = [], - level = 1, -}: { - items?: Item[]; - level?: number; -} ) => { - return ( - <> - { items.map( ( item, index ) => { - const hasChildren = !! item.types && !! item.types.length; - return ( - <Fragment key={ item.name }> - <TreeGridRow - positionInSet={ index + 1 } - setSize={ items.length } - level={ level } - isExpanded - > - <TreeGridCell> - { ( props ) => ( - <> - <Descender level={ level } /> - <Button variant="primary" { ...props }> - { item.name } - </Button> - </> - ) } - </TreeGridCell> - <TreeGridCell> - { ( props ) => ( - <InputControl - label="Description" - hideLabelFromVision - placeholder="Description" - { ...props } - /> - ) } - </TreeGridCell> - <TreeGridCell> - { ( props ) => ( - <InputControl - label="Notes" - hideLabelFromVision - placeholder="Notes" - { ...props } - /> - ) } - </TreeGridCell> - </TreeGridRow> - { hasChildren && ( - <Rows items={ item.types } level={ level + 1 } /> - ) } - </Fragment> - ); - } ) } - </> - ); -}; - -const Template: ComponentStory< typeof TreeGrid > = ( args ) => ( - <TreeGrid { ...args } /> -); - -export const Default = Template.bind( {} ); -Default.args = { - children: <Rows items={ groceries } />, -}; diff --git a/packages/components/src/tree-select/index.tsx b/packages/components/src/tree-select/index.tsx index 0c247901f432f7..dbb8686df4d29d 100644 --- a/packages/components/src/tree-select/index.tsx +++ b/packages/components/src/tree-select/index.tsx @@ -27,7 +27,6 @@ function getSelectOptions( /** * TreeSelect component is used to generate select input fields. * - * @example * ```jsx * import { TreeSelect } from '@wordpress/components'; * import { useState } from '@wordpress/element'; diff --git a/packages/components/src/tree-select/stories/index.story.tsx b/packages/components/src/tree-select/stories/index.story.tsx new file mode 100644 index 00000000000000..0a4212dc791227 --- /dev/null +++ b/packages/components/src/tree-select/stories/index.story.tsx @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; +import type { ComponentProps } from 'react'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TreeSelect from '../'; + +const meta: Meta< typeof TreeSelect > = { + title: 'Components/TreeSelect', + component: TreeSelect, + argTypes: { + help: { control: { type: 'text' } }, + label: { control: { type: 'text' } }, + prefix: { control: { type: 'text' } }, + suffix: { control: { type: 'text' } }, + selectedId: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; + +export default meta; + +const TreeSelectWithState: StoryFn< typeof TreeSelect > = ( props ) => { + const [ selection, setSelection ] = + useState< ComponentProps< typeof TreeSelect >[ 'selectedId' ] >(); + + return ( + <TreeSelect + { ...props } + onChange={ setSelection } + selectedId={ selection } + /> + ); +}; + +export const Default = TreeSelectWithState.bind( {} ); +Default.args = { + label: 'Label Text', + noOptionLabel: 'No parent page', + help: 'Help text to explain the select control.', + tree: [ + { + name: 'Page 1', + id: 'p1', + children: [ + { name: 'Descend 1 of page 1', id: 'p11' }, + { name: 'Descend 2 of page 1', id: 'p12' }, + ], + }, + { + name: 'Page 2', + id: 'p2', + children: [ + { + name: 'Descend 1 of page 2', + id: 'p21', + children: [ + { + name: 'Descend 1 of Descend 1 of page 2', + id: 'p211', + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/components/src/tree-select/stories/index.tsx b/packages/components/src/tree-select/stories/index.tsx deleted file mode 100644 index 63424973ee2169..00000000000000 --- a/packages/components/src/tree-select/stories/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { ComponentProps } from 'react'; -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import TreeSelect from '../'; - -const meta: ComponentMeta< typeof TreeSelect > = { - title: 'Components/TreeSelect', - component: TreeSelect, - argTypes: { - help: { control: { type: 'text' } }, - label: { control: { type: 'text' } }, - prefix: { control: { type: 'text' } }, - suffix: { control: { type: 'text' } }, - selectedId: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; - -export default meta; - -const TreeSelectWithState: ComponentStory< typeof TreeSelect > = ( props ) => { - const [ selection, setSelection ] = - useState< ComponentProps< typeof TreeSelect >[ 'selectedId' ] >(); - - return ( - <TreeSelect - { ...props } - onChange={ setSelection } - selectedId={ selection } - /> - ); -}; - -export const Default = TreeSelectWithState.bind( {} ); -Default.args = { - label: 'Label Text', - noOptionLabel: 'No parent page', - help: 'Help text to explain the select control.', - tree: [ - { - name: 'Page 1', - id: 'p1', - children: [ - { name: 'Descend 1 of page 1', id: 'p11' }, - { name: 'Descend 2 of page 1', id: 'p12' }, - ], - }, - { - name: 'Page 2', - id: 'p2', - children: [ - { - name: 'Descend 1 of page 2', - id: 'p21', - children: [ - { - name: 'Descend 1 of Descend 1 of page 2', - id: 'p211', - }, - ], - }, - ], - }, - ], -}; diff --git a/packages/components/src/truncate/component.tsx b/packages/components/src/truncate/component.tsx index cd14fd3989e88a..dfe6cd37b3e9a3 100644 --- a/packages/components/src/truncate/component.tsx +++ b/packages/components/src/truncate/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import useTruncate from './hook'; import type { TruncateProps } from './types'; diff --git a/packages/components/src/truncate/hook.ts b/packages/components/src/truncate/hook.ts index 5fac3b83efe3b0..c6afef52c09088 100644 --- a/packages/components/src/truncate/hook.ts +++ b/packages/components/src/truncate/hook.ts @@ -11,7 +11,8 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import * as styles from './styles'; import { TRUNCATE_ELLIPSIS, TRUNCATE_TYPE, truncateContent } from './utils'; import { useCx } from '../utils/hooks/use-cx'; diff --git a/packages/components/src/truncate/stories/index.story.tsx b/packages/components/src/truncate/stories/index.story.tsx new file mode 100644 index 00000000000000..84eaf59edbb3e1 --- /dev/null +++ b/packages/components/src/truncate/stories/index.story.tsx @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Truncate } from '..'; + +const meta: Meta< typeof Truncate > = { + component: Truncate, + title: 'Components (Experimental)/Truncate', + argTypes: { + children: { control: { type: 'text' } }, + as: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const defaultText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt. Duis semper dui id augue malesuada, ut feugiat nisi aliquam. Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel. In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat risus. Vivamus iaculis dui aliquet ante ultricies feugiat. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus nec pretium velit, sit amet consectetur ante. Praesent porttitor ex eget fermentum mattis.'; + +const Template: StoryFn< typeof Truncate > = ( args ) => { + return <Truncate { ...args } />; +}; + +export const Default: StoryFn< typeof Truncate > = Template.bind( {} ); +Default.args = { + numberOfLines: 2, + children: defaultText, +}; + +export const CharacterCount: StoryFn< typeof Truncate > = Template.bind( {} ); +CharacterCount.args = { + limit: 23, + children: defaultText, + ellipsizeMode: 'tail', + ellipsis: '[---]', +}; +CharacterCount.storyName = 'Truncate by character count'; diff --git a/packages/components/src/truncate/stories/index.tsx b/packages/components/src/truncate/stories/index.tsx deleted file mode 100644 index b339dac6bf9560..00000000000000 --- a/packages/components/src/truncate/stories/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Truncate } from '..'; - -const meta: ComponentMeta< typeof Truncate > = { - component: Truncate, - title: 'Components (Experimental)/Truncate', - argTypes: { - children: { control: { type: 'text' } }, - as: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const defaultText = - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt. Duis semper dui id augue malesuada, ut feugiat nisi aliquam. Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel. In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat risus. Vivamus iaculis dui aliquet ante ultricies feugiat. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus nec pretium velit, sit amet consectetur ante. Praesent porttitor ex eget fermentum mattis.'; - -const Template: ComponentStory< typeof Truncate > = ( args ) => { - return <Truncate { ...args } />; -}; - -export const Default: ComponentStory< typeof Truncate > = Template.bind( {} ); -Default.args = { - numberOfLines: 2, - children: defaultText, -}; - -export const CharacterCount: ComponentStory< typeof Truncate > = Template.bind( - {} -); -CharacterCount.args = { - limit: 23, - children: defaultText, - ellipsizeMode: 'tail', - ellipsis: '[---]', -}; -CharacterCount.storyName = 'Truncate by character count'; diff --git a/packages/components/src/ui/README.md b/packages/components/src/ui/README.md index 4fdf6dd220f563..6099fe0032851f 100644 --- a/packages/components/src/ui/README.md +++ b/packages/components/src/ui/README.md @@ -1,22 +1,5 @@ -# (Next) Component System +# TODO -This directory contains the code for next Component System. +We want to get rid of this folder. Don't add anything new here. -More information can be found in the original GitHub issue: -https://github.com/WordPress/gutenberg/issues/27484 - -## Usage - -(This is still very much a work in progress) - -```jsx -import { Text, View } from '@wordpress/components/ui'; - -function Example() { - return ( - <View> - <Text>Code is Poetry</Text> - </View> - ); -} -``` +What is left of this folder should either be deleted (if unused elsewhere), or be moved into the root `packages/components/src` and `packages/components/src/utils` folders. \ No newline at end of file diff --git a/packages/components/src/ui/context/constants.js b/packages/components/src/ui/context/constants.js index b5bee35a35fbae..aa98c3945dc17f 100644 --- a/packages/components/src/ui/context/constants.js +++ b/packages/components/src/ui/context/constants.js @@ -1,7 +1,5 @@ -export const REACT_TYPEOF_KEY = '$$typeof'; export const COMPONENT_NAMESPACE = 'data-wp-component'; export const CONNECTED_NAMESPACE = 'data-wp-c16t'; -export const CONTEXT_COMPONENT_NAMESPACE = 'data-wp-c5tc8t'; /** * Special key where the connected namespaces are stored. diff --git a/packages/components/src/ui/context/index.ts b/packages/components/src/ui/context/index.ts index 54694e47304ff2..266e2c3459a547 100644 --- a/packages/components/src/ui/context/index.ts +++ b/packages/components/src/ui/context/index.ts @@ -4,6 +4,7 @@ export { } from './context-system-provider'; export { contextConnect, + contextConnectWithoutRef, hasConnectNamespace, getConnectNamespace, } from './context-connect'; diff --git a/packages/components/src/ui/control-group/README.md b/packages/components/src/ui/control-group/README.md deleted file mode 100644 index 122a6a612a32c1..00000000000000 --- a/packages/components/src/ui/control-group/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# ControlGroup - -<div class="callout callout-alert"> -This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. -</div> - -`ControlGroup` is a layout-based component for rendering a group of control-based components, such as `Button`, `Select` or `TextInput`. Control components that render within `ControlGroup` automatically have their borders offset and border-radii rounded. - -## Usage - -```jsx -import { Button, ControlGroup, Select, TextInput } from `@wordpress/components/ui` - -function Example() { - return ( - <ControlGroup templateColumns="auto 1fr auto"> - <Select /> - <TextInput placeholder="First name" /> - <Button variant="primary" /> - </ControlGroup> - ); -} -``` diff --git a/packages/components/src/ui/control-group/component.js b/packages/components/src/ui/control-group/component.js deleted file mode 100644 index b034f52a0c82ab..00000000000000 --- a/packages/components/src/ui/control-group/component.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * External dependencies - */ - -/** - * Internal dependencies - */ -import { Flex } from '../../flex'; -import { Grid } from '../../grid'; -import { useControlGroup } from './hook'; -import { contextConnect } from '../context'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').Props, 'div'>} props - * @param {import('react').ForwardedRef<any>} forwardedRef - */ -function ControlGroup( props, forwardedRef ) { - const { - children, - direction = 'row', - templateColumns, - ...otherProps - } = useControlGroup( props ); - - const isGrid = !! templateColumns; - - if ( isGrid ) { - return ( - <Grid - gap={ 0 } - templateColumns={ templateColumns } - { ...otherProps } - ref={ forwardedRef } - > - { children } - </Grid> - ); - } - - return ( - <Flex - direction={ direction } - gap={ `-1px` } - { ...otherProps } - ref={ forwardedRef } - > - { children } - </Flex> - ); -} - -/** - * `ControlGroup` is a layout-based component for rendering a group of - * control-based components, such as `Button`, `Select` or `TextInput`. - * Control components that render within `ControlGroup` automatically - * have their borders offset and border-radii rounded. - * - * @example - * ```jsx - * import { Button, ControlGroup, Select, TextInput } from `@wordpress/components/ui`; - * - * function Example() { - * return ( - * <ControlGroup templateColumns="auto 1fr auto"> - * <Select /> - * <TextInput placeholder="First name" /> - * <Button variant="primary" /> - * </ControlGroup> - * ); - * } - * ``` - */ -const ConnectedControlGroup = contextConnect( ControlGroup, 'ControlGroup' ); - -export default ConnectedControlGroup; diff --git a/packages/components/src/ui/control-group/context.js b/packages/components/src/ui/control-group/context.js deleted file mode 100644 index 99d9c753222823..00000000000000 --- a/packages/components/src/ui/control-group/context.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * WordPress dependencies - */ -import { createContext, useContext } from '@wordpress/element'; - -/** @type {import('react').Context<import('./types').ControlGroupContext>} */ -export const ControlGroupContext = createContext( {} ); -export const useControlGroupContext = () => useContext( ControlGroupContext ); diff --git a/packages/components/src/ui/control-group/hook.js b/packages/components/src/ui/control-group/hook.js deleted file mode 100644 index bc1f5d34c08b37..00000000000000 --- a/packages/components/src/ui/control-group/hook.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Internal dependencies - */ -import { getValidChildren } from '../utils/get-valid-children'; -import { useContextSystem } from '../context'; -import { ControlGroupContext } from './context'; -import * as styles from './styles'; -import { useCx } from '../../utils/hooks/use-cx'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').Props, 'div'>} props - */ -export function useControlGroup( props ) { - const { - children, - className, - direction = 'row', - templateColumns, - ...otherProps - } = useContextSystem( props, 'ControlGroup' ); - - const validChildren = getValidChildren( children ); - const isVertical = direction === 'column'; - const isGrid = !! templateColumns; - - const cx = useCx(); - - const classes = cx( - styles.itemFocus, - isGrid && styles.itemGrid, - className - ); - - const clonedChildren = - validChildren && - validChildren.map( ( child, index ) => { - const isFirst = index === 0; - const isLast = index + 1 === validChildren.length; - const isOnly = isFirst && isLast; - const isMiddle = ! isFirst && ! isLast; - - // @ts-ignore - const _key = child?.key || index; - - /** @type {import('@emotion/react').SerializedStyles | undefined} */ - let first; - if ( isFirst ) { - if ( isVertical ) { - first = styles.firstRow; - } else { - first = styles.first; - } - } - - /** @type {import('@emotion/react').SerializedStyles | undefined} */ - let last; - if ( isLast ) { - if ( isVertical ) { - last = styles.lastRow; - } else { - last = styles.last; - } - } - - const contextStyles = cx( first, isMiddle && styles.middle, last ); - - const contextProps = { - isFirst, - isLast, - isMiddle, - isOnly, - isVertical, - styles: contextStyles, - }; - - return ( - <ControlGroupContext.Provider - key={ _key } - value={ contextProps } - > - { child } - </ControlGroupContext.Provider> - ); - } ); - - return { - ...otherProps, - children: clonedChildren, - className: classes, - direction, - templateColumns, - }; -} diff --git a/packages/components/src/ui/control-group/index.js b/packages/components/src/ui/control-group/index.js deleted file mode 100644 index bb8a730cdacd82..00000000000000 --- a/packages/components/src/ui/control-group/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default as ControlGroup } from './component'; -export * from './context'; -export * from './hook'; diff --git a/packages/components/src/ui/control-group/styles.js b/packages/components/src/ui/control-group/styles.js deleted file mode 100644 index 24f13a86ac0a2d..00000000000000 --- a/packages/components/src/ui/control-group/styles.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * External dependencies - */ -import { css } from '@emotion/react'; - -export const first = css` - border-bottom-right-radius: 0; - border-top-right-radius: 0; -`; - -export const middle = css` - border-radius: 0; -`; - -export const last = css` - border-bottom-left-radius: 0; - border-top-left-radius: 0; -`; - -export const firstRow = css` - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -`; - -export const lastRow = css` - border-top-left-radius: 0; - border-top-right-radius: 0; -`; - -export const itemFocus = css` - > * { - &:focus-within { - z-index: 1; - } - } -`; - -export const itemGrid = css` - > * + *:not( marquee ) { - margin-left: -1px; - width: calc( 100% + 1px ); - } -`; diff --git a/packages/components/src/ui/control-group/test/__snapshots__/index.js.snap b/packages/components/src/ui/control-group/test/__snapshots__/index.js.snap deleted file mode 100644 index d9f26aa5af8d20..00000000000000 --- a/packages/components/src/ui/control-group/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,51 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`props should render correctly 1`] = ` -.emotion-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - gap: -1px; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - justify-content: space-between; - width: 100%; -} - -.emotion-0>* { - min-width: 0; -} - -.emotion-0>*:focus-within { - z-index: 1; -} - -<div> - <div - class="components-flex components-control-group emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="ControlGroup" - > - <button - class="components-button" - type="button" - > - Code is Poetry - </button> - <button - class="components-button" - type="button" - > - WordPress.org - </button> - </div> -</div> -`; diff --git a/packages/components/src/ui/control-group/test/index.js b/packages/components/src/ui/control-group/test/index.js deleted file mode 100644 index 0587c329b40569..00000000000000 --- a/packages/components/src/ui/control-group/test/index.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * External dependencies - */ -import { render } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import { ControlGroup } from '..'; -import Button from '../../../button'; - -describe( 'props', () => { - test( 'should render correctly', () => { - const { container } = render( - <ControlGroup> - <Button>Code is Poetry</Button> - <Button>WordPress.org</Button> - </ControlGroup> - ); - expect( container ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/components/src/ui/control-group/types.ts b/packages/components/src/ui/control-group/types.ts deleted file mode 100644 index c7211eb35283a5..00000000000000 --- a/packages/components/src/ui/control-group/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * External dependencies - */ -import type { CSSProperties } from 'react'; - -/** - * Internal dependencies - */ -import type { FlexProps } from '../../flex/types'; - -export type ControlGroupContext = { - isFirst?: boolean; - isLast?: boolean; - isMiddle?: boolean; - isOnly?: boolean; - isVertical?: boolean; - styles?: string; -}; - -export type Props = Pick< FlexProps, 'direction' > & { - /** - * Adjust the layout (width) of content using CSS grid (`grid-template-columns`). - */ - templateColumns?: CSSProperties[ 'gridTemplateColumns' ]; - /** - * The children elements. - */ - children: React.ReactNode; -}; diff --git a/packages/components/src/ui/control-label/README.md b/packages/components/src/ui/control-label/README.md deleted file mode 100644 index d4e31e034dfd80..00000000000000 --- a/packages/components/src/ui/control-label/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# ControlLabel - -<div class="callout callout-alert"> -This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. -</div> - -`ControlLabel` is a form component that works with `FormGroup` to provide a label for form elements (e.g. `Switch` or `TextInput`). - -## Usage - -```jsx -import { ControlLabel, FormGroup, TextInput } from '@wordpress/components/ui'; - -function Example() { - return ( - <FormGroup> - <ControlLabel>First Name</ControlLabel> - <TextInput /> - </FormGroup> - ); -} -``` diff --git a/packages/components/src/ui/control-label/component.js b/packages/components/src/ui/control-label/component.js deleted file mode 100644 index 62a3e8a790c68b..00000000000000 --- a/packages/components/src/ui/control-label/component.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Internal dependencies - */ -import { contextConnect } from '../context'; -import { View } from '../../view'; -import { useControlLabel } from './hook'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').Props, 'label', false>} props - * @param {import('react').ForwardedRef<any>} forwardedRef - */ -function ControlLabel( props, forwardedRef ) { - const controlLabelProps = useControlLabel( props ); - - return <View as="label" { ...controlLabelProps } ref={ forwardedRef } />; -} - -/** - * `ControlLabel` is a form component that works with `FormGroup` to provide a - * label for form elements (e.g. `Switch` or `TextInput`). - * - * ```jsx - * import { ControlLabel, FormGroup, TextInput } from '@wordpress/components/ui'; - * - * function Example() { - * return ( - * <FormGroup> - * <ControlLabel>First Name</ControlLabel> - * <TextInput /> - * </FormGroup> - * ); - * } - * ``` - */ -const ConnectedControlLabel = contextConnect( ControlLabel, 'ControlLabel' ); - -export default ConnectedControlLabel; diff --git a/packages/components/src/ui/control-label/hook.js b/packages/components/src/ui/control-label/hook.js deleted file mode 100644 index dcdd5bb7e7effc..00000000000000 --- a/packages/components/src/ui/control-label/hook.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Internal dependencies - */ -import { useContextSystem } from '../context'; -import { useFormGroupContextId } from '../form-group'; -import { useText } from '../../text'; -import * as styles from './styles'; -import { useCx } from '../../utils/hooks/use-cx'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').Props, 'label', false>} props - */ -export function useControlLabel( props ) { - const { - htmlFor: htmlForProp, - isBlock = false, - size = 'medium', - truncate = true, - ...otherProps - } = useContextSystem( props, 'ControlLabel' ); - - const { className, ...textProps } = useText( { - ...otherProps, - isBlock, - truncate, - } ); - - const cx = useCx(); - - const htmlFor = useFormGroupContextId( htmlForProp ); - const classes = cx( - styles.ControlLabel, - styles[ /** @type {'small' | 'medium' | 'large'} */ ( size ) ], - className, - isBlock ? styles.block : styles.inline - ); - - return { - ...textProps, - className: classes, - htmlFor, - }; -} diff --git a/packages/components/src/ui/control-label/index.js b/packages/components/src/ui/control-label/index.js deleted file mode 100644 index c73e0027014e24..00000000000000 --- a/packages/components/src/ui/control-label/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as ControlLabel } from './component'; -export * from './hook'; diff --git a/packages/components/src/ui/control-label/stories/index.js b/packages/components/src/ui/control-label/stories/index.js deleted file mode 100644 index e22d46cbf70198..00000000000000 --- a/packages/components/src/ui/control-label/stories/index.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal dependencies - */ -import { ControlLabel } from '../index'; - -export default { - component: ControlLabel, - title: 'Components (Experimental)/ControlLabel', -}; - -export const _default = () => { - return <ControlLabel>Label</ControlLabel>; -}; diff --git a/packages/components/src/ui/control-label/styles.js b/packages/components/src/ui/control-label/styles.js deleted file mode 100644 index 8f96e91d0ef3fb..00000000000000 --- a/packages/components/src/ui/control-label/styles.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * External dependencies - */ -import { css } from '@emotion/react'; - -/** - * Internal dependencies - */ -import CONFIG from '../../utils/config-values'; -import { getHighDpi } from '../utils/get-high-dpi'; - -const lineHeight = `calc(${ CONFIG.fontSize } * 1.2)`; - -/** - * @param {keyof CONFIG} size The padding size. - */ -function getPadding( size ) { - return `calc((${ CONFIG[ size ] } - ${ lineHeight }) / 2)`; -} - -const highDpiAdjust = getHighDpi` - > * { - position: relative; - top: 0.5px; - } -`; - -export const ControlLabel = css` - display: inline-block; - line-height: ${ lineHeight }; - margin: 0; - max-width: 100%; - padding-bottom: ${ getPadding( 'controlHeight' ) }; - padding-top: ${ getPadding( 'controlHeight' ) }; - - &:active { - user-select: none; - } - - ${ highDpiAdjust }; -`; - -export const large = css` - padding-bottom: ${ getPadding( 'controlHeightLarge' ) }; - padding-top: ${ getPadding( 'controlHeightLarge' ) }; -`; - -export const medium = css` - padding-bottom: ${ getPadding( 'controlHeight' ) }; - padding-top: ${ getPadding( 'controlHeight' ) }; -`; - -export const small = css` - padding-bottom: ${ getPadding( 'controlHeightSmall' ) }; - padding-top: ${ getPadding( 'controlHeightSmall' ) }; -`; - -export const inline = css` - display: inline-block; - vertical-align: middle; -`; - -export const block = css` - display: block; -`; diff --git a/packages/components/src/ui/control-label/test/__snapshots__/index.js.snap b/packages/components/src/ui/control-label/test/__snapshots__/index.js.snap deleted file mode 100644 index ffe2d42932d002..00000000000000 --- a/packages/components/src/ui/control-label/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,141 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`props should render correctly 1`] = ` -.emotion-0 { - display: inline-block; - line-height: calc(13px * 1.2); - margin: 0; - max-width: 100%; - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: #1e1e1e; - line-height: 1.2; - margin: 0; - font-size: calc((13 / 13) * 13px); - font-weight: normal; - display: inline-block; - vertical-align: middle; -} - -.emotion-0:active { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -@media ( -webkit-min-device-pixel-ratio: 1.25 ), ( min-resolution: 120dpi ) { - .emotion-0>* { - position: relative; - top: 0.5px; - } -} - -<div> - <label - class="components-truncate components-text components-control-label emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="ControlLabel" - > - Label - </label> -</div> -`; - -exports[`props should render no truncate 1`] = ` -.emotion-0 { - display: inline-block; - line-height: calc(13px * 1.2); - margin: 0; - max-width: 100%; - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - color: #1e1e1e; - line-height: 1.2; - margin: 0; - font-size: calc((13 / 13) * 13px); - font-weight: normal; - display: inline-block; - vertical-align: middle; -} - -.emotion-0:active { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -@media ( -webkit-min-device-pixel-ratio: 1.25 ), ( min-resolution: 120dpi ) { - .emotion-0>* { - position: relative; - top: 0.5px; - } -} - -<div> - <label - class="components-truncate components-text components-control-label emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="ControlLabel" - > - Label - </label> -</div> -`; - -exports[`props should render size 1`] = ` -.emotion-0 { - display: inline-block; - line-height: calc(13px * 1.2); - margin: 0; - max-width: 100%; - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - padding-bottom: calc((calc( 36px * 0.8 ) - calc(13px * 1.2)) / 2); - padding-top: calc((calc( 36px * 0.8 ) - calc(13px * 1.2)) / 2); - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: #1e1e1e; - line-height: 1.2; - margin: 0; - font-size: calc((13 / 13) * 13px); - font-weight: normal; - display: inline-block; - vertical-align: middle; -} - -.emotion-0:active { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -@media ( -webkit-min-device-pixel-ratio: 1.25 ), ( min-resolution: 120dpi ) { - .emotion-0>* { - position: relative; - top: 0.5px; - } -} - -<div> - <label - class="components-truncate components-text components-control-label emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="ControlLabel" - > - Label - </label> -</div> -`; diff --git a/packages/components/src/ui/control-label/test/index.js b/packages/components/src/ui/control-label/test/index.js deleted file mode 100644 index 4443457a566f9d..00000000000000 --- a/packages/components/src/ui/control-label/test/index.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import { ControlLabel } from '../index'; - -describe( 'props', () => { - test( 'should render correctly', () => { - const { container } = render( <ControlLabel>Label</ControlLabel> ); - - expect( container ).toMatchSnapshot(); - } ); - - test( 'should render htmlFor', () => { - render( <ControlLabel htmlFor="Field">Label</ControlLabel> ); - - expect( screen.getByText( 'Label' ) ).toHaveAttribute( 'for', 'Field' ); - } ); - - test( 'should render size', () => { - const { container } = render( - <ControlLabel size="small">Label</ControlLabel> - ); - - expect( container ).toMatchSnapshot(); - } ); - - test( 'should render no truncate', () => { - const { container } = render( - <ControlLabel truncate={ false }>Label</ControlLabel> - ); - - expect( container ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/components/src/ui/control-label/types.ts b/packages/components/src/ui/control-label/types.ts deleted file mode 100644 index e76b7d3d895e73..00000000000000 --- a/packages/components/src/ui/control-label/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Internal dependencies - */ -import type { Props as TextProps } from '../../text/types'; - -export type Props = TextProps & { - isBlock?: boolean; - size?: 'large' | 'medium' | 'small'; -}; diff --git a/packages/components/src/ui/form-group/README.md b/packages/components/src/ui/form-group/README.md deleted file mode 100644 index fec4316caf3553..00000000000000 --- a/packages/components/src/ui/form-group/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# FormGroup - -<div class="callout callout-alert"> -This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. -</div> - -`FormGroup` is a form component that groups a label with a form element (e.g. `Switch` or `TextInput`). - -## Usage - -```jsx -import { FormGroup, TextInput } from '@wordpress/components/ui'; - -function Example() { - return ( - <FormGroup label="First name"> - <TextInput /> - </FormGroup> - ); -} -``` - -## Props - -##### align - -**Type**: `CSS['alignItems']` - -Adjusts the block alignment of children. - -##### alignLabel - -**Type**: `Pick<TextProps, "align">` - -Aligns the label within `FormGroup`. - -##### alignment - -**Type**: `string` - -Adjusts the horizontal and vertical alignment of children. - -##### columns - -**Type**: `number`,`(number`,`null)[]` - -Adjusts the number of columns of the `Grid`. - -##### gap - -**Type**: `number` - -Gap between each child. - -##### help - -**Type**: `React.ReactElement` - -Displays help content. - -##### horizontal - -**Type**: `boolean` - -Displays the label and form field horizontally. - -##### isInline - -**Type**: `boolean` - -Changes the CSS display from `grid` to `inline-grid`. - -##### justify - -**Type**: `CSS['justifyContent']` - -Adjusts the inline alignment of children. - -##### label - -**Type**: `string` - -Label of the form field. - -##### labelHidden - -**Type**: `boolean` - -Visually hides the label. - -##### rows - -**Type**: `number`,`(number`,`null)[]` - -Adjusts the number of rows of the `Grid`. - -##### templateColumns - -**Type**: `CSS['gridTemplateColumns']` - -Adjusts the CSS grid `template-columns`. - -##### templateRows - -**Type**: `CSS['gridTemplateRows']` - -Adjusts the CSS grid `template-rows`. - -##### truncate - -**Type**: `boolean` - -Truncates the label text content. diff --git a/packages/components/src/ui/form-group/form-group-content.js b/packages/components/src/ui/form-group/form-group-content.js deleted file mode 100644 index a9bdc87ec1b175..00000000000000 --- a/packages/components/src/ui/form-group/form-group-content.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WordPress dependencies - */ -import { useMemo, memo } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { VStack } from '../../v-stack'; -import { FormGroupContext } from './form-group-context'; -import FormGroupHelp from './form-group-help'; -import FormGroupLabel from './form-group-label'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').FormGroupContentProps, 'label', false>} props - */ -function FormGroupContent( { - alignLabel, - children, - help, - horizontal = false, - id, - label, - labelHidden, - spacing = 2, - truncate, - ...props -} ) { - const contextProps = useMemo( - () => ( { id, horizontal } ), - [ id, horizontal ] - ); - - const content = help ? ( - <VStack expanded={ false } spacing={ spacing }> - { children } - <FormGroupHelp>{ help }</FormGroupHelp> - </VStack> - ) : ( - children - ); - - return ( - <FormGroupContext.Provider value={ contextProps }> - <FormGroupLabel - align={ alignLabel } - id={ id } - labelHidden={ labelHidden } - truncate={ truncate } - { ...props } - > - { label } - </FormGroupLabel> - { content } - </FormGroupContext.Provider> - ); -} - -export default memo( FormGroupContent ); diff --git a/packages/components/src/ui/form-group/form-group-context.js b/packages/components/src/ui/form-group/form-group-context.js deleted file mode 100644 index e98f93de5095ac..00000000000000 --- a/packages/components/src/ui/form-group/form-group-context.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * WordPress dependencies - */ -import { createContext, useContext } from '@wordpress/element'; - -/** - * @typedef {{ id?: import('react').ReactText, horizontal: boolean }} FormGroupContext - */ - -/** - * @type {FormGroupContext} - */ -const initialContext = { - id: undefined, - horizontal: true, -}; - -/** - * @type {import('react').Context<FormGroupContext>} - */ -export const FormGroupContext = createContext( initialContext ); -export const useFormGroupContext = () => useContext( FormGroupContext ); - -/** - * @param {string | undefined} id The preferred id for the form group element. - * @return {import('react').ReactText | undefined} The form group context id. - */ -export const useFormGroupContextId = ( id ) => { - const contextId = useFormGroupContext().id; - return id || contextId; -}; diff --git a/packages/components/src/ui/form-group/form-group-help.js b/packages/components/src/ui/form-group/form-group-help.js deleted file mode 100644 index 456d5325b5cbb0..00000000000000 --- a/packages/components/src/ui/form-group/form-group-help.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * WordPress dependencies - */ -import { memo } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { ContextSystemProvider } from '../context'; - -const contextValue = { Text: { variant: 'muted' } }; - -/** - * @typedef Props - * @property {import('react').ReactNode} [children] The content to display within `FormGroupHelp`. - */ - -/** - * - * @param {Props} props - */ -function FormGroupHelp( { children } ) { - if ( ! children ) return null; - - return ( - <ContextSystemProvider value={ contextValue }> - { children } - </ContextSystemProvider> - ); -} - -export default memo( FormGroupHelp ); diff --git a/packages/components/src/ui/form-group/form-group-label.js b/packages/components/src/ui/form-group/form-group-label.js deleted file mode 100644 index 21a4a2dda5b129..00000000000000 --- a/packages/components/src/ui/form-group/form-group-label.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * WordPress dependencies - */ -import { memo } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { ControlLabel } from '../control-label'; -import { VisuallyHidden } from '../../visually-hidden'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').FormGroupLabelProps, 'label', false>} props - * @return {JSX.Element | null} The form group's label. - */ -function FormGroupLabel( { children, id, labelHidden = false, ...props } ) { - if ( ! children ) return null; - - if ( labelHidden ) { - return ( - <VisuallyHidden as="label" htmlFor={ id?.toString() }> - { children } - </VisuallyHidden> - ); - } - - return <ControlLabel { ...props }>{ children }</ControlLabel>; -} - -export default memo( FormGroupLabel ); diff --git a/packages/components/src/ui/form-group/form-group-styles.js b/packages/components/src/ui/form-group/form-group-styles.js deleted file mode 100644 index b675565d243562..00000000000000 --- a/packages/components/src/ui/form-group/form-group-styles.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * External dependencies - */ -import { css } from '@emotion/react'; - -export const FormGroup = css` - width: 100%; -`; diff --git a/packages/components/src/ui/form-group/form-group.js b/packages/components/src/ui/form-group/form-group.js deleted file mode 100644 index 79881be46f7116..00000000000000 --- a/packages/components/src/ui/form-group/form-group.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Internal dependencies - */ -import { contextConnect } from '../context'; -import { Grid } from '../../grid'; -import { View } from '../../view'; -import FormGroupContent from './form-group-content'; -import { useFormGroup } from './use-form-group'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').FormGroupProps, 'div'>} props - * @param {import('react').ForwardedRef<any>} forwardedRef - */ -function FormGroup( props, forwardedRef ) { - const { contentProps, horizontal, ...otherProps } = useFormGroup( props ); - - if ( horizontal ) { - return ( - <Grid - templateColumns="minmax(0, 1fr) 2fr" - { ...otherProps } - ref={ forwardedRef } - > - <FormGroupContent { ...contentProps } /> - </Grid> - ); - } - - return ( - <View { ...otherProps } ref={ forwardedRef }> - <FormGroupContent { ...contentProps } /> - </View> - ); -} - -/** - * `FormGroup` is a form component that groups a label with a form element (e.g. `Switch` or `TextInput`). - * - * @example - * ```jsx - * import { FormGroup, TextInput } from `@wordpress/components/ui`; - * - * function Example() { - * return ( - * <FormGroup label="First name"> - * <TextInput /> - * </FormGroup> - * ); - * } - * ``` - */ -const ConnectedFormGroup = contextConnect( FormGroup, 'FormGroup' ); - -export default ConnectedFormGroup; diff --git a/packages/components/src/ui/form-group/index.js b/packages/components/src/ui/form-group/index.js deleted file mode 100644 index 64c29381529b28..00000000000000 --- a/packages/components/src/ui/form-group/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default as FormGroup } from './form-group'; - -export * from './use-form-group'; -export * from './form-group-context'; diff --git a/packages/components/src/ui/form-group/stories/index.js b/packages/components/src/ui/form-group/stories/index.js deleted file mode 100644 index 804a41ad265f26..00000000000000 --- a/packages/components/src/ui/form-group/stories/index.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Internal dependencies - */ -import { FormGroup, useFormGroupContextId } from '../index'; -import { Text } from '../../../text'; - -// @todo: Refactor this after adding next TextInput component. -const TextInput = ( { id: idProp, ...props } ) => { - const id = useFormGroupContextId( idProp ); - return ( - <div> - <input type="text" id={ id } { ...props } /> - </div> - ); -}; - -export default { - component: FormGroup, - title: 'Components (Experimental)/FormGroup', -}; - -export const _default = () => { - return ( - <FormGroup label="Form Group"> - <TextInput /> - </FormGroup> - ); -}; - -export const _labelHidden = () => { - return ( - <FormGroup label="Form Group" labelHidden> - <TextInput /> - </FormGroup> - ); -}; - -export const _help = () => { - return ( - <FormGroup help={ <Text>Help Text</Text> } label="Form Group"> - <TextInput /> - </FormGroup> - ); -}; diff --git a/packages/components/src/ui/form-group/test/__snapshots__/index.js.snap b/packages/components/src/ui/form-group/test/__snapshots__/index.js.snap deleted file mode 100644 index b10a44c09d5d8b..00000000000000 --- a/packages/components/src/ui/form-group/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,121 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`props should render alignLabel 1`] = ` -.emotion-0 { - width: 100%; -} - -.emotion-2 { - display: inline-block; - line-height: calc(13px * 1.2); - margin: 0; - max-width: 100%; - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - color: #1e1e1e; - line-height: 1.2; - margin: 0; - font-size: calc((13 / 13) * 13px); - font-weight: normal; - text-align: right; - display: inline-block; - vertical-align: middle; -} - -.emotion-2:active { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -@media ( -webkit-min-device-pixel-ratio: 1.25 ), ( min-resolution: 120dpi ) { - .emotion-2>* { - position: relative; - top: 0.5px; - } -} - -<div> - <div - class="components-form-group emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="FormGroup" - > - <label - class="components-truncate components-text components-control-label emotion-2 emotion-1" - data-wp-c16t="true" - data-wp-component="ControlLabel" - for="fname" - > - First name - </label> - <input - id="fname" - type="text" - /> - </div> -</div> -`; - -exports[`props should render vertically 1`] = ` -.emotion-0 { - width: 100%; -} - -.emotion-2 { - display: inline-block; - line-height: calc(13px * 1.2); - margin: 0; - max-width: 100%; - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - padding-bottom: calc((36px - calc(13px * 1.2)) / 2); - padding-top: calc((36px - calc(13px * 1.2)) / 2); - color: #1e1e1e; - line-height: 1.2; - margin: 0; - font-size: calc((13 / 13) * 13px); - font-weight: normal; - text-align: left; - display: inline-block; - vertical-align: middle; -} - -.emotion-2:active { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -@media ( -webkit-min-device-pixel-ratio: 1.25 ), ( min-resolution: 120dpi ) { - .emotion-2>* { - position: relative; - top: 0.5px; - } -} - -<div> - <div - class="components-form-group emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="FormGroup" - > - <label - class="components-truncate components-text components-control-label emotion-2 emotion-1" - data-wp-c16t="true" - data-wp-component="ControlLabel" - for="fname" - > - First name - </label> - <input - id="fname" - type="text" - /> - </div> -</div> -`; diff --git a/packages/components/src/ui/form-group/test/index.js b/packages/components/src/ui/form-group/test/index.js deleted file mode 100644 index b6bfcd4d8f2110..00000000000000 --- a/packages/components/src/ui/form-group/test/index.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import { ControlLabel } from '../../control-label'; -import { FormGroup, useFormGroupContextId } from '../index'; - -// @todo: Refactor this after adding next TextInput component. -const TextInput = ( { id: idProp, ...props } ) => { - const id = useFormGroupContextId( idProp ); - return <input type="text" id={ id } { ...props } />; -}; - -// We're intentionally using a string literal for the ID to ensure -// the htmlFor and id properties of the label and inputs are bound correctly. -/* eslint-disable no-restricted-syntax */ -describe( 'props', () => { - test( 'should render correctly', () => { - render( - <FormGroup id="fname" label="First name"> - <TextInput /> - </FormGroup> - ); - - expect( - screen.getByRole( 'textbox', { name: 'First name' } ) - ).toBeVisible(); - } ); - - test( 'should render label without prop correctly', () => { - render( - <FormGroup id="fname"> - <ControlLabel htmlFor="fname">First name</ControlLabel> - <TextInput /> - </FormGroup> - ); - - expect( - screen.getByRole( 'textbox', { name: 'First name' } ) - ).toBeVisible(); - } ); - - test( 'should render labelHidden', () => { - render( - <FormGroup labelHidden id="fname" label="First name"> - <TextInput /> - </FormGroup> - ); - - expect( - screen.getByRole( 'textbox', { name: 'First name' } ) - ).toBeVisible(); - expect( screen.getByText( 'First name' ) ).toHaveClass( - 'components-visually-hidden' - ); - } ); - - test( 'should render alignLabel', () => { - const { container } = render( - <FormGroup alignLabel="right" id="fname" label="First name"> - <TextInput /> - </FormGroup> - ); - - expect( container ).toMatchSnapshot(); - } ); - - test( 'should render vertically', () => { - const { container } = render( - <FormGroup horizontal={ false } id="fname" label="First name"> - <TextInput /> - </FormGroup> - ); - - expect( container ).toMatchSnapshot(); - } ); -} ); -/* eslint-enable no-restricted-syntax */ diff --git a/packages/components/src/ui/form-group/types.ts b/packages/components/src/ui/form-group/types.ts deleted file mode 100644 index 288acfdba7e171..00000000000000 --- a/packages/components/src/ui/form-group/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import type { CSSProperties, ReactNode, ReactText } from 'react'; - -/** - * Internal dependencies - */ -import type { Props as ControlLabelProps } from '../control-label/types'; -import type { GridProps } from '../../grid/types'; - -export type FormGroupLabelProps = ControlLabelProps & { - labelHidden?: boolean; - id?: ReactText; -}; - -export type FormGroupContentProps = FormGroupLabelProps & { - alignLabel?: CSSProperties[ 'textAlign' ]; - help?: ReactNode; - horizontal?: boolean; - label?: ReactNode; - spacing?: CSSProperties[ 'width' ]; - truncate?: boolean; -}; - -type Horizontal = GridProps & { - horizontal: true; -}; - -type Vertical = { horizontal: false }; - -export type FormGroupProps = FormGroupContentProps & ( Horizontal | Vertical ); diff --git a/packages/components/src/ui/form-group/use-form-group.js b/packages/components/src/ui/form-group/use-form-group.js deleted file mode 100644 index eebc5ae3506398..00000000000000 --- a/packages/components/src/ui/form-group/use-form-group.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * WordPress dependencies - */ -import { useInstanceId } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import { useContextSystem } from '../context'; -import * as styles from './form-group-styles'; -import { useCx } from '../../utils/hooks/use-cx'; - -/** - * @param {import('../context').WordPressComponentProps<import('./types').FormGroupProps, 'div'>} props - */ -export function useFormGroup( props ) { - const { - alignLabel = 'left', - children, - className, - help, - horizontal = false, - id: idProp, - label, - labelHidden = false, - truncate = false, - ...otherProps - } = useContextSystem( props, 'FormGroup' ); - - const id = useInstanceId( useFormGroup, 'form-group', idProp ); - - const cx = useCx(); - - const classes = cx( styles.FormGroup, className ); - - const contentProps = { - alignLabel, - children, - help, - id, - horizontal, - label, - labelHidden, - truncate, - }; - - return { - ...otherProps, - className: classes, - contentProps, - horizontal, - }; -} diff --git a/packages/components/src/ui/index.js b/packages/components/src/ui/index.js deleted file mode 100644 index e8587d84b298ad..00000000000000 --- a/packages/components/src/ui/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export * from './control-group'; -export * from './control-label'; -export * from './form-group'; -export * from './shortcut'; -export * from './spinner'; diff --git a/packages/components/src/ui/shortcut/component.tsx b/packages/components/src/ui/shortcut/component.tsx index b48f8d385a3147..f6131428a2546f 100644 --- a/packages/components/src/ui/shortcut/component.tsx +++ b/packages/components/src/ui/shortcut/component.tsx @@ -6,11 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { - useContextSystem, - contextConnect, - WordPressComponentProps, -} from '../context'; +import type { WordPressComponentProps } from '../context'; +import { useContextSystem, contextConnect } from '../context'; import { View } from '../../view'; export interface ShortcutDescription { diff --git a/packages/components/src/ui/spinner/README.md b/packages/components/src/ui/spinner/README.md deleted file mode 100644 index 5e25f9992d0c32..00000000000000 --- a/packages/components/src/ui/spinner/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Spinner - -<div class="callout callout-alert"> -This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. -</div> - -`Spinner` is a component that notify users that their action is being processed. - -## Usage - -```jsx -import { Spinner } from '@wordpress/components/ui'; - -function Example() { - return <Spinner />; -} -``` - -## Props - -##### color - -**Type**: `CSSProperties['color']` - -The color of the `Spinner`. - -##### size - -**Type**: `number` - -The size of the `Spinner`. diff --git a/packages/components/src/ui/spinner/component.js b/packages/components/src/ui/spinner/component.js deleted file mode 100644 index d8da4fcf708231..00000000000000 --- a/packages/components/src/ui/spinner/component.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Internal dependencies - */ -import { BarsView, BarsWrapperView, ContainerView } from './styles'; -import { BASE_SIZE, WRAPPER_SIZE } from './utils'; -import { contextConnect, useContextSystem } from '../context'; -import { COLORS } from '../../utils/colors-values'; - -/* eslint-disable jsdoc/valid-types */ -/** - * @typedef Props - * @property {import('react').CSSProperties['color']} [color] Color of `Spinner`. - * @property {number} [size=16] Size of `Spinner`. - */ -/* eslint-enable jsdoc/valid-types */ - -/** - * - * @param {import('../context').WordPressComponentProps<Props, 'div'>} props - * @param {import('react').ForwardedRef<any>} forwardedRef - */ -function Spinner( props, forwardedRef ) { - const { - color = COLORS.gray[ 900 ], - size = BASE_SIZE, - ...otherProps - } = useContextSystem( props, 'Spinner' ); - const ratio = size / BASE_SIZE; - const scale = ( ratio * BASE_SIZE ) / WRAPPER_SIZE; - const transform = `scale(${ scale })`; - - const styles = { transform }; - - return ( - <ContainerView - { ...otherProps } - aria-busy={ true } - ref={ forwardedRef } - style={ { height: size, width: size } } - > - <BarsWrapperView aria-hidden={ true } style={ styles }> - <BarsView style={ { color } }> - <div className="InnerBar1" /> - <div className="InnerBar2" /> - <div className="InnerBar3" /> - <div className="InnerBar4" /> - <div className="InnerBar5" /> - <div className="InnerBar6" /> - <div className="InnerBar7" /> - <div className="InnerBar8" /> - <div className="InnerBar9" /> - <div className="InnerBar10" /> - <div className="InnerBar11" /> - <div className="InnerBar12" /> - </BarsView> - </BarsWrapperView> - </ContainerView> - ); -} - -/** - * `Spinner` is a component that notify users that their action is being processed. - * - * @example - * ```jsx - * import { Spinner } from `@wordpress/components/ui`; - * - * function Example() { - * return ( - * <Spinner /> - * ); - * } - * ``` - */ -export default contextConnect( Spinner, 'Spinner' ); diff --git a/packages/components/src/ui/spinner/index.js b/packages/components/src/ui/spinner/index.js deleted file mode 100644 index 84ac37f7401d54..00000000000000 --- a/packages/components/src/ui/spinner/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as Spinner } from './component'; diff --git a/packages/components/src/ui/spinner/stories/index.js b/packages/components/src/ui/spinner/stories/index.js deleted file mode 100644 index 9cb006d883c1ee..00000000000000 --- a/packages/components/src/ui/spinner/stories/index.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Internal dependencies - */ -import { Spinner } from '../index'; - -export default { - component: Spinner, - title: 'Components (Experimental)/Spinner', -}; - -export const _default = () => { - return ( - <> - <Spinner /> - <Spinner size={ 30 } /> - <Spinner color="blue" size="50" /> - </> - ); -}; diff --git a/packages/components/src/ui/spinner/styles.js b/packages/components/src/ui/spinner/styles.js deleted file mode 100644 index 057cca34c68460..00000000000000 --- a/packages/components/src/ui/spinner/styles.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * External dependencies - */ -import styled from '@emotion/styled'; - -/** - * Internal dependencies - */ -import { WRAPPER_SIZE } from './utils'; - -export const ContainerView = styled.div` - display: flex; - pointer-events: none; - position: relative; -`; - -export const BarsWrapperView = styled.div` - height: ${ WRAPPER_SIZE }px; - left: 0; - opacity: 0.6; - position: absolute; - top: 0; - transform-origin: top left; - width: ${ WRAPPER_SIZE }px; -`; - -export const BarsView = styled.div` - color: currentColor; - display: inline-flex; - height: 54px; - left: 50%; - padding: 10px; - position: absolute; - top: 50%; - transform: translate( -50%, -50% ); - width: 54px; - - > div { - animation: ComponentsUISpinnerFadeAnimation 1000ms linear infinite; - background: currentColor; - border-radius: 50px; - height: 16%; - left: 49%; - opacity: 0; - position: absolute; - top: 43%; - width: 6%; - } - - @keyframes ComponentsUISpinnerFadeAnimation { - from { - opacity: 1; - } - to { - opacity: 0.25; - } - } - - .InnerBar1 { - animation-delay: 0s; - transform: rotate( 0deg ) translate( 0, -130% ); - } - - .InnerBar2 { - animation-delay: -0.9167s; - transform: rotate( 30deg ) translate( 0, -130% ); - } - - .InnerBar3 { - animation-delay: -0.833s; - transform: rotate( 60deg ) translate( 0, -130% ); - } - .InnerBar4 { - animation-delay: -0.7497s; - transform: rotate( 90deg ) translate( 0, -130% ); - } - .InnerBar5 { - animation-delay: -0.667s; - transform: rotate( 120deg ) translate( 0, -130% ); - } - .InnerBar6 { - animation-delay: -0.5837s; - transform: rotate( 150deg ) translate( 0, -130% ); - } - .InnerBar7 { - animation-delay: -0.5s; - transform: rotate( 180deg ) translate( 0, -130% ); - } - .InnerBar8 { - animation-delay: -0.4167s; - transform: rotate( 210deg ) translate( 0, -130% ); - } - .InnerBar9 { - animation-delay: -0.333s; - transform: rotate( 240deg ) translate( 0, -130% ); - } - .InnerBar10 { - animation-delay: -0.2497s; - transform: rotate( 270deg ) translate( 0, -130% ); - } - .InnerBar11 { - animation-delay: -0.167s; - transform: rotate( 300deg ) translate( 0, -130% ); - } - .InnerBar12 { - animation-delay: -0.0833s; - transform: rotate( 330deg ) translate( 0, -130% ); - } -`; diff --git a/packages/components/src/ui/spinner/test/__snapshots__/index.js.snap b/packages/components/src/ui/spinner/test/__snapshots__/index.js.snap deleted file mode 100644 index 70fd3df3a5b484..00000000000000 --- a/packages/components/src/ui/spinner/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,265 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`props should render color 1`] = ` -Snapshot Diff: -- First value -+ Second value - -@@ -11,11 +11,11 @@ - class="css-1rq9ofd-BarsWrapperView e1s9yo7h1" - style="transform: scale(0.4444444444444444);" - > - <div - class="css-tada40-BarsView e1s9yo7h0" -- style="color: blue;" -+ style="color: rgb(30, 30, 30);" - > - <div - class="InnerBar1" - /> - <div -`; - -exports[`props should render correctly 1`] = ` -.emotion-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - pointer-events: none; - position: relative; -} - -.emotion-2 { - height: 36px; - left: 0; - opacity: 0.6; - position: absolute; - top: 0; - transform-origin: top left; - width: 36px; -} - -.emotion-4 { - color: currentColor; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - height: 54px; - left: 50%; - padding: 10px; - position: absolute; - top: 50%; - -webkit-transform: translate( -50%, -50% ); - -moz-transform: translate( -50%, -50% ); - -ms-transform: translate( -50%, -50% ); - transform: translate( -50%, -50% ); - width: 54px; -} - -.emotion-4>div { - -webkit-animation: ComponentsUISpinnerFadeAnimation 1000ms linear infinite; - animation: ComponentsUISpinnerFadeAnimation 1000ms linear infinite; - background: currentColor; - border-radius: 50px; - height: 16%; - left: 49%; - opacity: 0; - position: absolute; - top: 43%; - width: 6%; -} - -.emotion-4 .InnerBar1 { - -webkit-animation-delay: 0s; - animation-delay: 0s; - -webkit-transform: rotate( 0deg ) translate( 0, -130% ); - -moz-transform: rotate( 0deg ) translate( 0, -130% ); - -ms-transform: rotate( 0deg ) translate( 0, -130% ); - transform: rotate( 0deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar2 { - -webkit-animation-delay: -0.9167s; - animation-delay: -0.9167s; - -webkit-transform: rotate( 30deg ) translate( 0, -130% ); - -moz-transform: rotate( 30deg ) translate( 0, -130% ); - -ms-transform: rotate( 30deg ) translate( 0, -130% ); - transform: rotate( 30deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar3 { - -webkit-animation-delay: -0.833s; - animation-delay: -0.833s; - -webkit-transform: rotate( 60deg ) translate( 0, -130% ); - -moz-transform: rotate( 60deg ) translate( 0, -130% ); - -ms-transform: rotate( 60deg ) translate( 0, -130% ); - transform: rotate( 60deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar4 { - -webkit-animation-delay: -0.7497s; - animation-delay: -0.7497s; - -webkit-transform: rotate( 90deg ) translate( 0, -130% ); - -moz-transform: rotate( 90deg ) translate( 0, -130% ); - -ms-transform: rotate( 90deg ) translate( 0, -130% ); - transform: rotate( 90deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar5 { - -webkit-animation-delay: -0.667s; - animation-delay: -0.667s; - -webkit-transform: rotate( 120deg ) translate( 0, -130% ); - -moz-transform: rotate( 120deg ) translate( 0, -130% ); - -ms-transform: rotate( 120deg ) translate( 0, -130% ); - transform: rotate( 120deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar6 { - -webkit-animation-delay: -0.5837s; - animation-delay: -0.5837s; - -webkit-transform: rotate( 150deg ) translate( 0, -130% ); - -moz-transform: rotate( 150deg ) translate( 0, -130% ); - -ms-transform: rotate( 150deg ) translate( 0, -130% ); - transform: rotate( 150deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar7 { - -webkit-animation-delay: -0.5s; - animation-delay: -0.5s; - -webkit-transform: rotate( 180deg ) translate( 0, -130% ); - -moz-transform: rotate( 180deg ) translate( 0, -130% ); - -ms-transform: rotate( 180deg ) translate( 0, -130% ); - transform: rotate( 180deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar8 { - -webkit-animation-delay: -0.4167s; - animation-delay: -0.4167s; - -webkit-transform: rotate( 210deg ) translate( 0, -130% ); - -moz-transform: rotate( 210deg ) translate( 0, -130% ); - -ms-transform: rotate( 210deg ) translate( 0, -130% ); - transform: rotate( 210deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar9 { - -webkit-animation-delay: -0.333s; - animation-delay: -0.333s; - -webkit-transform: rotate( 240deg ) translate( 0, -130% ); - -moz-transform: rotate( 240deg ) translate( 0, -130% ); - -ms-transform: rotate( 240deg ) translate( 0, -130% ); - transform: rotate( 240deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar10 { - -webkit-animation-delay: -0.2497s; - animation-delay: -0.2497s; - -webkit-transform: rotate( 270deg ) translate( 0, -130% ); - -moz-transform: rotate( 270deg ) translate( 0, -130% ); - -ms-transform: rotate( 270deg ) translate( 0, -130% ); - transform: rotate( 270deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar11 { - -webkit-animation-delay: -0.167s; - animation-delay: -0.167s; - -webkit-transform: rotate( 300deg ) translate( 0, -130% ); - -moz-transform: rotate( 300deg ) translate( 0, -130% ); - -ms-transform: rotate( 300deg ) translate( 0, -130% ); - transform: rotate( 300deg ) translate( 0, -130% ); -} - -.emotion-4 .InnerBar12 { - -webkit-animation-delay: -0.0833s; - animation-delay: -0.0833s; - -webkit-transform: rotate( 330deg ) translate( 0, -130% ); - -moz-transform: rotate( 330deg ) translate( 0, -130% ); - -ms-transform: rotate( 330deg ) translate( 0, -130% ); - transform: rotate( 330deg ) translate( 0, -130% ); -} - -<div> - <div - aria-busy="true" - class="components-spinner emotion-0 emotion-1" - data-wp-c16t="true" - data-wp-component="Spinner" - style="height: 16px; width: 16px;" - > - <div - aria-hidden="true" - class="emotion-2 emotion-3" - style="transform: scale(0.4444444444444444);" - > - <div - class="emotion-4 emotion-5" - style="color: rgb(30, 30, 30);" - > - <div - class="InnerBar1" - /> - <div - class="InnerBar2" - /> - <div - class="InnerBar3" - /> - <div - class="InnerBar4" - /> - <div - class="InnerBar5" - /> - <div - class="InnerBar6" - /> - <div - class="InnerBar7" - /> - <div - class="InnerBar8" - /> - <div - class="InnerBar9" - /> - <div - class="InnerBar10" - /> - <div - class="InnerBar11" - /> - <div - class="InnerBar12" - /> - </div> - </div> - </div> -</div> -`; - -exports[`props should render size 1`] = ` -Snapshot Diff: -- First value -+ Second value - -@@ -2,16 +2,16 @@ - <div - aria-busy="true" - class="components-spinner css-14ywscc-ContainerView e1s9yo7h2" - data-wp-c16t="true" - data-wp-component="Spinner" -- style="height: 31px; width: 31px;" -+ style="height: 16px; width: 16px;" - > - <div - aria-hidden="true" - class="css-1rq9ofd-BarsWrapperView e1s9yo7h1" -- style="transform: scale(0.8611111111111112);" -+ style="transform: scale(0.4444444444444444);" - > - <div - class="css-tada40-BarsView e1s9yo7h0" - style="color: rgb(30, 30, 30);" - > -`; diff --git a/packages/components/src/ui/spinner/test/index.js b/packages/components/src/ui/spinner/test/index.js deleted file mode 100644 index aa9a4360fedff7..00000000000000 --- a/packages/components/src/ui/spinner/test/index.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import { render } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import { Spinner } from '..'; - -describe( 'props', () => { - test( 'should render correctly', () => { - const { container } = render( <Spinner /> ); - expect( container ).toMatchSnapshot(); - } ); - - test( 'should render color', () => { - const { container } = render( <Spinner /> ); - const { container: secondRenderContainer } = render( - <Spinner color="blue" /> - ); - expect( secondRenderContainer ).toMatchDiffSnapshot( container ); - } ); - - test( 'should render size', () => { - const { container } = render( <Spinner /> ); - const { container: secondRenderContainer } = render( - <Spinner size={ 31 } /> - ); - expect( secondRenderContainer ).toMatchDiffSnapshot( container ); - } ); -} ); diff --git a/packages/components/src/ui/spinner/utils.js b/packages/components/src/ui/spinner/utils.js deleted file mode 100644 index 843ec1133b953d..00000000000000 --- a/packages/components/src/ui/spinner/utils.js +++ /dev/null @@ -1,2 +0,0 @@ -export const BASE_SIZE = 16; -export const WRAPPER_SIZE = 36; diff --git a/packages/components/src/ui/tooltip/stories/index.js b/packages/components/src/ui/tooltip/stories/index.story.js similarity index 100% rename from packages/components/src/ui/tooltip/stories/index.js rename to packages/components/src/ui/tooltip/stories/index.story.js diff --git a/packages/components/src/ui/tooltip/test/index.js b/packages/components/src/ui/tooltip/test/index.js index f8f9fb36b4238b..b95a866738bd20 100644 --- a/packages/components/src/ui/tooltip/test/index.js +++ b/packages/components/src/ui/tooltip/test/index.js @@ -21,7 +21,7 @@ describe( 'props', () => { test( 'should render correctly', () => { render( <VisibleTooltip /> ); - const tooltip = screen.getByRole( /tooltip/i ); + const tooltip = screen.getByRole( 'tooltip' ); expect( tooltip ).toMatchSnapshot(); } ); @@ -37,7 +37,7 @@ describe( 'props', () => { <Text>{ invisibleTooltipTriggerContent }</Text> </Tooltip> ); - const tooltip = screen.getByRole( /tooltip/i ); + const tooltip = screen.getByRole( 'tooltip' ); const invisibleTooltipTrigger = screen.getByText( invisibleTooltipTriggerContent ); @@ -59,7 +59,7 @@ describe( 'props', () => { visible /> ); - const tooltips = screen.getAllByRole( /tooltip/i ); + const tooltips = screen.getAllByRole( 'tooltip' ); const childlessTooltip = tooltips.find( byId( childlessTooltipId ) ); expect( childlessTooltip ).not.toBeUndefined(); } ); @@ -72,7 +72,7 @@ describe( 'props', () => { <Text>WordPress.org</Text> </Tooltip> ); - const tooltip = screen.getByRole( /tooltip/i ); + const tooltip = screen.getByRole( 'tooltip' ); // Assert only the base tooltip rendered. expect( tooltip ).toBeInTheDocument(); expect( tooltip.id ).toBe( baseTooltipId ); diff --git a/packages/components/src/ui/utils/get-high-dpi.ts b/packages/components/src/ui/utils/get-high-dpi.ts deleted file mode 100644 index 17e6ad2a2c0951..00000000000000 --- a/packages/components/src/ui/utils/get-high-dpi.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * External dependencies - */ -import { css } from '@emotion/react'; -// eslint-disable-next-line no-restricted-imports -import type { CSSInterpolation } from '@emotion/css'; - -export function getHighDpi( - strings: TemplateStringsArray, - ...interpolations: CSSInterpolation[] -) { - const interpolatedStyles = css( strings, ...interpolations ); - - return css` - @media ( -webkit-min-device-pixel-ratio: 1.25 ), - ( min-resolution: 120dpi ) { - ${ interpolatedStyles } - } - `; -} diff --git a/packages/components/src/unit-control/index.tsx b/packages/components/src/unit-control/index.tsx index 5efb4aacfd0e08..cd5bf4e4787682 100644 --- a/packages/components/src/unit-control/index.tsx +++ b/packages/components/src/unit-control/index.tsx @@ -1,12 +1,7 @@ /** * External dependencies */ -import type { - FocusEventHandler, - KeyboardEvent, - ForwardedRef, - SyntheticEvent, -} from 'react'; +import type { KeyboardEvent, ForwardedRef, SyntheticEvent } from 'react'; import classnames from 'classnames'; /** @@ -20,7 +15,6 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import type { WordPressComponentProps } from '../ui/context'; -import * as inputControlActionTypes from '../input-control/reducer/actions'; import { ValueInput } from './styles/unit-control-styles'; import UnitSelectControl from './unit-select-control'; import { @@ -30,8 +24,8 @@ import { getValidParsedQuantityAndUnit, } from './utils'; import { useControlledState } from '../utils/hooks'; +import { escapeRegExp } from '../utils/strings'; import type { UnitControlProps, UnitControlOnChangeCallback } from './types'; -import type { StateReducer } from '../input-control/reducer/state'; function UnforwardedUnitControl( unitControlProps: WordPressComponentProps< @@ -42,7 +36,7 @@ function UnforwardedUnitControl( forwardedRef: ForwardedRef< any > ) { const { - __unstableStateReducer: stateReducerProp, + __unstableStateReducer, autoComplete = 'off', // @ts-expect-error Ensure that children is omitted from restProps children, @@ -59,7 +53,6 @@ function UnforwardedUnitControl( unit: unitProp, units: unitsProp = CSS_UNITS, value: valueProp, - onBlur: onBlurProp, onFocus: onFocusProp, ...props } = unitControlProps; @@ -76,10 +69,19 @@ function UnforwardedUnitControl( // ensures it fallback to `undefined` in case a consumer of `UnitControl` // still passes `null` as a `value`. const nonNullValueProp = valueProp ?? undefined; - const units = useMemo( - () => getUnitsWithCurrentUnit( nonNullValueProp, unitProp, unitsProp ), - [ nonNullValueProp, unitProp, unitsProp ] - ); + const [ units, reFirstCharacterOfUnits ] = useMemo( () => { + const list = getUnitsWithCurrentUnit( + nonNullValueProp, + unitProp, + unitsProp + ); + const [ { value: firstUnitValue = '' } = {}, ...rest ] = list; + const firstCharacters = rest.reduce( ( carry, { value } ) => { + const first = escapeRegExp( value?.substring( 0, 1 ) || '' ); + return carry.includes( first ) ? carry : `${ carry }|${ first }`; + }, escapeRegExp( firstUnitValue.substring( 0, 1 ) ) ); + return [ list, new RegExp( `^(?:${ firstCharacters })$`, 'i' ) ]; + }, [ nonNullValueProp, unitProp, unitsProp ] ); const [ parsedQuantity, parsedUnit ] = getParsedQuantityAndUnit( nonNullValueProp, unitProp, @@ -100,9 +102,6 @@ function UnforwardedUnitControl( } }, [ parsedUnit, setUnit ] ); - // Stores parsed value for hand-off in state reducer. - const refParsedQuantity = useRef< number | undefined >( undefined ); - const classes = classnames( 'components-unit-control', // This class is added for legacy purposes to maintain it on the outer @@ -158,85 +157,22 @@ function UnforwardedUnitControl( setUnit( nextUnitValue ); }; - const mayUpdateUnit = ( event: SyntheticEvent< HTMLInputElement > ) => { - if ( ! isNaN( Number( event.currentTarget.value ) ) ) { - refParsedQuantity.current = undefined; - return; - } - const [ validParsedQuantity, validParsedUnit ] = - getValidParsedQuantityAndUnit( - event.currentTarget.value, - units, - parsedQuantity, - unit - ); - - refParsedQuantity.current = validParsedQuantity; - - if ( isPressEnterToChange && validParsedUnit !== unit ) { - const data = Array.isArray( units ) - ? units.find( ( option ) => option.value === validParsedUnit ) - : undefined; - const changeProps = { event, data }; - - // The `onChange` callback already gets called, no need to call it explicitly. - onUnitChange?.( validParsedUnit, changeProps ); - - setUnit( validParsedUnit ); - } - }; - - const handleOnBlur: FocusEventHandler< HTMLInputElement > = ( event ) => { - mayUpdateUnit( event ); - onBlurProp?.( event ); - }; - - const handleOnKeyDown = ( event: KeyboardEvent< HTMLInputElement > ) => { - const { key } = event; - if ( key === 'Enter' ) { - mayUpdateUnit( event ); - } - }; - - /** - * "Middleware" function that intercepts updates from InputControl. - * This allows us to tap into actions to transform the (next) state for - * InputControl. - * - * @param state State from InputControl - * @param action Action triggering state change - * @return The updated state to apply to InputControl - */ - const unitControlStateReducer: StateReducer = ( state, action ) => { - const nextState = { ...state }; - - /* - * On commits (when pressing ENTER and on blur if - * isPressEnterToChange is true), if a parse has been performed - * then use that result to update the state. - */ - if ( action.type === inputControlActionTypes.COMMIT ) { - if ( refParsedQuantity.current !== undefined ) { - nextState.value = ( - refParsedQuantity.current ?? '' - ).toString(); - refParsedQuantity.current = undefined; - } - } - - return nextState; - }; - - let stateReducer: StateReducer = unitControlStateReducer; - if ( stateReducerProp ) { - stateReducer = ( state, action ) => { - const baseState = unitControlStateReducer( state, action ); - return stateReducerProp( baseState, action ); + let handleOnKeyDown; + if ( ! disableUnits && isUnitSelectTabbable && units.length ) { + handleOnKeyDown = ( event: KeyboardEvent< HTMLInputElement > ) => { + props.onKeyDown?.( event ); + // Unless the meta key was pressed (to avoid interfering with + // shortcuts, e.g. pastes), moves focus to the unit select if a key + // matches the first character of a unit. + if ( ! event.metaKey && reFirstCharacterOfUnits.test( event.key ) ) + refInputSuffix.current?.focus(); }; } + const refInputSuffix = useRef< HTMLSelectElement >( null ); const inputSuffix = ! disableUnits ? ( <UnitSelectControl + ref={ refInputSuffix } aria-label={ __( 'Select unit' ) } disabled={ disabled } isUnitSelectTabbable={ isUnitSelectTabbable } @@ -244,8 +180,8 @@ function UnforwardedUnitControl( size={ size } unit={ unit } units={ units } - onBlur={ onBlurProp } onFocus={ onFocusProp } + onBlur={ unitControlProps.onBlur } /> ) : null; @@ -262,7 +198,6 @@ function UnforwardedUnitControl( return ( <ValueInput - type={ isPressEnterToChange ? 'text' : 'number' } { ...props } autoComplete={ autoComplete } className={ classes } @@ -270,16 +205,16 @@ function UnforwardedUnitControl( spinControls="none" isPressEnterToChange={ isPressEnterToChange } label={ label } - onBlur={ handleOnBlur } onKeyDown={ handleOnKeyDown } onChange={ handleOnQuantityChange } ref={ forwardedRef } size={ size } suffix={ inputSuffix } + type={ isPressEnterToChange ? 'text' : 'number' } value={ parsedQuantity ?? '' } step={ step } - __unstableStateReducer={ stateReducer } onFocus={ onFocusProp } + __unstableStateReducer={ __unstableStateReducer } /> ); } @@ -288,7 +223,6 @@ function UnforwardedUnitControl( * `UnitControl` allows the user to set a numeric quantity as well as a unit (e.g. `px`). * * - * @example * ```jsx * import { __experimentalUnitControl as UnitControl } from '@wordpress/components'; * import { useState } from '@wordpress/element'; diff --git a/packages/components/src/unit-control/stories/index.story.tsx b/packages/components/src/unit-control/stories/index.story.tsx new file mode 100644 index 00000000000000..5134d4902144da --- /dev/null +++ b/packages/components/src/unit-control/stories/index.story.tsx @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { UnitControl } from '../'; +import { CSS_UNITS } from '../utils'; + +const meta: Meta< typeof UnitControl > = { + component: UnitControl, + title: 'Components (Experimental)/UnitControl', + argTypes: { + __unstableInputWidth: { control: { type: 'text' } }, + __unstableStateReducer: { control: { type: null } }, + onChange: { control: { type: null } }, + onUnitChange: { control: { type: null } }, + prefix: { control: { type: 'text' } }, + value: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const DefaultTemplate: StoryFn< typeof UnitControl > = ( { + onChange, + ...args +} ) => { + const [ value, setValue ] = useState< string | undefined >( '10px' ); + + return ( + <UnitControl + { ...args } + value={ value } + onChange={ ( v, extra ) => { + setValue( v ); + onChange?.( v, extra ); + } } + /> + ); +}; + +export const Default: StoryFn< typeof UnitControl > = DefaultTemplate.bind( + {} +); +Default.args = { + label: 'Label', +}; + +/** + * If the `isPressEnterToChange` prop is set to `true`, the `onChange` callback + * will not fire while a new value is typed in the input field (you can verify this + * behavior by inspecting the console's output). + */ +export const PressEnterToChange: StoryFn< typeof UnitControl > = + DefaultTemplate.bind( {} ); +PressEnterToChange.args = { + ...Default.args, + isPressEnterToChange: true, +}; + +/** + * Most of `NumberControl`'s props can be passed to `UnitControl`, and they will + * affect its numeric input field. + */ +export const TweakingTheNumberInput: StoryFn< typeof UnitControl > = + DefaultTemplate.bind( {} ); +TweakingTheNumberInput.args = { + ...Default.args, + min: 0, + max: 100, + step: 'any', + label: 'Custom label', +}; + +/** + * When only one unit is available, the unit selection dropdown becomes static text. + */ +export const WithSingleUnit: StoryFn< typeof UnitControl > = + DefaultTemplate.bind( {} ); +WithSingleUnit.args = { + ...Default.args, + units: CSS_UNITS.slice( 0, 1 ), +}; + +/** + * It is possible to pass a custom list of units. Every time the unit changes, + * if the `isResetValueOnUnitChange` is set to `true`, the input's quantity is + * reset to the new unit's default value. + */ +export const WithCustomUnits: StoryFn< typeof UnitControl > = ( { + onChange, + ...args +} ) => { + const [ value, setValue ] = useState< string | undefined >( '80km' ); + + return ( + <UnitControl + { ...args } + value={ value } + onChange={ ( v, extra ) => { + setValue( v ); + onChange?.( v, extra ); + } } + /> + ); +}; +WithCustomUnits.args = { + ...Default.args, + isResetValueOnUnitChange: true, + min: 0, + units: [ + { + value: 'km', + label: 'km', + default: 1, + }, + { + value: 'mi', + label: 'mi', + default: 1, + }, + { + value: 'm', + label: 'm', + default: 1000, + }, + { + value: 'yd', + label: 'yd', + default: 1760, + }, + ], +}; diff --git a/packages/components/src/unit-control/stories/index.tsx b/packages/components/src/unit-control/stories/index.tsx deleted file mode 100644 index 6fe4b49a5687db..00000000000000 --- a/packages/components/src/unit-control/stories/index.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { UnitControl } from '../'; -import { CSS_UNITS } from '../utils'; - -const meta: ComponentMeta< typeof UnitControl > = { - component: UnitControl, - title: 'Components (Experimental)/UnitControl', - argTypes: { - __unstableInputWidth: { control: { type: 'text' } }, - __unstableStateReducer: { control: { type: null } }, - onChange: { control: { type: null } }, - onUnitChange: { control: { type: null } }, - prefix: { control: { type: 'text' } }, - value: { control: { type: null } }, - }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const DefaultTemplate: ComponentStory< typeof UnitControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState< string | undefined >( '10px' ); - - return ( - <UnitControl - { ...args } - value={ value } - onChange={ ( v, extra ) => { - setValue( v ); - onChange?.( v, extra ); - } } - /> - ); -}; - -export const Default: ComponentStory< typeof UnitControl > = - DefaultTemplate.bind( {} ); -Default.args = { - label: 'Label', -}; - -/** - * If the `isPressEnterToChange` prop is set to `true`, the `onChange` callback - * will not fire while a new value is typed in the input field (you can verify this - * behavior by inspecting the console's output). - */ -export const PressEnterToChange: ComponentStory< typeof UnitControl > = - DefaultTemplate.bind( {} ); -PressEnterToChange.args = { - ...Default.args, - isPressEnterToChange: true, - onChange: ( v ) => { - // eslint-disable-next-line no-console - console.log( v ); - }, -}; - -/** - * Most of `NumberControl`'s props can be passed to `UnitControl`, and they will - * affect its numeric input field. - */ -export const TweakingTheNumberInput: ComponentStory< typeof UnitControl > = - DefaultTemplate.bind( {} ); -TweakingTheNumberInput.args = { - ...Default.args, - min: 0, - max: 100, - step: 'any', - label: 'Custom label', -}; - -/** - * When only one unit is available, the unit selection dropdown becomes static text. - */ -export const WithSingleUnit: ComponentStory< typeof UnitControl > = - DefaultTemplate.bind( {} ); -WithSingleUnit.args = { - ...Default.args, - units: CSS_UNITS.slice( 0, 1 ), -}; - -/** - * It is possible to pass a custom list of units. Every time the unit changes, - * if the `isResetValueOnUnitChange` is set to `true`, the input's quantity is - * reset to the new unit's default value. - */ -export const WithCustomUnits: ComponentStory< typeof UnitControl > = ( { - onChange, - ...args -} ) => { - const [ value, setValue ] = useState< string | undefined >( '80km' ); - - return ( - <UnitControl - { ...args } - value={ value } - onChange={ ( v, extra ) => { - setValue( v ); - onChange?.( v, extra ); - } } - /> - ); -}; -WithCustomUnits.args = { - ...Default.args, - isResetValueOnUnitChange: true, - min: 0, - units: [ - { - value: 'km', - label: 'km', - default: 1, - }, - { - value: 'mi', - label: 'mi', - default: 1, - }, - { - value: 'm', - label: 'm', - default: 1000, - }, - { - value: 'yd', - label: 'yd', - default: 1760, - }, - ], -}; diff --git a/packages/components/src/unit-control/styles/unit-control-styles.ts b/packages/components/src/unit-control/styles/unit-control-styles.ts index 9cf51b2d3bbdae..e5557307b0b0d0 100644 --- a/packages/components/src/unit-control/styles/unit-control-styles.ts +++ b/packages/components/src/unit-control/styles/unit-control-styles.ts @@ -54,7 +54,7 @@ const baseUnitLabelStyles = ( { selectSize }: SelectProps ) => { height: 24px; margin-inline-end: ${ space( 2 ) }; padding: ${ space( 1 ) }; - color: ${ COLORS.ui.theme }; + color: ${ COLORS.theme.accent }; font-size: 13px; line-height: 1; text-align-last: center; diff --git a/packages/components/src/unit-control/test/index.tsx b/packages/components/src/unit-control/test/index.tsx index b4103ac4b2b8b7..777004a6e8ae27 100644 --- a/packages/components/src/unit-control/test/index.tsx +++ b/packages/components/src/unit-control/test/index.tsx @@ -13,8 +13,7 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import UnitControl from '..'; -import { parseQuantityAndUnitFromRawValue } from '../utils'; -import type { UnitControlOnChangeCallback } from '../types'; +import { CSS_UNITS, parseQuantityAndUnitFromRawValue } from '../utils'; const getInput = ( { isInputTypeText = false, @@ -137,11 +136,9 @@ describe( 'UnitControl', () => { describe( 'Value', () => { it( 'should update value on change', async () => { const user = userEvent.setup(); + const onChangeSpy = jest.fn(); - let state = '50px'; - const setState = jest.fn( ( value ) => ( state = value ) ); - - render( <UnitControl value={ state } onChange={ setState } /> ); + render( <UnitControl value={ '50px' } onChange={ onChangeSpy } /> ); const input = getInput(); await user.clear( input ); @@ -151,81 +148,85 @@ describe( 'UnitControl', () => { // - 1: clear // - 2: type '6' // - 3: type '62' - expect( setState ).toHaveBeenCalledTimes( 3 ); - expect( state ).toBe( '62px' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 3 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '62px', + expect.anything() + ); } ); it( 'should increment value on UP press', async () => { const user = userEvent.setup(); + const onChangeSpy = jest.fn(); - let state: string | undefined = '50px'; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); - - render( <UnitControl value={ state } onChange={ setState } /> ); + render( <UnitControl value={ '50px' } onChange={ onChangeSpy } /> ); const input = getInput(); await user.type( input, '{ArrowUp}' ); - expect( state ).toBe( '51px' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '51px', + expect.anything() + ); } ); it( 'should increment value on UP + SHIFT press, with step', async () => { const user = userEvent.setup(); + const onChangeSpy = jest.fn(); - let state: string | undefined = '50px'; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); - - render( <UnitControl value={ state } onChange={ setState } /> ); + render( <UnitControl value={ '50px' } onChange={ onChangeSpy } /> ); const input = getInput(); await user.type( input, '{Shift>}{ArrowUp}{/Shift}' ); - expect( state ).toBe( '60px' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '60px', + expect.anything() + ); } ); it( 'should decrement value on DOWN press', async () => { const user = userEvent.setup(); + const onChangeSpy = jest.fn(); - let state: string | number | undefined = 50; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); - - render( <UnitControl value={ state } onChange={ setState } /> ); + render( <UnitControl value={ 50 } onChange={ onChangeSpy } /> ); const input = getInput(); await user.type( input, '{ArrowDown}' ); - expect( state ).toBe( '49px' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '49px', + expect.anything() + ); } ); it( 'should decrement value on DOWN + SHIFT press, with step', async () => { const user = userEvent.setup(); + const onChangeSpy = jest.fn(); - let state: string | number | undefined = 50; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); - - render( <UnitControl value={ state } onChange={ setState } /> ); + render( <UnitControl value={ 50 } onChange={ onChangeSpy } /> ); const input = getInput(); await user.type( input, '{Shift>}{ArrowDown}{/Shift}' ); - expect( state ).toBe( '40px' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '40px', + expect.anything() + ); } ); it( 'should cancel change when ESCAPE key is pressed', async () => { const user = userEvent.setup(); - - let state: string | number | undefined = 50; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); + const onChangeSpy = jest.fn(); render( <UnitControl - value={ state } - onChange={ setState } + value={ 50 } + onChange={ onChangeSpy } isPressEnterToChange /> ); @@ -233,15 +234,15 @@ describe( 'UnitControl', () => { // Input type is `text` when the `isPressEnterToChange` prop is passed const input = getInput( { isInputTypeText: true } ); await user.clear( input ); - await user.type( input, '300px' ); + await user.type( input, '300' ); - expect( input.value ).toBe( '300px' ); - expect( state ).toBe( 50 ); + expect( input.value ).toBe( '300' ); + expect( onChangeSpy ).not.toHaveBeenCalled(); await user.keyboard( '{Escape}' ); expect( input.value ).toBe( '50' ); - expect( state ).toBe( 50 ); + expect( onChangeSpy ).not.toHaveBeenCalled(); } ); it( 'should run onBlur callback when quantity input is blurred', async () => { @@ -250,16 +251,10 @@ describe( 'UnitControl', () => { const onChangeSpy = jest.fn(); const onBlurSpy = jest.fn(); - let state: string | undefined = '33%'; - const setState: UnitControlOnChangeCallback = ( nextState ) => { - onChangeSpy( nextState ); - state = nextState; - }; - render( <UnitControl - value={ state } - onChange={ setState } + value={ '33%' } + onChange={ onChangeSpy } onBlur={ onBlurSpy } /> ); @@ -269,7 +264,10 @@ describe( 'UnitControl', () => { await user.type( input, '41' ); expect( onChangeSpy ).toHaveBeenCalledTimes( 3 ); - expect( onChangeSpy ).toHaveBeenLastCalledWith( '41%' ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '41%', + expect.anything() + ); // Clicking document.body to trigger a blur event on the input. await user.click( document.body ); @@ -277,23 +275,15 @@ describe( 'UnitControl', () => { expect( onBlurSpy ).toHaveBeenCalledTimes( 1 ); } ); - it( 'should invoke onChange and onUnitChange callbacks when isPressEnterToChange is true and the component is blurred with an uncommitted value', async () => { + it( 'should invoke onChange when isPressEnterToChange is true and the input is blurred with an uncommitted value', async () => { const user = userEvent.setup(); - const onUnitChangeSpy = jest.fn(); const onChangeSpy = jest.fn(); - let state: string | undefined = '15px'; - const setState: UnitControlOnChangeCallback = ( nextState ) => { - onChangeSpy( nextState ); - state = nextState; - }; - render( <UnitControl - value={ state } - onChange={ setState } - onUnitChange={ onUnitChangeSpy } + value={ '15px' } + onChange={ onChangeSpy } isPressEnterToChange /> ); @@ -301,21 +291,18 @@ describe( 'UnitControl', () => { // Input type is `text` when the `isPressEnterToChange` prop is passed const input = getInput( { isInputTypeText: true } ); await user.clear( input ); - await user.type( input, '41vh' ); - - // This is because `isPressEnterToChange` is `true` - expect( onChangeSpy ).not.toHaveBeenCalled(); - expect( onUnitChangeSpy ).not.toHaveBeenCalled(); - - // Clicking document.body to trigger a blur event on the input. - await user.click( document.body ); + // Typing the first letter of a unit blurs the input. + await user.type( input, '41v' ); + // Called only once because `isPressEnterToChange` is `true`. expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); - expect( onChangeSpy ).toHaveBeenLastCalledWith( '41vh' ); - expect( onUnitChangeSpy ).toHaveBeenCalledTimes( 1 ); - expect( onUnitChangeSpy ).toHaveBeenLastCalledWith( - 'vh', + // The correct expected behavior would be for the `onChangeSpy` callback + // to be called twice, first with `41px` and immediately after with `41vh`, + // but the test environment doesn't seem to change values on `select` + // elements when using the keyboard. + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '41px', expect.anything() ); } ); @@ -356,52 +343,56 @@ describe( 'UnitControl', () => { describe( 'Unit', () => { it( 'should update unit value on change', async () => { const user = userEvent.setup(); - - let state: string | undefined = '14rem'; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); - - const spy = jest.fn(); + const onChangeSpy = jest.fn(); + const onUnitChangeSpy = jest.fn(); render( <UnitControl - value={ state } - onChange={ setState } - onUnitChange={ spy } + value={ '14rem' } + onChange={ onChangeSpy } + onUnitChange={ onUnitChangeSpy } /> ); const select = getSelect(); await user.selectOptions( select, [ 'px' ] ); - expect( spy ).toHaveBeenCalledWith( 'px', expect.anything() ); - expect( state ).toBe( '14px' ); + expect( onUnitChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onUnitChangeSpy ).toHaveBeenLastCalledWith( + 'px', + expect.anything() + ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '14px', + expect.anything() + ); } ); it( 'should render customized units, if defined', () => { const units = [ { value: 'pt', label: 'pt', default: 0 }, { value: 'vmax', label: 'vmax', default: 10 }, + // Proves that units with regex control characters don't error. + { value: '+', label: '+', default: 10 }, ]; render( <UnitControl units={ units } /> ); const options = getSelectOptions(); - expect( options.length ).toBe( 2 ); + expect( options.length ).toBe( 3 ); - const [ pt, vmax ] = options; + const [ pt, vmax, plus ] = options; expect( pt.value ).toBe( 'pt' ); expect( vmax.value ).toBe( 'vmax' ); + expect( plus.value ).toBe( '+' ); } ); it( 'should reset value on unit change, if unit has default value', async () => { const user = userEvent.setup(); - - let state: string | number | undefined = 50; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); + const onChangeSpy = jest.fn(); const units = [ { value: 'pt', label: 'pt', default: 25 }, @@ -412,27 +403,32 @@ describe( 'UnitControl', () => { <UnitControl isResetValueOnUnitChange units={ units } - onChange={ setState } - value={ state } + onChange={ onChangeSpy } + value={ 50 } /> ); const select = getSelect(); await user.selectOptions( select, [ 'vmax' ] ); - expect( state ).toBe( '75vmax' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '75vmax', + expect.anything() + ); await user.selectOptions( select, [ 'pt' ] ); - expect( state ).toBe( '25pt' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 2 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '25pt', + expect.anything() + ); } ); it( 'should not reset value on unit change, if disabled', async () => { const user = userEvent.setup(); - - let state: string | number | undefined = 50; - const setState: UnitControlOnChangeCallback = ( nextState ) => - ( state = nextState ); + const onChangeSpy = jest.fn(); const units = [ { value: 'pt', label: 'pt', default: 25 }, @@ -442,34 +438,39 @@ describe( 'UnitControl', () => { render( <UnitControl isResetValueOnUnitChange={ false } - value={ state } + value={ 50 } units={ units } - onChange={ setState } + onChange={ onChangeSpy } /> ); const select = getSelect(); await user.selectOptions( select, [ 'vmax' ] ); - expect( state ).toBe( '50vmax' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 1 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '50vmax', + expect.anything() + ); await user.selectOptions( select, [ 'pt' ] ); - expect( state ).toBe( '50pt' ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 2 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '50pt', + expect.anything() + ); } ); it( 'should set correct unit if single units', async () => { const user = userEvent.setup(); - - let state: string | undefined = '50%'; - const setState: UnitControlOnChangeCallback = ( value ) => - ( state = value ); + const onChangeSpy = jest.fn(); render( <UnitControl - value={ state } + value={ '50%' } units={ [ { value: '%', label: '%' } ] } - onChange={ setState } + onChange={ onChangeSpy } /> ); @@ -477,7 +478,15 @@ describe( 'UnitControl', () => { await user.clear( input ); await user.type( input, '62' ); - expect( state ).toBe( '62%' ); + // 3 times: + // - 1: clear + // - 2: type '6' + // - 3: type '62' + expect( onChangeSpy ).toHaveBeenCalledTimes( 3 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '62%', + expect.anything() + ); } ); it( 'should update unit value when a new raw value is passed', async () => { @@ -560,121 +569,6 @@ describe( 'UnitControl', () => { } ); describe( 'Unit Parser', () => { - it( 'should parse unit from input', async () => { - const user = userEvent.setup(); - - let state = '10px'; - const setState = jest.fn( ( nextState ) => ( state = nextState ) ); - - render( - <UnitControl - value={ state } - onChange={ setState } - isPressEnterToChange - /> - ); - - // Input type is `text` when the `isPressEnterToChange` prop is passed - const input = getInput( { isInputTypeText: true } ); - await user.clear( input ); - await user.type( input, '55 em' ); - await user.keyboard( '{Enter}' ); - - expect( state ).toBe( '55em' ); - } ); - - it( 'should parse PX unit from input', async () => { - const user = userEvent.setup(); - - let state = '10px'; - const setState = jest.fn( ( nextState ) => ( state = nextState ) ); - - render( - <UnitControl - value={ state } - onChange={ setState } - isPressEnterToChange - /> - ); - - // Input type is `text` when the `isPressEnterToChange` prop is passed - const input = getInput( { isInputTypeText: true } ); - await user.clear( input ); - await user.type( input, '61 PX' ); - await user.keyboard( '{Enter}' ); - - expect( state ).toBe( '61px' ); - } ); - - it( 'should parse EM unit from input', async () => { - const user = userEvent.setup(); - - let state = '10px'; - const setState = jest.fn( ( nextState ) => ( state = nextState ) ); - - render( - <UnitControl - value={ state } - onChange={ setState } - isPressEnterToChange - /> - ); - - // Input type is `text` when the `isPressEnterToChange` prop is passed - const input = getInput( { isInputTypeText: true } ); - await user.clear( input ); - await user.type( input, '55 em' ); - await user.keyboard( '{Enter}' ); - - expect( state ).toBe( '55em' ); - } ); - - it( 'should parse % unit from input', async () => { - const user = userEvent.setup(); - - let state = '10px'; - const setState = jest.fn( ( nextState ) => ( state = nextState ) ); - - render( - <UnitControl - value={ state } - onChange={ setState } - isPressEnterToChange - /> - ); - - // Input type is `text` when the `isPressEnterToChange` prop is passed - const input = getInput( { isInputTypeText: true } ); - await user.clear( input ); - await user.type( input, '-10 %' ); - await user.keyboard( '{Enter}' ); - - expect( state ).toBe( '-10%' ); - } ); - - it( 'should parse REM unit from input', async () => { - const user = userEvent.setup(); - - let state = '10px'; - const setState = jest.fn( ( nextState ) => ( state = nextState ) ); - - render( - <UnitControl - value={ state } - onChange={ setState } - isPressEnterToChange - /> - ); - - // Input type is `text` when the `isPressEnterToChange` prop is passed - const input = getInput( { isInputTypeText: true } ); - await user.clear( input ); - await user.type( input, '123 rEm ' ); - await user.keyboard( '{Enter}' ); - - expect( state ).toBe( '123rem' ); - } ); - it( 'should update unit after initial render and with new unit prop', async () => { const { rerender } = render( <UnitControl value={ '10%' } /> ); @@ -711,4 +605,41 @@ describe( 'UnitControl', () => { expect( options.length ).toBe( 3 ); } ); } ); + + describe( 'Unit switching convenience', () => { + it.each( CSS_UNITS.map( ( { value } ) => value ) )( + 'should move focus from the input to the unit select when typing the first character of %p', + async ( testUnit ) => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + const onUnitChangeSpy = jest.fn(); + + render( + <UnitControl + value={ '10%' } + onChange={ onChangeSpy } + onUnitChange={ onUnitChangeSpy } + /> + ); + + const input = getInput(); + await user.clear( input ); + await user.type( input, `55${ testUnit }` ); + + expect( getSelect() ).toHaveFocus(); + // The unit character was not entered in the input. + expect( input ).toHaveValue( 55 ); + + // The correct expected behavior would be for onChangeSpy to be + // called 4 times, and for the last value it was called with to be + // `55${testUnit}`, but the test environment doesn't seem to change + // values on `select` elements when using the keyboard. + expect( onChangeSpy ).toHaveBeenCalledTimes( 3 ); + expect( onChangeSpy ).toHaveBeenLastCalledWith( + '55%', + expect.anything() + ); + } + ); + } ); } ); diff --git a/packages/components/src/unit-control/unit-select-control.tsx b/packages/components/src/unit-control/unit-select-control.tsx index 844415fd0e4263..99f481ae506fd0 100644 --- a/packages/components/src/unit-control/unit-select-control.tsx +++ b/packages/components/src/unit-control/unit-select-control.tsx @@ -2,7 +2,12 @@ * External dependencies */ import classnames from 'classnames'; -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; /** * Internal dependencies @@ -12,15 +17,18 @@ import { UnitSelect, UnitLabel } from './styles/unit-control-styles'; import { CSS_UNITS, hasUnits } from './utils'; import type { UnitSelectControlProps } from './types'; -export default function UnitSelectControl( { - className, - isUnitSelectTabbable: isTabbable = true, - onChange, - size = 'default', - unit = 'px', - units = CSS_UNITS, - ...props -}: WordPressComponentProps< UnitSelectControlProps, 'select', false > ) { +function UnitSelectControl( + { + className, + isUnitSelectTabbable: isTabbable = true, + onChange, + size = 'default', + unit = 'px', + units = CSS_UNITS, + ...props + }: WordPressComponentProps< UnitSelectControlProps, 'select', false >, + ref: ForwardedRef< any > +) { if ( ! hasUnits( units ) || units?.length === 1 ) { return ( <UnitLabel @@ -43,6 +51,7 @@ export default function UnitSelectControl( { return ( <UnitSelect + ref={ ref } className={ classes } onChange={ handleOnChange } selectSize={ size } @@ -58,3 +67,4 @@ export default function UnitSelectControl( { </UnitSelect> ); } +export default forwardRef( UnitSelectControl ); diff --git a/packages/components/src/utils/browsers.js b/packages/components/src/utils/browsers.js deleted file mode 100644 index 7c852465d5f1be..00000000000000 --- a/packages/components/src/utils/browsers.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * External dependencies - */ -import { css } from '@emotion/react'; - -/* eslint-disable jsdoc/no-undefined-types */ -/** - * @param {TemplateStringsArray} strings - * @param {import('@emotion/css/create-instance').CSSInterpolation[]} interpolations - */ -export function firefoxOnly( strings, ...interpolations ) { - const interpolatedStyles = css( strings, ...interpolations ); - - return css` - @-moz-document url-prefix() { - ${ interpolatedStyles }; - } - `; -} - -/** - * @param {TemplateStringsArray} strings - * @param {import('@emotion/css/create-instance').CSSInterpolation[]} interpolations - */ -export function safariOnly( strings, ...interpolations ) { - const interpolatedStyles = css( strings, ...interpolations ); - - return css` - @media not all and ( min-resolution: 0.001dpcm ) { - @supports ( -webkit-appearance: none ) { - ${ interpolatedStyles } - } - } - `; -} -/* eslint-enable jsdoc/no-undefined-types */ diff --git a/packages/components/src/utils/colors-values.js b/packages/components/src/utils/colors-values.js index 556455b09ce336..473330b853a9c3 100644 --- a/packages/components/src/utils/colors-values.js +++ b/packages/components/src/utils/colors-values.js @@ -37,8 +37,6 @@ const ADMIN = { }; const UI = { - theme: ADMIN.theme, - themeDark10: ADMIN.themeDark10, background: white, backgroundDisabled: GRAY[ 100 ], border: GRAY[ 600 ], @@ -53,6 +51,11 @@ const UI = { lightGrayPlaceholder: rgba( white, 0.65 ), }; +const THEME = { + accent: ADMIN.theme, + accentDarker10: ADMIN.themeDark10, +}; + export const COLORS = Object.freeze( { /** * The main gray color object. @@ -60,6 +63,7 @@ export const COLORS = Object.freeze( { gray: GRAY, white, alert: ALERT, + theme: THEME, ui: UI, } ); diff --git a/packages/components/src/utils/config-values.js b/packages/components/src/utils/config-values.js index 40c15a22ec17e3..7f4ee6971b8c46 100644 --- a/packages/components/src/utils/config-values.js +++ b/packages/components/src/utils/config-values.js @@ -9,14 +9,14 @@ const CONTROL_PADDING_X = '12px'; const CONTROL_PROPS = { controlSurfaceColor: COLORS.white, - controlTextActiveColor: COLORS.ui.theme, + controlTextActiveColor: COLORS.theme.accent, controlPaddingX: CONTROL_PADDING_X, controlPaddingXLarge: `calc(${ CONTROL_PADDING_X } * 1.3334)`, controlPaddingXSmall: `calc(${ CONTROL_PADDING_X } / 1.3334)`, controlBackgroundColor: COLORS.white, controlBorderRadius: '2px', controlBoxShadow: 'transparent', - controlBoxShadowFocus: `0 0 0 0.5px ${ COLORS.ui.theme }`, + controlBoxShadowFocus: `0 0 0 0.5px ${ COLORS.theme.accent }`, controlDestructiveBorderColor: COLORS.alert.red, controlHeight: CONTROL_HEIGHT, controlHeightXSmall: `calc( ${ CONTROL_HEIGHT } * 0.6 )`, @@ -67,6 +67,7 @@ export default Object.assign( {}, CONTROL_PROPS, TOGGLE_GROUP_CONTROL_PROPS, { cardPaddingSmall: `${ space( 4 ) }`, cardPaddingMedium: `${ space( 4 ) } ${ space( 6 ) }`, cardPaddingLarge: `${ space( 6 ) } ${ space( 8 ) }`, + popoverShadow: `0 0.7px 1px rgba(0, 0, 0, 0.1), 0 1.2px 1.7px -0.2px rgba(0, 0, 0, 0.1), 0 2.3px 3.3px -0.5px rgba(0, 0, 0, 0.1)`, surfaceBackgroundColor: COLORS.white, surfaceBackgroundSubtleColor: '#F3F3F3', surfaceBackgroundTintColor: '#F5F5F5', diff --git a/packages/components/src/utils/events.ts b/packages/components/src/utils/events.ts deleted file mode 100644 index a3153aa6cad58e..00000000000000 --- a/packages/components/src/utils/events.ts +++ /dev/null @@ -1,50 +0,0 @@ -type EventHandler< T extends Event > = ( event: T ) => void; - -/** - * Merges event handlers together. - * - * @template TEvent - * @param handler - * @param otherHandler - */ -function mergeEvent< TEvent extends Event >( - handler: EventHandler< TEvent >, - otherHandler: EventHandler< TEvent > -): EventHandler< TEvent > { - return ( event: TEvent ) => { - if ( typeof handler === 'function' ) { - handler( event ); - } - if ( typeof otherHandler === 'function' ) { - otherHandler( event ); - } - }; -} - -/** - * Merges two sets of event handlers together. - * - * @template TEvent - * @param handlers - * @param extraHandlers - */ -export function mergeEventHandlers< - TEvent extends Event, - TLeft extends Record< string, EventHandler< TEvent > >, - TRight extends Record< string, EventHandler< TEvent > > ->( handlers: TLeft, extraHandlers: TRight ): TLeft & TRight { - // @ts-ignore We'll fill in all the properties below - const mergedHandlers: TLeft & TRight = { - ...handlers, - }; - - for ( const [ key, handler ] of Object.entries( extraHandlers ) ) { - // @ts-ignore - mergedHandlers[ key as keyof typeof mergedHandlers ] = - key in mergedHandlers - ? mergeEvent( mergedHandlers[ key ], handler ) - : handler; - } - - return mergedHandlers; -} diff --git a/packages/components/src/utils/hooks/use-cx.ts b/packages/components/src/utils/hooks/use-cx.ts index 19bc0a797d1279..409352c26a6e1b 100644 --- a/packages/components/src/utils/hooks/use-cx.ts +++ b/packages/components/src/utils/hooks/use-cx.ts @@ -5,7 +5,9 @@ import { __unsafe_useEmotionCache as useEmotionCache } from '@emotion/react'; import type { SerializedStyles } from '@emotion/serialize'; import { insertStyles } from '@emotion/utils'; // eslint-disable-next-line no-restricted-imports -import { cx as innerCx, ClassNamesArg } from '@emotion/css'; +import type { ClassNamesArg } from '@emotion/css'; +// eslint-disable-next-line no-restricted-imports +import { cx as innerCx } from '@emotion/css'; /** * WordPress dependencies @@ -23,7 +25,7 @@ const isSerializedStyles = ( o: any ): o is SerializedStyles => * `cx` normally knows how to handle. It also hooks into the Emotion * Cache, allowing `css` calls to work inside iframes. * - * @example + * ```jsx * import { css } from '@emotion/react'; * * const styles = css` @@ -37,6 +39,7 @@ const isSerializedStyles = ( o: any ): o is SerializedStyles => * * return <span className={classes} {...props} />; * } + * ``` */ export const useCx = () => { const cache = useEmotionCache(); diff --git a/packages/components/src/utils/input/base.js b/packages/components/src/utils/input/base.js index 2c5010fc92737c..e657664493a965 100644 --- a/packages/components/src/utils/input/base.js +++ b/packages/components/src/utils/input/base.js @@ -19,10 +19,10 @@ export const inputStyleNeutral = css` `; export const inputStyleFocus = css` - border-color: ${ COLORS.ui.theme }; + border-color: ${ COLORS.theme.accent }; box-shadow: 0 0 0 calc( ${ CONFIG.borderWidthFocus } - ${ CONFIG.borderWidth } ) - ${ COLORS.ui.theme }; + ${ COLORS.theme.accent }; // Windows High Contrast mode will show this outline, but not the box-shadow. outline: 2px solid transparent; diff --git a/packages/components/src/utils/math.js b/packages/components/src/utils/math.js index 5bfb17e700ed39..ffaab4e3bd11ba 100644 --- a/packages/components/src/utils/math.js +++ b/packages/components/src/utils/math.js @@ -96,14 +96,3 @@ export function roundClamp( ? getNumber( clampedValue.toFixed( precision ) ) : clampedValue; } - -/** - * Clamps a value based on a min/max range with rounding. - * Returns a string. - * - * @param {Parameters<typeof roundClamp>} args Arguments for roundClamp(). - * @return {string} The rounded and clamped value. - */ -export function roundClampString( ...args ) { - return roundClamp( ...args ).toString(); -} diff --git a/packages/components/src/utils/test/events.js b/packages/components/src/utils/test/events.js deleted file mode 100644 index 75709614b9d072..00000000000000 --- a/packages/components/src/utils/test/events.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Internal dependencies - */ -import { mergeEventHandlers } from '../events'; - -describe( 'mergeEventHandlers', () => { - it( 'should merge the two event handler objects', () => { - const left = { - onclick: jest.fn(), - }; - - const right = { - onclick: jest.fn(), - }; - - const merged = mergeEventHandlers( left, right ); - - merged.onclick(); - - expect( left.onclick ).toHaveBeenCalled(); - expect( right.onclick ).toHaveBeenCalled(); - } ); - - it( 'should preserve all handlers from the left hand side that do not overlap with the right', () => { - const left = { - ArrowUp: jest.fn(), - ArrowDown: jest.fn(), - }; - - const right = { - ArrowUp: jest.fn(), - }; - - const merged = mergeEventHandlers( left, right ); - - merged.ArrowUp(); - - expect( left.ArrowUp ).toHaveBeenCalled(); - expect( right.ArrowUp ).toHaveBeenCalled(); - - expect( merged.ArrowDown ).toBe( left.ArrowDown ); - } ); - - it( 'should preserve all handlers form the right hand side that do not overlap with the left', () => { - const right = { - ArrowUp: jest.fn(), - ArrowDown: jest.fn(), - }; - - const left = { - ArrowUp: jest.fn(), - }; - - const merged = mergeEventHandlers( left, right ); - - merged.ArrowUp(); - - expect( left.ArrowUp ).toHaveBeenCalled(); - expect( right.ArrowUp ).toHaveBeenCalled(); - - expect( merged.ArrowDown ).toBe( right.ArrowDown ); - } ); -} ); diff --git a/packages/components/src/utils/test/values.js b/packages/components/src/utils/test/values.js deleted file mode 100644 index 384136949beb3c..00000000000000 --- a/packages/components/src/utils/test/values.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Internal dependencies - */ -import { isValueNumeric } from '../values'; - -/** - * This works because Node 12 ships with `small-icu` instead of `full-icu` - * meaning it only supports the `en-US` locale. If we test for support of the - * `pt-BR` locale, we can detect when we're running in a "full-icu" environment - * i.e., either Node 14+ or previous versions built with the `full-icu` option. - * - * Furthermore, this is safe to run in a `jsdom` test environment because, as the - * issue linked below describes, `jsdom` does not implement the Intl object, instead - * relying on the relevant Node implementation. - * - * @see https://nodejs.org/docs/latest-v12.x/api/intl.html#intl_options_for_building_node_js - * @see https://nodejs.org/docs/latest-v14.x/api/intl.html#intl_options_for_building_node_js - * @see https://github.com/jsdom/jsdom/issues/1626 - * - * @todo Remove when Node 12 is deprecated - * @param {() => void} testCallback - */ -function scopeTestToFullICU( testCallback ) { - if ( Intl.NumberFormat.supportedLocalesOf( 'pt-BR' ).length === 1 ) { - testCallback(); - } -} - -describe( 'isValueNumeric', () => { - it( 'should handle space separated numbers for locales with period decimal delimiters', () => { - expect( isValueNumeric( '1 000.1', 'en-US' ) ).toBe( true ); - } ); - - it.each( [ - '999', - '99.33', - 0.0003, - 2222, - '22,222,222', - -888, - new Number(), - ] )( - 'should return `true` for numeric values %s for locale with comma delimiter', - ( x ) => { - expect( isValueNumeric( x, 'en-US' ) ).toBe( true ); - } - ); - - it.each( [ null, , 'Stringy', {}, [] ] )( - 'should return `false` for non-numeric value %s for locale with comma delimiter', - ( x ) => { - expect( isValueNumeric( x, 'en-US' ) ).toBe( false ); - } - ); - - scopeTestToFullICU( () => { - it( 'should handle space separated numbers for locales with comma decimal delimiters', () => { - expect( isValueNumeric( '1 000,1', 'pt-BR' ) ).toBe( true ); - } ); - - it.each( [ - '999', - '99,33', - 0.0003, - 2222, - '22.222.222', - -888, - new Number(), - ] )( - 'should return `true` for numeric values %s for locale with period delimiter', - ( x ) => { - expect( isValueNumeric( x, 'pt-BR' ) ).toBe( true ); - } - ); - - it.each( [ null, , 'Stringy', {}, [] ] )( - 'should return `false` for non-numeric value %s for locale with period delimiter', - ( x ) => { - expect( isValueNumeric( x, 'pt-BR' ) ).toBe( false ); - } - ); - - it( 'should handle arabic locales with western arabic numerals', () => { - expect( isValueNumeric( '1.000,1', 'ar' ) ).toBe( true ); - expect( isValueNumeric( '1.000,1', 'fa' ) ).toBe( true ); - expect( isValueNumeric( '1.000,1', 'ur' ) ).toBe( true ); - expect( isValueNumeric( '1.000,1', 'ckb' ) ).toBe( true ); - expect( isValueNumeric( '1.000,1', 'ps' ) ).toBe( true ); - expect( isValueNumeric( '1.000,a', 'ar' ) ).toBe( false ); - } ); - - it( 'should handle arabic locales with eastern arabic numerals', () => { - expect( isValueNumeric( '١٬٠٠٠٫١', 'ar' ) ).toBe( true ); - expect( isValueNumeric( '۴۵۶٫۱', 'fa' ) ).toBe( true ); - expect( isValueNumeric( '١٬٠٠٠٫١', 'ur' ) ).toBe( true ); - expect( isValueNumeric( '١٬٠٠٠٫١', 'ckb' ) ).toBe( true ); - expect( isValueNumeric( '١٬٠٠٠٫١', 'ps' ) ).toBe( true ); - expect( isValueNumeric( '١٬٠٠٠٫a', 'ar' ) ).toBe( false ); - } ); - } ); -} ); diff --git a/packages/components/src/utils/values.js b/packages/components/src/utils/values.js index dacad621cc0e68..b620ebccbdb0ca 100644 --- a/packages/components/src/utils/values.js +++ b/packages/components/src/utils/values.js @@ -39,65 +39,6 @@ export function getDefinedValue( values = [], fallbackValue ) { return values.find( isValueDefined ) ?? fallbackValue; } -/** - * @param {string} [locale] - * @return {[RegExp, RegExp]} The delimiter and decimal regexp - */ -const getDelimiterAndDecimalRegex = ( locale ) => { - const formatted = Intl.NumberFormat( locale ).format( 1000.1 ); - const delimiter = formatted[ 1 ]; - const decimal = formatted[ formatted.length - 2 ]; - return [ - new RegExp( `\\${ delimiter }`, 'g' ), - new RegExp( `\\${ decimal }`, 'g' ), - ]; -}; - -// https://en.wikipedia.org/wiki/Decimal_separator#Current_standards -const INTERNATIONAL_THOUSANDS_DELIMITER = / /g; - -const ARABIC_NUMERAL_LOCALES = [ 'ar', 'fa', 'ur', 'ckb', 'ps' ]; - -const EASTERN_ARABIC_NUMBERS = /([۰-۹]|[٠-٩])/g; - -/** - * Checks to see if a value is a numeric value (`number` or `string`). - * - * Intentionally ignores whether the thousands delimiters are only - * in the thousands marks. - * - * @param {any} value - * @param {string} [locale] - * @return {boolean} Whether value is numeric. - */ -export function isValueNumeric( value, locale = window.navigator.language ) { - if ( ARABIC_NUMERAL_LOCALES.some( ( l ) => locale.startsWith( l ) ) ) { - locale = 'en-GB'; - if ( EASTERN_ARABIC_NUMBERS.test( value ) ) { - value = value - .replace( /[٠-٩]/g, ( /** @type {string} */ d ) => - '٠١٢٣٤٥٦٧٨٩'.indexOf( d ) - ) - .replace( /[۰-۹]/g, ( /** @type {string} */ d ) => - '۰۱۲۳۴۵۶۷۸۹'.indexOf( d ) - ) - .replace( /٬/g, ',' ) - .replace( /٫/g, '.' ); - } - } - - const [ delimiterRegexp, decimalRegexp ] = - getDelimiterAndDecimalRegex( locale ); - const valueToCheck = - typeof value === 'string' - ? value - .replace( delimiterRegexp, '' ) - .replace( decimalRegexp, '.' ) - .replace( INTERNATIONAL_THOUSANDS_DELIMITER, '' ) - : value; - return ! isNaN( parseFloat( valueToCheck ) ) && isFinite( valueToCheck ); -} - /** * Converts a string to a number. * @@ -108,16 +49,6 @@ export const stringToNumber = ( value ) => { return parseFloat( value ); }; -/** - * Converts a number to a string. - * - * @param {number} value - * @return {string} Number as a string. - */ -export const numberToString = ( value ) => { - return `${ value }`; -}; - /** * Regardless of the input being a string or a number, returns a number. * @@ -129,15 +60,3 @@ export const numberToString = ( value ) => { export const ensureNumber = ( value ) => { return typeof value === 'string' ? stringToNumber( value ) : value; }; - -/** - * Regardless of the input being a string or a number, returns a number. - * - * Returns `undefined` in case the string is `undefined` or not a valid numeric value. - * - * @param {string | number} value - * @return {string} The converted string, or `undefined` in case the input is `undefined` or `NaN`. - */ -export const ensureString = ( value ) => { - return typeof value === 'string' ? value : numberToString( value ); -}; diff --git a/packages/components/src/v-stack/component.tsx b/packages/components/src/v-stack/component.tsx index aa24673db1677f..5c51043eba7261 100644 --- a/packages/components/src/v-stack/component.tsx +++ b/packages/components/src/v-stack/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { contextConnect, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { contextConnect } from '../ui/context'; import { View } from '../view'; import { useVStack } from './hook'; import type { VStackProps } from './types'; diff --git a/packages/components/src/v-stack/hook.ts b/packages/components/src/v-stack/hook.ts index 0196eef36130c5..f9c592e8f02851 100644 --- a/packages/components/src/v-stack/hook.ts +++ b/packages/components/src/v-stack/hook.ts @@ -1,7 +1,8 @@ /** * Internal dependencies */ -import { useContextSystem, WordPressComponentProps } from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem } from '../ui/context'; import { useHStack } from '../h-stack'; import type { VStackProps } from './types'; diff --git a/packages/components/src/v-stack/stories/e2e/index.story.tsx b/packages/components/src/v-stack/stories/e2e/index.story.tsx new file mode 100644 index 00000000000000..c83402e66a7b46 --- /dev/null +++ b/packages/components/src/v-stack/stories/e2e/index.story.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import type { StoryFn, Meta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '../../../view'; +import { VStack } from '../..'; + +const meta: Meta< typeof VStack > = { + component: VStack, + title: 'Components (Experimental)/VStack', +}; +export default meta; + +const Template: StoryFn< typeof VStack > = ( props ) => { + return ( + <VStack + { ...props } + style={ { background: '#eee', minHeight: '3rem' } } + > + { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( + <View key={ text } style={ { background: '#b9f9ff' } }> + { text } + </View> + ) ) } + </VStack> + ); +}; + +export const Default: StoryFn< typeof VStack > = Template.bind( {} ); +Default.args = { + spacing: 3, +}; diff --git a/packages/components/src/v-stack/stories/e2e/index.tsx b/packages/components/src/v-stack/stories/e2e/index.tsx deleted file mode 100644 index 54456551757925..00000000000000 --- a/packages/components/src/v-stack/stories/e2e/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentStory, ComponentMeta } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { View } from '../../../view'; -import { VStack } from '../..'; - -const meta: ComponentMeta< typeof VStack > = { - component: VStack, - title: 'Components (Experimental)/VStack', -}; -export default meta; - -const Template: ComponentStory< typeof VStack > = ( props ) => { - return ( - <VStack - { ...props } - style={ { background: '#eee', minHeight: '3rem' } } - > - { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( - <View key={ text } style={ { background: '#b9f9ff' } }> - { text } - </View> - ) ) } - </VStack> - ); -}; - -export const Default: ComponentStory< typeof VStack > = Template.bind( {} ); -Default.args = { - spacing: 3, -}; diff --git a/packages/components/src/v-stack/stories/index.story.tsx b/packages/components/src/v-stack/stories/index.story.tsx new file mode 100644 index 00000000000000..781c1bce6676cc --- /dev/null +++ b/packages/components/src/v-stack/stories/index.story.tsx @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '../../view'; +import { VStack } from '..'; + +const ALIGNMENTS = { + top: 'top', + topLeft: 'topLeft', + topRight: 'topRight', + left: 'left', + center: 'center', + right: 'right', + bottom: 'bottom', + bottomLeft: 'bottomLeft', + bottomRight: 'bottomRight', + edge: 'edge', + stretch: 'stretch', +}; + +const meta: Meta< typeof VStack > = { + component: VStack, + title: 'Components (Experimental)/VStack', + argTypes: { + alignment: { + control: { type: 'select' }, + options: Object.keys( ALIGNMENTS ), + mapping: ALIGNMENTS, + }, + as: { control: { type: 'text' } }, + direction: { control: { type: 'text' } }, + justify: { control: { type: 'text' } }, + spacing: { control: { type: 'text' } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof VStack > = ( props ) => { + return ( + <VStack + { ...props } + style={ { background: '#eee', minHeight: '200px' } } + > + { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( + <View key={ text } style={ { background: '#b9f9ff' } }> + { text } + </View> + ) ) } + </VStack> + ); +}; + +export const Default = Template.bind( {} ); diff --git a/packages/components/src/v-stack/stories/index.tsx b/packages/components/src/v-stack/stories/index.tsx deleted file mode 100644 index b0b0ece2d0f9bc..00000000000000 --- a/packages/components/src/v-stack/stories/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { View } from '../../view'; -import { VStack } from '..'; - -const ALIGNMENTS = { - top: 'top', - topLeft: 'topLeft', - topRight: 'topRight', - left: 'left', - center: 'center', - right: 'right', - bottom: 'bottom', - bottomLeft: 'bottomLeft', - bottomRight: 'bottomRight', - edge: 'edge', - stretch: 'stretch', -}; - -const meta: ComponentMeta< typeof VStack > = { - component: VStack, - title: 'Components (Experimental)/VStack', - argTypes: { - alignment: { - control: { type: 'select' }, - options: Object.keys( ALIGNMENTS ), - mapping: ALIGNMENTS, - }, - as: { control: { type: 'text' } }, - direction: { control: { type: 'text' } }, - justify: { control: { type: 'text' } }, - spacing: { control: { type: 'text' } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof VStack > = ( props ) => { - return ( - <VStack - { ...props } - style={ { background: '#eee', minHeight: '200px' } } - > - { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( - <View key={ text } style={ { background: '#b9f9ff' } }> - { text } - </View> - ) ) } - </VStack> - ); -}; - -export const Default = Template.bind( {} ); diff --git a/packages/components/src/view/README.md b/packages/components/src/view/README.md index 794d7b49a0d386..c1e04ebd8cfff4 100644 --- a/packages/components/src/view/README.md +++ b/packages/components/src/view/README.md @@ -27,13 +27,13 @@ function Example() { ## Props -##### as +### as **Type**: `string`,`E` Render the component as another React Component or HTML Element. -##### css +### css **Type**: `InterpolatedCSS` diff --git a/packages/components/src/view/component.tsx b/packages/components/src/view/component.tsx index e8b4f440e528b1..da7efc69e38590 100644 --- a/packages/components/src/view/component.tsx +++ b/packages/components/src/view/component.tsx @@ -14,7 +14,6 @@ import type { ViewProps } from './types'; * `View` is a core component that renders everything in the library. * It is the principle component in the entire library. * - * @example * ```jsx * import { View } from `@wordpress/components`; * diff --git a/packages/components/src/view/stories/index.story.tsx b/packages/components/src/view/stories/index.story.tsx new file mode 100644 index 00000000000000..324825059deb20 --- /dev/null +++ b/packages/components/src/view/stories/index.story.tsx @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '..'; + +const meta: Meta< typeof View > = { + component: View, + title: 'Components (Experimental)/View', + argTypes: { + as: { control: { type: null } }, + children: { control: { type: 'text' } }, + }, + parameters: { + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof View > = ( args ) => { + return <View { ...args } />; +}; + +export const Default: StoryFn< typeof View > = Template.bind( {} ); +Default.args = { + children: 'An example tip', +}; diff --git a/packages/components/src/view/stories/index.tsx b/packages/components/src/view/stories/index.tsx deleted file mode 100644 index fb27e29433470a..00000000000000 --- a/packages/components/src/view/stories/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { View } from '..'; - -const meta: ComponentMeta< typeof View > = { - component: View, - title: 'Components (Experimental)/View', - argTypes: { - as: { control: { type: null } }, - children: { control: { type: 'text' } }, - }, - parameters: { - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof View > = ( args ) => { - return <View { ...args } />; -}; - -export const Default: ComponentStory< typeof View > = Template.bind( {} ); -Default.args = { - children: 'An example tip', -}; diff --git a/packages/components/src/visually-hidden/component.tsx b/packages/components/src/visually-hidden/component.tsx index ffaa7f564019b2..c5cb9c0fd00677 100644 --- a/packages/components/src/visually-hidden/component.tsx +++ b/packages/components/src/visually-hidden/component.tsx @@ -6,11 +6,8 @@ import type { ForwardedRef } from 'react'; /** * Internal dependencies */ -import { - useContextSystem, - contextConnect, - WordPressComponentProps, -} from '../ui/context'; +import type { WordPressComponentProps } from '../ui/context'; +import { useContextSystem, contextConnect } from '../ui/context'; import { visuallyHidden } from './styles'; import { View } from '../view'; import type { VisuallyHiddenProps } from './types'; diff --git a/packages/components/src/visually-hidden/stories/index.story.tsx b/packages/components/src/visually-hidden/stories/index.story.tsx new file mode 100644 index 00000000000000..149b2da6b5c891 --- /dev/null +++ b/packages/components/src/visually-hidden/stories/index.story.tsx @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { VisuallyHidden } from '..'; + +const meta: Meta< typeof VisuallyHidden > = { + component: VisuallyHidden, + title: 'Components/VisuallyHidden', + argTypes: { + children: { control: { type: null } }, + as: { control: { type: 'text' } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +export const Default: StoryFn< typeof VisuallyHidden > = ( args ) => ( + <> + <VisuallyHidden as="span" { ...args }> + This should not show. + </VisuallyHidden> + <div> + This text will{ ' ' } + <VisuallyHidden as="span" { ...args }> + but not inline{ ' ' } + </VisuallyHidden>{ ' ' } + always show. + </div> + </> +); + +export const WithForwardedProps: StoryFn< typeof VisuallyHidden > = ( + args +) => ( + <> + Additional props can be passed to VisuallyHidden and are forwarded to + the rendered element.{ ' ' } + <VisuallyHidden as="span" data-id="test" { ...args }> + Check out my data attribute!{ ' ' } + </VisuallyHidden> + Inspect the HTML to see! + </> +); + +export const WithAdditionalClassNames: StoryFn< typeof VisuallyHidden > = ( + args +) => ( + <> + Additional class names passed to VisuallyHidden extend the component + class name.{ ' ' } + <VisuallyHidden as="label" className="test-input" { ...args }> + Check out my class!{ ' ' } + </VisuallyHidden> + Inspect the HTML to see! + </> +); diff --git a/packages/components/src/visually-hidden/stories/index.tsx b/packages/components/src/visually-hidden/stories/index.tsx deleted file mode 100644 index 7ed337b5bac9f3..00000000000000 --- a/packages/components/src/visually-hidden/stories/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { VisuallyHidden } from '..'; - -const meta: ComponentMeta< typeof VisuallyHidden > = { - component: VisuallyHidden, - title: 'Components/VisuallyHidden', - argTypes: { - children: { control: { type: null } }, - as: { control: { type: 'text' } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -export const Default: ComponentStory< typeof VisuallyHidden > = ( args ) => ( - <> - <VisuallyHidden as="span" { ...args }> - This should not show. - </VisuallyHidden> - <div> - This text will{ ' ' } - <VisuallyHidden as="span" { ...args }> - but not inline{ ' ' } - </VisuallyHidden>{ ' ' } - always show. - </div> - </> -); - -export const WithForwardedProps: ComponentStory< typeof VisuallyHidden > = ( - args -) => ( - <> - Additional props can be passed to VisuallyHidden and are forwarded to - the rendered element.{ ' ' } - <VisuallyHidden as="span" data-id="test" { ...args }> - Check out my data attribute!{ ' ' } - </VisuallyHidden> - Inspect the HTML to see! - </> -); - -export const WithAdditionalClassNames: ComponentStory< - typeof VisuallyHidden -> = ( args ) => ( - <> - Additional class names passed to VisuallyHidden extend the component - class name.{ ' ' } - <VisuallyHidden as="label" className="test-input" { ...args }> - Check out my class!{ ' ' } - </VisuallyHidden> - Inspect the HTML to see! - </> -); diff --git a/packages/components/src/z-stack/component.tsx b/packages/components/src/z-stack/component.tsx index 1cbcac56e4fb70..e087f8536e3401 100644 --- a/packages/components/src/z-stack/component.tsx +++ b/packages/components/src/z-stack/component.tsx @@ -35,13 +35,14 @@ function UnconnectedZStack( const clonedChildren = validChildren.map( ( child, index ) => { const zIndex = isReversed ? childrenLastIndex - index : index; - const offsetAmount = offset * index; + // Only when the component is layered, the offset needs to be multiplied by + // the item's index, so that items can correctly stack at the right distance + const offsetAmount = isLayered ? offset * index : offset; const key = isValidElement( child ) ? child.key : index; return ( <ZStackChildView - isLayered={ isLayered } offsetAmount={ offsetAmount } zIndex={ zIndex } key={ key } @@ -55,6 +56,7 @@ function UnconnectedZStack( <ZStackView { ...otherProps } className={ className } + isLayered={ isLayered } ref={ forwardedRef } > { clonedChildren } diff --git a/packages/components/src/z-stack/stories/index.story.tsx b/packages/components/src/z-stack/stories/index.story.tsx new file mode 100644 index 00000000000000..46a364bc520f32 --- /dev/null +++ b/packages/components/src/z-stack/stories/index.story.tsx @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import type { CSSProperties } from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { Elevation } from '../../elevation'; +import { View } from '../../view'; +import { ZStack } from '..'; + +const meta: Meta< typeof ZStack > = { + component: ZStack, + title: 'Components (Experimental)/ZStack', + argTypes: { + as: { control: { type: 'text' } }, + children: { control: { type: null } }, + }, + parameters: { + controls: { + expanded: true, + }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Avatar = ( { + backgroundColor, +}: { + backgroundColor: CSSProperties[ 'backgroundColor' ]; +} ) => { + return ( + <View> + <View + style={ { + border: '3px solid black', + borderRadius: '9999px', + height: '48px', + width: '48px', + backgroundColor, + } } + /> + <Elevation + borderRadius={ 9999 } + isInteractive={ false } + value={ 3 } + /> + </View> + ); +}; + +const Template: StoryFn< typeof ZStack > = ( args ) => { + return ( + <ZStack { ...args }> + <Avatar backgroundColor="#444" /> + <Avatar backgroundColor="#777" /> + <Avatar backgroundColor="#aaa" /> + <Avatar backgroundColor="#fff" /> + </ZStack> + ); +}; + +export const Default: StoryFn< typeof ZStack > = Template.bind( {} ); +Default.args = { + offset: 20, +}; diff --git a/packages/components/src/z-stack/stories/index.tsx b/packages/components/src/z-stack/stories/index.tsx deleted file mode 100644 index b04e19962aed15..00000000000000 --- a/packages/components/src/z-stack/stories/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** - * External dependencies - */ -import type { CSSProperties } from 'react'; -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * Internal dependencies - */ -import { Elevation } from '../../elevation'; -import { HStack } from '../../h-stack'; -import { View } from '../../view'; -import { ZStack } from '..'; - -const meta: ComponentMeta< typeof ZStack > = { - component: ZStack, - title: 'Components (Experimental)/ZStack', - argTypes: { - as: { control: { type: 'text' } }, - children: { control: { type: null } }, - }, - parameters: { - controls: { - expanded: true, - }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Avatar = ( { - backgroundColor, -}: { - backgroundColor: CSSProperties[ 'backgroundColor' ]; -} ) => { - return ( - <View> - <View - style={ { - border: '3px solid black', - borderRadius: '9999px', - height: '48px', - width: '48px', - backgroundColor, - } } - /> - <Elevation - borderRadius={ 9999 } - isInteractive={ false } - value={ 3 } - /> - </View> - ); -}; - -const Template: ComponentStory< typeof ZStack > = ( args ) => { - return ( - <View> - <HStack> - <View> - <ZStack { ...args }> - <Avatar backgroundColor="#444" /> - <Avatar backgroundColor="#777" /> - <Avatar backgroundColor="#aaa" /> - <Avatar backgroundColor="#fff" /> - </ZStack> - </View> - </HStack> - </View> - ); -}; - -export const Default: ComponentStory< typeof ZStack > = Template.bind( {} ); -Default.args = { - offset: 20, -}; diff --git a/packages/components/src/z-stack/styles.ts b/packages/components/src/z-stack/styles.ts index d0bf20d38b1f4c..186f268e643663 100644 --- a/packages/components/src/z-stack/styles.ts +++ b/packages/components/src/z-stack/styles.ts @@ -4,36 +4,35 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; -/** - * Internal dependencies - */ -import { rtl } from '../utils'; - -export const ZStackView = styled.div` - display: flex; - position: relative; -`; - export const ZStackChildView = styled.div< { - isLayered: boolean; offsetAmount: number; zIndex: number; } >` - ${ ( { isLayered, offsetAmount } ) => - isLayered - ? css( rtl( { marginLeft: offsetAmount } )() ) - : css( rtl( { right: offsetAmount * -1 } )() ) } + &:not( :first-of-type ) { + ${ ( { offsetAmount } ) => + css( { + marginInlineStart: offsetAmount, + } ) }; + } - ${ ( { isLayered } ) => - isLayered ? positionAbsolute : positionRelative } - - ${ ( { zIndex } ) => css( { zIndex } ) } -`; - -const positionAbsolute = css` - position: absolute; + ${ ( { zIndex } ) => css( { zIndex } ) }; `; -const positionRelative = css` +export const ZStackView = styled.div< { + isLayered: boolean; +} >` + display: inline-grid; + grid-auto-flow: column; position: relative; + + & > ${ ZStackChildView } { + position: relative; + justify-self: start; + + ${ ( { isLayered } ) => + isLayered + ? // When `isLayered` is true, all items overlap in the same grid cell + css( { gridRowStart: 1, gridColumnStart: 1 } ) + : undefined }; + } `; diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index be473ffcc877d2..1ee9a35f1a8b22 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -8,9 +8,7 @@ "gutenberg-test-env", "dom-scroll-into-view", "jest", - "@testing-library/jest-dom", - "snapshot-diff", - "@wordpress/jest-console" + "@testing-library/jest-dom" ], // TODO: Remove `skipLibCheck` after resolving duplicate declaration of the `process` variable // between `@types/webpack-env` (from @storybook packages) and `gutenberg-env`. diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md index 84de1012fcbfe1..c8db63936b6561 100644 --- a/packages/compose/CHANGELOG.md +++ b/packages/compose/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 6.17.0 (2023-08-16) + +## 6.16.0 (2023-08-10) + +## 6.15.0 (2023-07-20) + +## 6.14.0 (2023-07-05) + +## 6.13.0 (2023-06-23) + +## 6.12.0 (2023-06-07) + ## 6.11.0 (2023-05-24) ## 6.10.0 (2023-05-10) diff --git a/packages/compose/README.md b/packages/compose/README.md index 0a3fed45bce8f1..62ebdef6d798ed 100644 --- a/packages/compose/README.md +++ b/packages/compose/README.md @@ -323,7 +323,7 @@ _Returns_ ### useFocusReturn -When opening modals/sidebars/dialogs, the focus must move to the opened area and return to the previously focused element when closed. The current hook implements the returning behavior. +Adds the unmount behavior of returning focus to the element which had it previously as is expected for roles like menus or dialogs. _Usage_ diff --git a/packages/compose/package.json b/packages/compose/package.json index 59da686aa402e0..0ae05b14739397 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/compose", - "version": "6.11.0", + "version": "6.17.0", "description": "WordPress higher-order components (HOCs).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/compose/src/higher-order/with-instance-id/index.tsx b/packages/compose/src/higher-order/with-instance-id/index.tsx index d4802f66b56d0c..f2e78e7a80cfc4 100644 --- a/packages/compose/src/higher-order/with-instance-id/index.tsx +++ b/packages/compose/src/higher-order/with-instance-id/index.tsx @@ -1,11 +1,11 @@ /** * Internal dependencies */ -import { - createHigherOrderComponent, +import type { WithInjectedProps, WithoutInjectedProps, } from '../../utils/create-higher-order-component'; +import { createHigherOrderComponent } from '../../utils/create-higher-order-component'; import useInstanceId from '../../hooks/use-instance-id'; type InstanceIdProps = { instanceId: string | number }; diff --git a/packages/compose/src/higher-order/with-safe-timeout/index.tsx b/packages/compose/src/higher-order/with-safe-timeout/index.tsx index d15b8a72d41eab..73e8faec6c1011 100644 --- a/packages/compose/src/higher-order/with-safe-timeout/index.tsx +++ b/packages/compose/src/higher-order/with-safe-timeout/index.tsx @@ -6,11 +6,11 @@ import { Component } from '@wordpress/element'; /** * Internal dependencies */ -import { - createHigherOrderComponent, +import type { WithInjectedProps, WithoutInjectedProps, } from '../../utils/create-higher-order-component'; +import { createHigherOrderComponent } from '../../utils/create-higher-order-component'; /** * We cannot use the `Window['setTimeout']` and `Window['clearTimeout']` diff --git a/packages/compose/src/hooks/use-fixed-window-list/index.js b/packages/compose/src/hooks/use-fixed-window-list/index.js index 598a131e33fe16..3ea72c539e2cd3 100644 --- a/packages/compose/src/hooks/use-fixed-window-list/index.js +++ b/packages/compose/src/hooks/use-fixed-window-list/index.js @@ -27,6 +27,7 @@ const DEFAULT_INIT_WINDOW_SIZE = 30; * @property {number} [windowOverscan] Renders windowOverscan number of items before and after the calculated visible window. * @property {boolean} [useWindowing] When false avoids calculating the window size * @property {number} [initWindowSize] Initial window size to use on first render before we can calculate the window size. + * @property {any} [expandedState] Used to recalculate the window size when the expanded state of a list changes. */ /** @@ -125,7 +126,14 @@ export default function useFixedWindowList( debounceMeasureList ); }; - }, [ itemHeight, elementRef, totalItems ] ); + }, [ + itemHeight, + elementRef, + totalItems, + options?.expandedState, + options?.windowOverscan, + useWindowing, + ] ); useLayoutEffect( () => { if ( ! useWindowing ) { @@ -168,7 +176,14 @@ export default function useFixedWindowList( handleKeyDown ); }; - }, [ totalItems, itemHeight, elementRef, fixedListWindow.visibleItems ] ); + }, [ + totalItems, + itemHeight, + elementRef, + fixedListWindow.visibleItems, + useWindowing, + options?.expandedState, + ] ); return [ fixedListWindow, setFixedListWindow ]; } diff --git a/packages/compose/src/hooks/use-focus-return/index.js b/packages/compose/src/hooks/use-focus-return/index.js index 66751b7028d329..2cd93b279cd318 100644 --- a/packages/compose/src/hooks/use-focus-return/index.js +++ b/packages/compose/src/hooks/use-focus-return/index.js @@ -3,11 +3,12 @@ */ import { useRef, useEffect, useCallback } from '@wordpress/element'; +/** @type {Element|null} */ +let origin = null; + /** - * When opening modals/sidebars/dialogs, the focus - * must move to the opened area and return to the - * previously focused element when closed. - * The current hook implements the returning behavior. + * Adds the unmount behavior of returning focus to the element which had it + * previously as is expected for roles like menus or dialogs. * * @param {() => void} [onFocusReturn] Overrides the default return behavior. * @return {import('react').RefCallback<HTMLElement>} Element Ref. @@ -54,6 +55,7 @@ function useFocusReturn( onFocusReturn ) { ); if ( ref.current?.isConnected && ! isFocused ) { + origin ??= focusedBeforeMount.current; return; } @@ -64,10 +66,13 @@ function useFocusReturn( onFocusReturn ) { if ( onFocusReturnRef.current ) { onFocusReturnRef.current(); } else { - /** @type {null | HTMLElement} */ ( - focusedBeforeMount.current + /** @type {null|HTMLElement} */ ( + ! focusedBeforeMount.current.isConnected + ? origin + : focusedBeforeMount.current )?.focus(); } + origin = null; } }, [] ); } diff --git a/packages/compose/src/hooks/use-resize-observer/index.native.js b/packages/compose/src/hooks/use-resize-observer/index.native.js index 40ef036a3c6602..3b171ee00bf47c 100644 --- a/packages/compose/src/hooks/use-resize-observer/index.native.js +++ b/packages/compose/src/hooks/use-resize-observer/index.native.js @@ -24,7 +24,6 @@ import { useState, useCallback } from '@wordpress/element'; * ); * }; * ``` - * */ const useResizeObserver = () => { const [ measurements, setMeasurements ] = useState( null ); diff --git a/packages/core-commands/CHANGELOG.md b/packages/core-commands/CHANGELOG.md index 4050df40063c82..9a5cabcae8392c 100644 --- a/packages/core-commands/CHANGELOG.md +++ b/packages/core-commands/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.9.0 (2023-08-16) + +## 0.8.0 (2023-08-10) + +## 0.7.0 (2023-07-20) + +## 0.6.0 (2023-07-05) + +## 0.5.0 (2023-06-23) + +## 0.4.0 (2023-06-07) + ## 0.3.0 (2023-05-24) ## 0.2.0 (2023-05-10) diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json index c8559bd2bd2950..a802e4d384c500 100644 --- a/packages/core-commands/package.json +++ b/packages/core-commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-commands", - "version": "0.3.0", + "version": "0.9.0", "description": "WordPress core reusable commands.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -27,6 +27,7 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.16.0", + "@wordpress/block-editor": "file:../block-editor", "@wordpress/commands": "file:../commands", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", @@ -38,7 +39,8 @@ "@wordpress/url": "file:../url" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/core-commands/src/add-post-type-commands.js b/packages/core-commands/src/add-post-type-commands.js deleted file mode 100644 index 47e6014f569444..00000000000000 --- a/packages/core-commands/src/add-post-type-commands.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * WordPress dependencies - */ -import { useCommand } from '@wordpress/commands'; -import { __ } from '@wordpress/i18n'; -import { plus } from '@wordpress/icons'; - -export function useAddPostTypeCommands() { - useCommand( { - name: 'core/add-new-post', - label: __( 'Add new post' ), - icon: plus, - callback: () => { - document.location.href = 'post-new.php'; - }, - } ); - useCommand( { - name: 'core/add-new-page', - label: __( 'Add new page' ), - icon: plus, - callback: () => { - document.location.href = 'post-new.php?post_type=page'; - }, - } ); -} diff --git a/packages/core-commands/src/admin-navigation-commands.js b/packages/core-commands/src/admin-navigation-commands.js new file mode 100644 index 00000000000000..891361a6b33cca --- /dev/null +++ b/packages/core-commands/src/admin-navigation-commands.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { useCommand } from '@wordpress/commands'; +import { __ } from '@wordpress/i18n'; +import { external, plus, symbol } from '@wordpress/icons'; +import { addQueryArgs, getPath } from '@wordpress/url'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import { useIsTemplatesAccessible, useIsBlockBasedTheme } from './hooks'; +import { unlock } from './lock-unlock'; + +const { useHistory } = unlock( routerPrivateApis ); + +export function useAdminNavigationCommands() { + const history = useHistory(); + const isTemplatesAccessible = useIsTemplatesAccessible(); + const isBlockBasedTheme = useIsBlockBasedTheme(); + + const isSiteEditor = getPath( window.location.href )?.includes( + 'site-editor.php' + ); + + useCommand( { + name: 'core/add-new-post', + label: __( 'Add new post' ), + icon: plus, + callback: () => { + document.location.href = 'post-new.php'; + }, + } ); + useCommand( { + name: 'core/add-new-page', + label: __( 'Add new page' ), + icon: plus, + callback: () => { + document.location.href = 'post-new.php?post_type=page'; + }, + } ); + useCommand( { + name: 'core/manage-reusable-blocks', + label: __( 'Open patterns' ), + callback: ( { close } ) => { + if ( isTemplatesAccessible && isBlockBasedTheme ) { + const args = { + path: '/patterns', + }; + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = addQueryArgs( 'site-editor.php', args ); + } + close(); + } else { + document.location.href = 'edit.php?post_type=wp_block'; + } + }, + icon: isSiteEditor ? symbol : external, + } ); +} diff --git a/packages/core-commands/src/hooks.js b/packages/core-commands/src/hooks.js new file mode 100644 index 00000000000000..6d744e3223234d --- /dev/null +++ b/packages/core-commands/src/hooks.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +export function useIsTemplatesAccessible() { + return useSelect( + ( select ) => select( coreStore ).canUser( 'read', 'templates' ), + [] + ); +} + +export function useIsBlockBasedTheme() { + return useSelect( + ( select ) => select( coreStore ).getCurrentTheme()?.is_block_theme, + [] + ); +} diff --git a/packages/core-commands/src/private-apis.js b/packages/core-commands/src/private-apis.js index b0e0cd87040f6a..de5b0de197600f 100644 --- a/packages/core-commands/src/private-apis.js +++ b/packages/core-commands/src/private-apis.js @@ -1,12 +1,12 @@ /** * Internal dependencies */ -import { useAddPostTypeCommands } from './add-post-type-commands'; +import { useAdminNavigationCommands } from './admin-navigation-commands'; import { useSiteEditorNavigationCommands } from './site-editor-navigation-commands'; import { lock } from './lock-unlock'; function useCommands() { - useAddPostTypeCommands(); + useAdminNavigationCommands(); useSiteEditorNavigationCommands(); } diff --git a/packages/core-commands/src/site-editor-navigation-commands.js b/packages/core-commands/src/site-editor-navigation-commands.js index 31f65bb98579e2..9571d728c8ecab 100644 --- a/packages/core-commands/src/site-editor-navigation-commands.js +++ b/packages/core-commands/src/site-editor-navigation-commands.js @@ -6,14 +6,24 @@ import { __ } from '@wordpress/i18n'; import { useMemo } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { post, page, layout, symbolFilled } from '@wordpress/icons'; +import { + post, + page, + layout, + symbol, + symbolFilled, + styles, + navigation, +} from '@wordpress/icons'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { getQueryArg, addQueryArgs, getPath } from '@wordpress/url'; /** * Internal dependencies */ +import { useIsTemplatesAccessible, useIsBlockBasedTheme } from './hooks'; import { unlock } from './lock-unlock'; +import { orderEntityRecordsBySearch } from './utils/order-entity-records-by-search'; const { useHistory } = unlock( routerPrivateApis ); @@ -27,53 +37,153 @@ const icons = { const getNavigationCommandLoaderPerPostType = ( postType ) => function useNavigationCommandLoader( { search } ) { const history = useHistory(); - const supportsSearch = ! [ 'wp_template', 'wp_template_part' ].includes( - postType - ); + const isBlockBasedTheme = useIsBlockBasedTheme(); const { records, isLoading } = useSelect( ( select ) => { const { getEntityRecords } = select( coreStore ); - const query = supportsSearch - ? { - search: !! search ? search : undefined, - per_page: 10, - orderby: search ? 'relevance' : 'date', - } - : { - per_page: -1, - }; + const query = { + search: !! search ? search : undefined, + per_page: 10, + orderby: search ? 'relevance' : 'date', + status: [ + 'publish', + 'future', + 'draft', + 'pending', + 'private', + ], + }; return { records: getEntityRecords( 'postType', postType, query ), isLoading: ! select( coreStore ).hasFinishedResolution( 'getEntityRecords', [ 'postType', postType, query ] ), - // We're using the string literal to check whether we're in the site editor. - /* eslint-disable-next-line @wordpress/data-no-store-string-literals */ - isSiteEditor: !! select( 'edit-site' ), }; }, - [ supportsSearch, search ] + [ search ] ); const commands = useMemo( () => { - return ( records ?? [] ).slice( 0, 10 ).map( ( record ) => { + return ( records ?? [] ).map( ( record ) => { + const command = { + name: postType + '-' + record.id, + searchLabel: record.title?.rendered + ' ' + record.id, + label: record.title?.rendered + ? record.title?.rendered + : __( '(no title)' ), + icon: icons[ postType ], + }; + + if ( + postType === 'post' || + ( postType === 'page' && ! isBlockBasedTheme ) + ) { + return { + ...command, + callback: ( { close } ) => { + const args = { + post: record.id, + action: 'edit', + }; + const targetUrl = addQueryArgs( 'post.php', args ); + document.location = targetUrl; + close(); + }, + }; + } + + const isSiteEditor = getPath( window.location.href )?.includes( + 'site-editor.php' + ); + const extraArgs = isSiteEditor + ? { + canvas: getQueryArg( + window.location.href, + 'canvas' + ), + } + : {}; + + return { + ...command, + callback: ( { close } ) => { + const args = { + postType, + postId: record.id, + ...extraArgs, + }; + const targetUrl = addQueryArgs( + 'site-editor.php', + args + ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } + close(); + }, + }; + } ); + }, [ records, isBlockBasedTheme, history ] ); + + return { + commands, + isLoading, + }; + }; + +const getNavigationCommandLoaderPerTemplate = ( templateType ) => + function useNavigationCommandLoader( { search } ) { + const history = useHistory(); + const isBlockBasedTheme = useIsBlockBasedTheme(); + const { records, isLoading } = useSelect( ( select ) => { + const { getEntityRecords } = select( coreStore ); + const query = { per_page: -1 }; + return { + records: getEntityRecords( 'postType', templateType, query ), + isLoading: ! select( coreStore ).hasFinishedResolution( + 'getEntityRecords', + [ 'postType', templateType, query ] + ), + }; + }, [] ); + + /* + * wp_template and wp_template_part endpoints do not support per_page or orderby parameters. + * We need to sort the results based on the search query to avoid removing relevant + * records below using .slice(). + */ + const orderedRecords = useMemo( () => { + return orderEntityRecordsBySearch( records, search ).slice( 0, 10 ); + }, [ records, search ] ); + + const commands = useMemo( () => { + if ( + ! isBlockBasedTheme && + ! templateType === 'wp_template_part' + ) { + return []; + } + return orderedRecords.map( ( record ) => { const isSiteEditor = getPath( window.location.href )?.includes( 'site-editor.php' ); const extraArgs = isSiteEditor ? { canvas: getQueryArg( window.location.href, 'canvas' ) } : {}; + return { - name: postType + '-' + record.id, + name: templateType + '-' + record.id, searchLabel: record.title?.rendered + ' ' + record.id, label: record.title?.rendered ? record.title?.rendered : __( '(no title)' ), - icon: icons[ postType ], + icon: icons[ templateType ], callback: ( { close } ) => { const args = { - postType, + postType: templateType, postId: record.id, ...extraArgs, }; @@ -90,7 +200,7 @@ const getNavigationCommandLoaderPerPostType = ( postType ) => }, }; } ); - }, [ records, history ] ); + }, [ isBlockBasedTheme, orderedRecords, history ] ); return { commands, @@ -103,9 +213,122 @@ const usePageNavigationCommandLoader = const usePostNavigationCommandLoader = getNavigationCommandLoaderPerPostType( 'post' ); const useTemplateNavigationCommandLoader = - getNavigationCommandLoaderPerPostType( 'wp_template' ); + getNavigationCommandLoaderPerTemplate( 'wp_template' ); const useTemplatePartNavigationCommandLoader = - getNavigationCommandLoaderPerPostType( 'wp_template_part' ); + getNavigationCommandLoaderPerTemplate( 'wp_template_part' ); + +function useSiteEditorBasicNavigationCommands() { + const history = useHistory(); + const isSiteEditor = getPath( window.location.href )?.includes( + 'site-editor.php' + ); + const isTemplatesAccessible = useIsTemplatesAccessible(); + const isBlockBasedTheme = useIsBlockBasedTheme(); + const commands = useMemo( () => { + const result = []; + + if ( ! isTemplatesAccessible || ! isBlockBasedTheme ) { + return result; + } + + result.push( { + name: 'core/edit-site/open-navigation', + label: __( 'Navigation' ), + icon: navigation, + callback: ( { close } ) => { + const args = { + path: '/navigation', + }; + const targetUrl = addQueryArgs( 'site-editor.php', args ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } + close(); + }, + } ); + + result.push( { + name: 'core/edit-site/open-styles', + label: __( 'Styles' ), + icon: styles, + callback: ( { close } ) => { + const args = { + path: '/wp_global_styles', + }; + const targetUrl = addQueryArgs( 'site-editor.php', args ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } + close(); + }, + } ); + + result.push( { + name: 'core/edit-site/open-pages', + label: __( 'Pages' ), + icon: page, + callback: ( { close } ) => { + const args = { + path: '/page', + }; + const targetUrl = addQueryArgs( 'site-editor.php', args ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } + close(); + }, + } ); + + result.push( { + name: 'core/edit-site/open-templates', + label: __( 'Templates' ), + icon: layout, + callback: ( { close } ) => { + const args = { + path: '/wp_template', + }; + const targetUrl = addQueryArgs( 'site-editor.php', args ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } + close(); + }, + } ); + + result.push( { + name: 'core/edit-site/open-patterns', + label: __( 'Patterns' ), + icon: symbol, + callback: ( { close } ) => { + const args = { + path: '/patterns', + }; + const targetUrl = addQueryArgs( 'site-editor.php', args ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } + close(); + }, + } ); + + return result; + }, [ history, isSiteEditor, isTemplatesAccessible, isBlockBasedTheme ] ); + + return { + commands, + isLoading: false, + }; +} export function useSiteEditorNavigationCommands() { useCommandLoader( { @@ -124,4 +347,9 @@ export function useSiteEditorNavigationCommands() { name: 'core/edit-site/navigate-template-parts', hook: useTemplatePartNavigationCommandLoader, } ); + useCommandLoader( { + name: 'core/edit-site/basic-navigation', + hook: useSiteEditorBasicNavigationCommands, + context: 'site-editor', + } ); } diff --git a/packages/core-commands/src/utils/order-entity-records-by-search.js b/packages/core-commands/src/utils/order-entity-records-by-search.js new file mode 100644 index 00000000000000..35b29654871504 --- /dev/null +++ b/packages/core-commands/src/utils/order-entity-records-by-search.js @@ -0,0 +1,25 @@ +export function orderEntityRecordsBySearch( records = [], search = '' ) { + if ( ! Array.isArray( records ) || ! records.length ) { + return []; + } + + if ( ! search ) { + return records; + } + + const priority = []; + const nonPriority = []; + + for ( let i = 0; i < records.length; i++ ) { + const record = records[ i ]; + if ( + record?.title?.raw?.toLowerCase()?.includes( search?.toLowerCase() ) + ) { + priority.push( record ); + } else { + nonPriority.push( record ); + } + } + + return priority.concat( nonPriority ); +} diff --git a/packages/core-commands/src/utils/test/order-entity-records-by-search.js b/packages/core-commands/src/utils/test/order-entity-records-by-search.js new file mode 100644 index 00000000000000..4f869a777beae8 --- /dev/null +++ b/packages/core-commands/src/utils/test/order-entity-records-by-search.js @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ +import { orderEntityRecordsBySearch } from '../order-entity-records-by-search'; + +const mockData = [ + { + title: { + raw: 'Category', + }, + }, + { + title: { + raw: 'Archive', + }, + }, + { + title: { + raw: 'Single', + }, + }, + { + title: { + raw: 'Single Product', + }, + }, + { + title: { + raw: 'Order Confirmation', + }, + }, +]; + +describe( 'orderEntityRecordsBySearch', () => { + it( 'should return an empty array if no records are passed', () => { + expect( orderEntityRecordsBySearch( [], '' ) ).toEqual( [] ); + expect( orderEntityRecordsBySearch( null, '' ) ).toEqual( [] ); + } ); + + it( 'should correctly order records by search', () => { + const singleResult = orderEntityRecordsBySearch( mockData, 'Single' ); + const singleProductResult = orderEntityRecordsBySearch( + mockData, + 'Single Product' + ); + const categoryResult = orderEntityRecordsBySearch( + mockData, + 'Category' + ); + const orderResult = orderEntityRecordsBySearch( mockData, 'Order' ); + + expect( singleResult.map( ( { title } ) => title.raw ) ).toEqual( [ + 'Single', + 'Single Product', + 'Category', + 'Archive', + 'Order Confirmation', + ] ); + expect( singleProductResult.map( ( { title } ) => title.raw ) ).toEqual( + [ + 'Single Product', + 'Category', + 'Archive', + 'Single', + 'Order Confirmation', + ] + ); + expect( categoryResult.map( ( { title } ) => title.raw ) ).toEqual( [ + 'Category', + 'Archive', + 'Single', + 'Single Product', + 'Order Confirmation', + ] ); + expect( orderResult.map( ( { title } ) => title.raw ) ).toEqual( [ + 'Order Confirmation', + 'Category', + 'Archive', + 'Single', + 'Single Product', + ] ); + } ); +} ); diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index d01b1f1ca64da1..830aed362938c7 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 6.17.0 (2023-08-16) + +## 6.16.0 (2023-08-10) + +## 6.15.0 (2023-07-20) + +## 6.14.0 (2023-07-05) + +## 6.13.0 (2023-06-23) + +## 6.12.0 (2023-06-07) + ## 6.11.0 (2023-05-24) ## 6.10.0 (2023-05-10) diff --git a/packages/core-data/README.md b/packages/core-data/README.md index dddc3550e03b26..c778b724149ef3 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -313,7 +313,7 @@ _Parameters_ _Returns_ -- `Object | null`: The current global styles. +- `Array< object > | null`: The current global styles. ### getCurrentUser @@ -506,18 +506,6 @@ _Returns_ - `any`: The entity record's save error. -### getNavigationFallbackId - -Retrieve the fallback Navigation. - -_Parameters_ - -- _state_ `State`: Data state. - -_Returns_ - -- `EntityRecordKey | undefined`: The ID for the fallback Navigation post. - ### getRawEntityRecord Returns the entity's record object by key, with its attributes mapped to their raw values. @@ -535,6 +523,8 @@ _Returns_ ### getRedoEdit +> **Deprecated** since 6.3 + Returns the next edit from the current undo offset for the entity records edits history, if any. _Parameters_ @@ -578,6 +568,8 @@ _Returns_ ### getUndoEdit +> **Deprecated** since 6.3 + Returns the previous edit from the current undo offset for the entity records edits history, if any. _Parameters_ @@ -855,7 +847,7 @@ Resolves the specified entity records. _Usage_ ```js -import { useEntityRecord } from '@wordpress/core-data'; +import { useEntityRecords } from '@wordpress/core-data'; function PageTitlesList() { const { records, isResolving } = useEntityRecords( 'postType', 'page' ); diff --git a/packages/core-data/package.json b/packages/core-data/package.json index cd6d09b4917781..8bb83bfdbbf247 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-data", - "version": "6.11.0", + "version": "6.17.0", "description": "Access to and manipulation of core WordPress entities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -32,6 +32,7 @@ "dependencies": { "@babel/runtime": "^7.16.0", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", @@ -40,6 +41,8 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/sync": "file:../sync", "@wordpress/url": "file:../url", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", @@ -49,7 +52,8 @@ "uuid": "^8.3.0" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index ffae417a83cd13..1969d2cd717a2a 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -18,6 +18,7 @@ import { receiveItems, removeItems, receiveQueriedItems } from './queried-data'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; import { createBatch } from './batch'; import { STORE_NAME } from './name'; +import { getSyncProvider } from './sync'; /** * Returns an action object used in signalling that authors have been received. @@ -356,7 +357,7 @@ export const editEntityRecord = `The entity being edited (${ kind }, ${ name }) does not have a loaded config.` ); } - const { transientEdits = {}, mergedEdits = {} } = entityConfig; + const { mergedEdits = {} } = entityConfig; const record = select.getRawEntityRecord( kind, name, recordId ); const editedRecord = select.getEditedEntityRecord( kind, @@ -381,22 +382,31 @@ export const editEntityRecord = : value; return acc; }, {} ), - transientEdits, }; - dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...edit, - meta: { - undo: ! options.undoIgnore && { - ...edit, - // Send the current values for things like the first undo stack entry. - edits: Object.keys( edits ).reduce( ( acc, key ) => { - acc[ key ] = editedRecord[ key ]; - return acc; - }, {} ), + if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { + const objectId = entityConfig.getSyncObjectId( recordId ); + getSyncProvider().update( + entityConfig.syncObjectType + '--edit', + objectId, + edit.edits + ); + } else { + dispatch( { + type: 'EDIT_ENTITY_RECORD', + ...edit, + meta: { + undo: ! options.undoIgnore && { + ...edit, + // Send the current values for things like the first undo stack entry. + edits: Object.keys( edits ).reduce( ( acc, key ) => { + acc[ key ] = editedRecord[ key ]; + return acc; + }, {} ), + isCached: options.isCached, + }, }, - }, - } ); + } ); + } }; /** @@ -406,14 +416,13 @@ export const editEntityRecord = export const undo = () => ( { select, dispatch } ) => { - const undoEdit = select.getUndoEdit(); + const undoEdit = select.getUndoEdits(); if ( ! undoEdit ) { return; } dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...undoEdit, - meta: { isUndo: true }, + type: 'UNDO', + stackedEdits: undoEdit, } ); }; @@ -424,14 +433,13 @@ export const undo = export const redo = () => ( { select, dispatch } ) => { - const redoEdit = select.getRedoEdit(); + const redoEdit = select.getRedoEdits(); if ( ! redoEdit ) { return; } dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...redoEdit, - meta: { isRedo: true }, + type: 'REDO', + stackedEdits: redoEdit, } ); }; @@ -553,9 +561,12 @@ export const saveEntityRecord = data = Object.keys( data ).reduce( ( acc, key ) => { if ( - [ 'title', 'excerpt', 'content' ].includes( - key - ) + [ + 'title', + 'excerpt', + 'content', + 'meta', + ].includes( key ) ) { acc[ key ] = data[ key ]; } @@ -788,6 +799,22 @@ export const __experimentalSaveSpecifiedEntityEdits = editsToSave[ edit ] = edits[ edit ]; } } + + const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const entityConfig = configs.find( + ( config ) => config.kind === kind && config.name === name + ); + + const entityIdKey = entityConfig?.key || DEFAULT_ENTITY_KEY; + + // If a record key is provided then update the existing record. + // This necessitates providing `recordKey` to saveEntityRecord as part of the + // `record` argument (here called `editsToSave`) to stop that action creating + // a new record and instead cause it to update the existing record. + if ( recordId ) { + editsToSave[ entityIdKey ] = recordId; + } + return await dispatch.saveEntityRecord( kind, name, diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 3b8a443bcf1e39..6c1579de1ecdf1 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -13,6 +13,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { addEntities } from './actions'; +import { getSyncProvider } from './sync'; export const DEFAULT_ENTITY_KEY = 'id'; @@ -37,6 +38,24 @@ export const rootEntitiesConfig = [ 'url', ].join( ',' ), }, + syncConfig: { + fetch: async () => { + return apiFetch( { path: '/' } ); + }, + applyChangesToDoc: ( doc, changes ) => { + const document = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( document.get( key ) !== value ) { + document.set( key, value ); + } + } ); + }, + fromCRDTDoc: ( doc ) => { + return doc.getMap( 'document' ).toJSON(); + }, + }, + syncObjectType: 'root/base', + getSyncObjectId: () => 'index', }, { label: __( 'Site' ), @@ -46,6 +65,24 @@ export const rootEntitiesConfig = [ getTitle: ( record ) => { return record?.title ?? __( 'Site Title' ); }, + syncConfig: { + fetch: async () => { + return apiFetch( { path: '/wp/v2/settings' } ); + }, + applyChangesToDoc: ( doc, changes ) => { + const document = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( document.get( key ) !== value ) { + document.set( key, value ); + } + } ); + }, + fromCRDTDoc: ( doc ) => { + return doc.getMap( 'document' ).toJSON(); + }, + }, + syncObjectType: 'root/site', + getSyncObjectId: () => 'index', }, { label: __( 'Post Type' ), @@ -54,6 +91,26 @@ export const rootEntitiesConfig = [ key: 'slug', baseURL: '/wp/v2/types', baseURLParams: { context: 'edit' }, + syncConfig: { + fetch: async ( id ) => { + return apiFetch( { + path: `/wp/v2/types/${ id }?context=edit`, + } ); + }, + applyChangesToDoc: ( doc, changes ) => { + const document = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( document.get( key ) !== value ) { + document.set( key, value ); + } + } ); + }, + fromCRDTDoc: ( doc ) => { + return doc.getMap( 'document' ).toJSON(); + }, + }, + syncObjectType: 'root/postType', + getSyncObjectId: ( id ) => id, }, { name: 'media', @@ -237,6 +294,29 @@ async function loadPostTypeEntities() { : String( record.id ) ), __unstablePrePersist: isTemplate ? undefined : prePersistPostType, __unstable_rest_base: postType.rest_base, + syncConfig: { + fetch: async ( id ) => { + return apiFetch( { + path: `/${ namespace }/${ postType.rest_base }/${ id }?context=edit`, + } ); + }, + applyChangesToDoc: ( doc, changes ) => { + const document = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( + document.get( key ) !== value && + typeof value !== 'function' + ) { + document.set( key, value ); + } + } ); + }, + fromCRDTDoc: ( doc ) => { + return doc.getMap( 'document' ).toJSON(); + }, + }, + syncObjectType: 'postType/' + postType.name, + getSyncObjectId: ( id ) => id, }; } ); } @@ -299,6 +379,15 @@ export const getMethodName = ( return `${ prefix }${ kindPrefix }${ suffix }`; }; +function registerSyncConfigs( configs ) { + configs.forEach( ( { syncObjectType, syncConfig } ) => { + getSyncProvider().register( syncObjectType, syncConfig ); + const editSyncConfig = { ...syncConfig }; + delete editSyncConfig.fetch; + getSyncProvider().register( syncObjectType + '--edit', editSyncConfig ); + } ); +} + /** * Loads the kind entities into the store. * @@ -311,6 +400,7 @@ export const getOrLoadEntitiesConfig = async ( { select, dispatch } ) => { let configs = select.getEntitiesConfig( kind ); if ( configs && configs.length !== 0 ) { + registerSyncConfigs( configs ); return configs; } @@ -322,6 +412,7 @@ export const getOrLoadEntitiesConfig = } configs = await loader.loadEntities(); + registerSyncConfigs( configs ); dispatch( addEntities( configs ) ); return configs; diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js index f9d61aa413fc23..d32b3853627b32 100644 --- a/packages/core-data/src/entity-provider.js +++ b/packages/core-data/src/entity-provider.js @@ -5,20 +5,24 @@ import { createContext, useContext, useCallback, - useEffect, + useMemo, } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ import { STORE_NAME } from './name'; +import { unlock } from './private-apis'; /** @typedef {import('@wordpress/blocks').WPBlock} WPBlock */ const EMPTY_ARRAY = []; +let oldFootnotes = {}; + /** * Internal dependencies */ @@ -125,7 +129,7 @@ export function useEntityProp( kind, name, prop, _id ) { [ prop ]: newValue, } ); }, - [ kind, name, id, prop ] + [ editEntityRecord, kind, name, id, prop ] ); return [ value, setValue, fullValue ]; @@ -138,7 +142,7 @@ export function useEntityProp( kind, name, prop, _id ) { * The return value has the shape `[ blocks, onInput, onChange ]`. * `onInput` is for block changes that don't create undo levels * or dirty the post, non-persistent changes, and `onChange` is for - * peristent changes. They map directly to the props of a + * persistent changes. They map directly to the props of a * `BlockEditorProvider` and are intended to be used with it, * or similar components or hooks. * @@ -152,13 +156,14 @@ export function useEntityProp( kind, name, prop, _id ) { export function useEntityBlockEditor( kind, name, { id: _id } = {} ) { const providerId = useEntityId( kind, name ); const id = _id ?? providerId; - const { content, blocks } = useSelect( + const { content, editedBlocks, meta } = useSelect( ( select ) => { const { getEditedEntityRecord } = select( STORE_NAME ); const editedRecord = getEditedEntityRecord( kind, name, id ); return { - blocks: editedRecord.blocks, + editedBlocks: editedRecord.blocks, content: editedRecord.content, + meta: editedRecord.meta, }; }, [ kind, name, id ] @@ -166,53 +171,191 @@ export function useEntityBlockEditor( kind, name, { id: _id } = {} ) { const { __unstableCreateUndoLevel, editEntityRecord } = useDispatch( STORE_NAME ); - useEffect( () => { - // Load the blocks from the content if not already in state - // Guard against other instances that might have - // set content to a function already or the blocks are already in state. - if ( content && typeof content !== 'function' && ! blocks ) { - const parsedContent = parse( content ); - editEntityRecord( - kind, - name, - id, - { - blocks: parsedContent, - }, - { undoIgnore: true } - ); + const blocks = useMemo( () => { + if ( editedBlocks ) { + return editedBlocks; } - }, [ content ] ); + + return content && typeof content !== 'function' + ? parse( content ) + : EMPTY_ARRAY; + }, [ editedBlocks, content ] ); + + const updateFootnotes = useCallback( + ( _blocks ) => { + const output = { blocks: _blocks }; + if ( ! meta ) return output; + // If meta.footnotes is empty, it means the meta is not registered. + if ( meta.footnotes === undefined ) return output; + + const { getRichTextValues } = unlock( blockEditorPrivateApis ); + const _content = getRichTextValues( _blocks ).join( '' ) || ''; + const newOrder = []; + + // This can be avoided when + // https://github.com/WordPress/gutenberg/pull/43204 lands. We can then + // get the order directly from the rich text values. + if ( _content.indexOf( 'data-fn' ) !== -1 ) { + const regex = /data-fn="([^"]+)"/g; + let match; + while ( ( match = regex.exec( _content ) ) !== null ) { + newOrder.push( match[ 1 ] ); + } + } + + const footnotes = meta.footnotes + ? JSON.parse( meta.footnotes ) + : []; + const currentOrder = footnotes.map( ( fn ) => fn.id ); + + if ( currentOrder.join( '' ) === newOrder.join( '' ) ) + return output; + + const newFootnotes = newOrder.map( + ( fnId ) => + footnotes.find( ( fn ) => fn.id === fnId ) || + oldFootnotes[ fnId ] || { + id: fnId, + content: '', + } + ); + + function updateAttributes( attributes ) { + // Only attempt to update attributes, if attributes is an object. + if ( + ! attributes || + Array.isArray( attributes ) || + typeof attributes !== 'object' + ) { + return attributes; + } + + attributes = { ...attributes }; + + for ( const key in attributes ) { + const value = attributes[ key ]; + + if ( Array.isArray( value ) ) { + attributes[ key ] = value.map( updateAttributes ); + continue; + } + + if ( typeof value !== 'string' ) { + continue; + } + + if ( value.indexOf( 'data-fn' ) === -1 ) { + continue; + } + + // When we store rich text values, this would no longer + // require a regex. + const regex = + /(<sup[^>]+data-fn="([^"]+)"[^>]*><a[^>]*>)[\d*]*<\/a><\/sup>/g; + + attributes[ key ] = value.replace( + regex, + ( match, opening, fnId ) => { + const index = newOrder.indexOf( fnId ); + return `${ opening }${ index + 1 }</a></sup>`; + } + ); + + const compatRegex = + /<a[^>]+data-fn="([^"]+)"[^>]*>\*<\/a>/g; + + attributes[ key ] = attributes[ key ].replace( + compatRegex, + ( match, fnId ) => { + const index = newOrder.indexOf( fnId ); + return `<sup data-fn="${ fnId }" class="fn"><a href="#${ fnId }" id="${ fnId }-link">${ + index + 1 + }</a></sup>`; + } + ); + } + + return attributes; + } + + function updateBlocksAttributes( __blocks ) { + return __blocks.map( ( block ) => { + return { + ...block, + attributes: updateAttributes( block.attributes ), + innerBlocks: updateBlocksAttributes( + block.innerBlocks + ), + }; + } ); + } + + // We need to go through all block attributes deeply and update the + // footnote anchor numbering (textContent) to match the new order. + const newBlocks = updateBlocksAttributes( _blocks ); + + oldFootnotes = { + ...oldFootnotes, + ...footnotes.reduce( ( acc, fn ) => { + if ( ! newOrder.includes( fn.id ) ) { + acc[ fn.id ] = fn; + } + return acc; + }, {} ), + }; + + return { + meta: { + ...meta, + footnotes: JSON.stringify( newFootnotes ), + }, + blocks: newBlocks, + }; + }, + [ meta ] + ); const onChange = useCallback( ( newBlocks, options ) => { - const { selection } = options; - const edits = { blocks: newBlocks, selection }; - - const noChange = blocks === edits.blocks; + const noChange = blocks === newBlocks; if ( noChange ) { return __unstableCreateUndoLevel( kind, name, id ); } + const { selection } = options; // We create a new function here on every persistent edit // to make sure the edit makes the post dirty and creates // a new undo level. - edits.content = ( { blocks: blocksForSerialization = [] } ) => - __unstableSerializeAndClean( blocksForSerialization ); + const edits = { + selection, + content: ( { blocks: blocksForSerialization = [] } ) => + __unstableSerializeAndClean( blocksForSerialization ), + ...updateFootnotes( newBlocks ), + }; - editEntityRecord( kind, name, id, edits ); + editEntityRecord( kind, name, id, edits, { isCached: false } ); }, - [ kind, name, id, blocks ] + [ + kind, + name, + id, + blocks, + updateFootnotes, + __unstableCreateUndoLevel, + editEntityRecord, + ] ); const onInput = useCallback( ( newBlocks, options ) => { const { selection } = options; - const edits = { blocks: newBlocks, selection }; - editEntityRecord( kind, name, id, edits ); + const footnotesChanges = updateFootnotes( newBlocks ); + const edits = { selection, ...footnotesChanges }; + + editEntityRecord( kind, name, id, edits, { isCached: true } ); }, - [ kind, name, id ] + [ kind, name, id, updateFootnotes, editEntityRecord ] ); - return [ blocks ?? EMPTY_ARRAY, onInput, onChange ]; + return [ blocks, onInput, onChange ]; } diff --git a/packages/core-data/src/entity-types/theme.ts b/packages/core-data/src/entity-types/theme.ts index 04904ae2501f00..761c6461d4aca8 100644 --- a/packages/core-data/src/entity-types/theme.ts +++ b/packages/core-data/src/entity-types/theme.ts @@ -85,6 +85,10 @@ declare module './base-entity-records' { * Whether posts and comments RSS feed links are added to head. */ 'automatic-feed-links': boolean; + /** + * Whether border settings are enabled. + */ + border: boolean; /** * Custom background if defined by the theme. */ @@ -141,6 +145,10 @@ declare module './base-entity-records' { * Post formats supported. */ formats: PostFormat[]; + /** + * Whether link colors are enabled. + */ + 'link-color': boolean; /** * The post types that support thumbnails or true if all post types are supported. */ diff --git a/packages/core-data/src/entity-types/wp-template.ts b/packages/core-data/src/entity-types/wp-template.ts index 544476bbb7f36e..ac6db09035f193 100644 --- a/packages/core-data/src/entity-types/wp-template.ts +++ b/packages/core-data/src/entity-types/wp-template.ts @@ -85,6 +85,10 @@ declare module './base-entity-records' { * Whether a template is a custom template. */ is_custom: Record< string, string >; + /** + * The date the template was last modified, in the site's timezone. + */ + modified: ContextualField< string, 'view' | 'edit', C >; } } } diff --git a/packages/core-data/src/hooks/use-entity-record.ts b/packages/core-data/src/hooks/use-entity-record.ts index 9019bc8f59ed19..310c013ce0b877 100644 --- a/packages/core-data/src/hooks/use-entity-record.ts +++ b/packages/core-data/src/hooks/use-entity-record.ts @@ -160,7 +160,7 @@ export default function useEntityRecord< RecordType >( ...saveOptions, } ), } ), - [ recordId ] + [ editEntityRecord, kind, name, recordId, saveEditedEntityRecord ] ); const { editedRecord, hasEdits } = useSelect( @@ -182,7 +182,9 @@ export default function useEntityRecord< RecordType >( const { data: record, ...querySelectRest } = useQuerySelect( ( query ) => { if ( ! options.enabled ) { - return null; + return { + data: null, + }; } return query( coreStore ).getEntityRecord( kind, name, recordId ); }, diff --git a/packages/core-data/src/hooks/use-entity-records.ts b/packages/core-data/src/hooks/use-entity-records.ts index 01d4aa9e7e6091..ff72ea4078c0e4 100644 --- a/packages/core-data/src/hooks/use-entity-records.ts +++ b/packages/core-data/src/hooks/use-entity-records.ts @@ -43,7 +43,7 @@ const EMPTY_ARRAY = []; * @param options Optional hook options. * @example * ```js - * import { useEntityRecord } from '@wordpress/core-data'; + * import { useEntityRecords } from '@wordpress/core-data'; * * function PageTitlesList() { * const { records, isResolving } = useEntityRecords( 'postType', 'page' ); diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 43fa4a0b3cd074..5b42b6731716ff 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -8,11 +8,13 @@ import { createReduxStore, register } from '@wordpress/data'; */ import reducer from './reducer'; import * as selectors from './selectors'; +import * as privateSelectors from './private-selectors'; import * as actions from './actions'; import * as resolvers from './resolvers'; import createLocksActions from './locks/actions'; import { rootEntitiesConfig, getMethodName } from './entities'; import { STORE_NAME } from './name'; +import { unlock } from './private-apis'; // The entity selectors/resolvers and actions are shortcuts to their generic equivalents // (getEntityRecord, getEntityRecords, updateEntityRecord, updateEntityRecords) @@ -62,8 +64,8 @@ const storeConfig = () => ( { * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore */ export const store = createReduxStore( STORE_NAME, storeConfig() ); - -register( store ); +unlock( store ).registerPrivateSelectors( privateSelectors ); +register( store ); // Register store after unlocking private selectors to allow resolvers to use them. export { default as EntityProvider } from './entity-provider'; export * from './entity-provider'; diff --git a/packages/core-data/src/private-apis.js b/packages/core-data/src/private-apis.js new file mode 100644 index 00000000000000..a5b93a25dbf778 --- /dev/null +++ b/packages/core-data/src/private-apis.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/core-data' + ); diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts new file mode 100644 index 00000000000000..1e253b900e1cbb --- /dev/null +++ b/packages/core-data/src/private-selectors.ts @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import type { State, UndoEdit } from './selectors'; + +type Optional< T > = T | undefined; +type EntityRecordKey = string | number; + +/** + * Returns the previous edit from the current undo offset + * for the entity records edits history, if any. + * + * @param state State tree. + * + * @return The edit. + */ +export function getUndoEdits( state: State ): Optional< UndoEdit[] > { + return state.undo.list[ state.undo.list.length - 1 + state.undo.offset ]; +} + +/** + * Returns the next edit from the current undo offset + * for the entity records edits history, if any. + * + * @param state State tree. + * + * @return The edit. + */ +export function getRedoEdits( state: State ): Optional< UndoEdit[] > { + return state.undo.list[ state.undo.list.length + state.undo.offset ]; +} + +/** + * Retrieve the fallback Navigation. + * + * @param state Data state. + * @return The ID for the fallback Navigation post. + */ +export function getNavigationFallbackId( + state: State +): EntityRecordKey | undefined { + return state.navigationFallbackId; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index f04d543919b8c8..20755dad4be8d2 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -183,6 +183,30 @@ export function themeGlobalStyleVariations( state = {}, action ) { return state; } +const withMultiEntityRecordEdits = ( reducer ) => ( state, action ) => { + if ( action.type === 'UNDO' || action.type === 'REDO' ) { + const { stackedEdits } = action; + + let newState = state; + stackedEdits.forEach( + ( { kind, name, recordId, property, from, to } ) => { + newState = reducer( newState, { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId, + edits: { + [ property ]: action.type === 'UNDO' ? from : to, + }, + } ); + } + ); + return newState; + } + + return reducer( state, action ); +}; + /** * Higher Order Reducer for a given entity config. It supports: * @@ -196,6 +220,8 @@ export function themeGlobalStyleVariations( state = {}, action ) { */ function entity( entityConfig ) { return compose( [ + withMultiEntityRecordEdits, + // Limit to matching action type so we don't attempt to replace action on // an unhandled action. ifMatchingAction( @@ -411,8 +437,9 @@ export const entities = ( state = {}, action ) => { /** * @typedef {Object} UndoStateMeta * - * @property {number} offset Where in the undo stack we are. - * @property {Object} [flattenedUndo] Flattened form of undo stack. + * @property {number} list The undo stack. + * @property {number} offset Where in the undo stack we are. + * @property {Object} cache Cache of unpersisted edits. */ /** @typedef {Array<Object> & UndoStateMeta} UndoState */ @@ -422,10 +449,7 @@ export const entities = ( state = {}, action ) => { * * @todo Given how we use this we might want to make a custom class for it. */ -const UNDO_INITIAL_STATE = Object.assign( [], { offset: 0 } ); - -/** @type {Object} */ -let lastEditAction; +const UNDO_INITIAL_STATE = { list: [], offset: 0 }; /** * Reducer keeping track of entity edit undo history. @@ -436,107 +460,110 @@ let lastEditAction; * @return {UndoState} Updated state. */ export function undo( state = UNDO_INITIAL_STATE, action ) { + const omitPendingRedos = ( currentState ) => { + return { + ...currentState, + list: currentState.list.slice( + 0, + currentState.offset || undefined + ), + offset: 0, + }; + }; + + const appendCachedEditsToLastUndo = ( currentState ) => { + if ( ! currentState.cache ) { + return currentState; + } + + let nextState = { + ...currentState, + list: [ ...currentState.list ], + }; + nextState = omitPendingRedos( nextState ); + const previousUndoState = nextState.list.pop(); + const updatedUndoState = currentState.cache.reduce( + appendEditToStack, + previousUndoState + ); + nextState.list.push( updatedUndoState ); + + return { + ...nextState, + cache: undefined, + }; + }; + + const appendEditToStack = ( + stack = [], + { kind, name, recordId, property, from, to } + ) => { + const existingEditIndex = stack?.findIndex( + ( { kind: k, name: n, recordId: r, property: p } ) => { + return ( + k === kind && n === name && r === recordId && p === property + ); + } + ); + const nextStack = [ ...stack ]; + if ( existingEditIndex !== -1 ) { + // If the edit is already in the stack leave the initial "from" value. + nextStack[ existingEditIndex ] = { + ...nextStack[ existingEditIndex ], + to, + }; + } else { + nextStack.push( { + kind, + name, + recordId, + property, + from, + to, + } ); + } + return nextStack; + }; + switch ( action.type ) { - case 'EDIT_ENTITY_RECORD': case 'CREATE_UNDO_LEVEL': - let isCreateUndoLevel = action.type === 'CREATE_UNDO_LEVEL'; - const isUndoOrRedo = - ! isCreateUndoLevel && - ( action.meta.isUndo || action.meta.isRedo ); - if ( isCreateUndoLevel ) { - action = lastEditAction; - } else if ( ! isUndoOrRedo ) { - // Don't lose the last edit cache if the new one only has transient edits. - // Transient edits don't create new levels so updating the cache would make - // us skip an edit later when creating levels explicitly. - if ( - Object.keys( action.edits ).some( - ( key ) => ! action.transientEdits[ key ] - ) - ) { - lastEditAction = action; - } else { - lastEditAction = { - ...action, - edits: { - ...( lastEditAction && lastEditAction.edits ), - ...action.edits, - }, - }; - } - } + return appendCachedEditsToLastUndo( state ); - /** @type {UndoState} */ - let nextState; - - if ( isUndoOrRedo ) { - // @ts-ignore we might consider using Object.assign({}, state) - nextState = [ ...state ]; - nextState.offset = - state.offset + ( action.meta.isUndo ? -1 : 1 ); - - if ( state.flattenedUndo ) { - // The first undo in a sequence of undos might happen while we have - // flattened undos in state. If this is the case, we want execution - // to continue as if we were creating an explicit undo level. This - // will result in an extra undo level being appended with the flattened - // undo values. - // We also have to take into account if the `lastEditAction` had opted out - // of being tracked in undo history, like the action that persists the latest - // content right before saving. In that case we have to update the `lastEditAction` - // to avoid returning early before applying the existing flattened undos. - isCreateUndoLevel = true; - if ( ! lastEditAction.meta.undo ) { - lastEditAction.meta.undo = { - edits: {}, - }; - } - action = lastEditAction; - } else { - return nextState; - } - } + case 'UNDO': + case 'REDO': { + const nextState = appendCachedEditsToLastUndo( state ); + return { + ...nextState, + offset: state.offset + ( action.type === 'UNDO' ? -1 : 1 ), + }; + } + case 'EDIT_ENTITY_RECORD': { if ( ! action.meta.undo ) { return state; } - // Transient edits don't create an undo level, but are - // reachable in the next meaningful edit to which they - // are merged. They are defined in the entity's config. - if ( - ! isCreateUndoLevel && - ! Object.keys( action.edits ).some( - ( key ) => ! action.transientEdits[ key ] - ) - ) { - // @ts-ignore we might consider using Object.assign({}, state) - nextState = [ ...state ]; - nextState.flattenedUndo = { - ...state.flattenedUndo, - ...action.edits, + const edits = Object.keys( action.edits ).map( ( key ) => { + return { + kind: action.kind, + name: action.name, + recordId: action.recordId, + property: key, + from: action.meta.undo.edits[ key ], + to: action.edits[ key ], + }; + } ); + + if ( action.meta.undo.isCached ) { + return { + ...state, + cache: edits.reduce( appendEditToStack, state.cache ), }; - nextState.offset = state.offset; - return nextState; } - // Clear potential redos, because this only supports linear history. - nextState = - // @ts-ignore this needs additional cleanup, probably involving code-level changes - nextState || state.slice( 0, state.offset || undefined ); - nextState.offset = nextState.offset || 0; - nextState.pop(); - if ( ! isCreateUndoLevel ) { - nextState.push( { - kind: action.meta.undo.kind, - name: action.meta.undo.name, - recordId: action.meta.undo.recordId, - edits: { - ...state.flattenedUndo, - ...action.meta.undo.edits, - }, - } ); - } + let nextState = omitPendingRedos( state ); + nextState = appendCachedEditsToLastUndo( nextState ); + nextState = { ...nextState, list: [ ...nextState.list ] }; // When an edit is a function it's an optimization to avoid running some expensive operation. // We can't rely on the function references being the same so we opt out of comparing them here. const comparisonUndoEdits = Object.values( @@ -546,16 +573,11 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { ( edit ) => typeof edit !== 'function' ); if ( ! isShallowEqual( comparisonUndoEdits, comparisonEdits ) ) { - nextState.push( { - kind: action.kind, - name: action.name, - recordId: action.recordId, - edits: isCreateUndoLevel - ? { ...state.flattenedUndo, ...action.edits } - : action.edits, - } ); + nextState.list.push( edits ); } + return nextState; + } } return state; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 6437b759976901..a9bd6adfcdbff0 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -15,6 +15,7 @@ import apiFetch from '@wordpress/api-fetch'; import { STORE_NAME } from './name'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; import { forwardResolver, getNormalizedCommaSeparable } from './utils'; +import { getSyncProvider } from './sync'; /** * Requests authors from the REST API. @@ -71,51 +72,98 @@ export const getEntityRecord = ); try { - if ( query !== undefined && query._fields ) { - // If requesting specific fields, items and query association to said - // records are stored by ID reference. Thus, fields must always include - // the ID. - query = { - ...query, - _fields: [ - ...new Set( [ - ...( getNormalizedCommaSeparable( query._fields ) || - [] ), - entityConfig.key || DEFAULT_ENTITY_KEY, - ] ), - ].join(), - }; - } - - // Disable reason: While true that an early return could leave `path` - // unused, it's important that path is derived using the query prior to - // additional query modifications in the condition below, since those - // modifications are relevant to how the data is tracked in state, and not - // for how the request is made to the REST API. - - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const path = addQueryArgs( - entityConfig.baseURL + ( key ? '/' + key : '' ), - { - ...entityConfig.baseURLParams, - ...query, + // Entity supports configs, + // use the sync algorithm instead of the old fetch behavior. + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig && + ! query + ) { + const objectId = entityConfig.getSyncObjectId( key ); + + // Loads the persisted document. + await getSyncProvider().bootstrap( + entityConfig.syncObjectType, + objectId, + ( record ) => { + dispatch.receiveEntityRecords( + kind, + name, + record, + query + ); + } + ); + + // Boostraps the edited document as well (and load from peers). + await getSyncProvider().bootstrap( + entityConfig.syncObjectType + '--edit', + objectId, + ( record ) => { + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId: key, + edits: record, + meta: { + undo: undefined, + }, + } ); + } + ); + } else { + if ( query !== undefined && query._fields ) { + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( + query._fields + ) || [] ), + entityConfig.key || DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; } - ); - - if ( query !== undefined ) { - query = { ...query, include: [ key ] }; - // The resolution cache won't consider query as reusable based on the - // fields, so it's tested here, prior to initiating the REST request, - // and without causing `getEntityRecords` resolution to occur. - const hasRecords = select.hasEntityRecords( kind, name, query ); - if ( hasRecords ) { - return; + // Disable reason: While true that an early return could leave `path` + // unused, it's important that path is derived using the query prior to + // additional query modifications in the condition below, since those + // modifications are relevant to how the data is tracked in state, and not + // for how the request is made to the REST API. + + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, + } + ); + + if ( query !== undefined ) { + query = { ...query, include: [ key ] }; + + // The resolution cache won't consider query as reusable based on the + // fields, so it's tested here, prior to initiating the REST request, + // and without causing `getEntityRecords` resolution to occur. + const hasRecords = select.hasEntityRecords( + kind, + name, + query + ); + if ( hasRecords ) { + return; + } } - } - const record = await apiFetch( { path } ); - dispatch.receiveEntityRecords( kind, name, record, query ); + const record = await apiFetch( { path } ); + dispatch.receiveEntityRecords( kind, name, record, query ); + } } finally { dispatch.__unstableReleaseStoreLock( lock ); } @@ -573,7 +621,7 @@ export const getBlockPatternCategories = export const getNavigationFallbackId = () => - async ( { dispatch } ) => { + async ( { dispatch, select } ) => { const fallback = await apiFetch( { path: addQueryArgs( '/wp-block-editor/v1/navigation-fallback', { _embed: true, @@ -585,10 +633,21 @@ export const getNavigationFallbackId = dispatch.receiveNavigationFallbackId( fallback?.id ); if ( record ) { + // If the fallback is already in the store, don't invalidate navigation queries. + // Otherwise, invalidate the cache for the scenario where there were no Navigation + // posts in the state and the fallback created one. + const existingFallbackEntityRecord = select.getEntityRecord( + 'postType', + 'wp_navigation', + fallback?.id + ); + const invalidateNavigationQueries = ! existingFallbackEntityRecord; dispatch.receiveEntityRecords( 'postType', 'wp_navigation', - record + record, + undefined, + invalidateNavigationQueries ); // Resolve to avoid further network requests. diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 7513d918109673..377134ab7c9a3d 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -22,6 +22,7 @@ import { setNestedValue, } from './utils'; import type * as ET from './entity-types'; +import { getUndoEdits, getRedoEdits } from './private-selectors'; // This is an incomplete, high-level approximation of the State type. // It makes the selectors slightly more safe, but is intended to evolve @@ -73,9 +74,18 @@ interface EntityConfig { kind: string; } -interface UndoState extends Array< Object > { - flattenedUndo: unknown; +export interface UndoEdit { + name: string; + kind: string; + recordId: string; + from: any; + to: any; +} + +interface UndoState { + list: Array< UndoEdit[] >; offset: number; + cache: UndoEdit[]; } interface UserState { @@ -884,24 +894,38 @@ function getCurrentUndoOffset( state: State ): number { * Returns the previous edit from the current undo offset * for the entity records edits history, if any. * - * @param state State tree. + * @deprecated since 6.3 + * + * @param state State tree. * * @return The edit. */ export function getUndoEdit( state: State ): Optional< any > { - return state.undo[ state.undo.length - 2 + getCurrentUndoOffset( state ) ]; + deprecated( "select( 'core' ).getUndoEdit()", { + since: '6.3', + } ); + return state.undo.list[ + state.undo.list.length - 2 + getCurrentUndoOffset( state ) + ]?.[ 0 ]; } /** * Returns the next edit from the current undo offset * for the entity records edits history, if any. * - * @param state State tree. + * @deprecated since 6.3 + * + * @param state State tree. * * @return The edit. */ export function getRedoEdit( state: State ): Optional< any > { - return state.undo[ state.undo.length + getCurrentUndoOffset( state ) ]; + deprecated( "select( 'core' ).getRedoEdit()", { + since: '6.3', + } ); + return state.undo.list[ + state.undo.list.length + getCurrentUndoOffset( state ) + ]?.[ 0 ]; } /** @@ -913,7 +937,7 @@ export function getRedoEdit( state: State ): Optional< any > { * @return Whether there is a previous edit or not. */ export function hasUndo( state: State ): boolean { - return Boolean( getUndoEdit( state ) ); + return Boolean( getUndoEdits( state ) ); } /** @@ -925,7 +949,7 @@ export function hasUndo( state: State ): boolean { * @return Whether there is a next edit or not. */ export function hasRedo( state: State ): boolean { - return Boolean( getRedoEdit( state ) ); + return Boolean( getRedoEdits( state ) ); } /** @@ -1142,11 +1166,7 @@ export const hasFetchedAutosaves = createRegistrySelector( export const getReferenceByDistinctEdits = createSelector( // This unused state argument is listed here for the documentation generating tool (docgen). ( state: State ) => [], - ( state: State ) => [ - state.undo.length, - state.undo.offset, - state.undo.flattenedUndo, - ] + ( state: State ) => [ state.undo.list.length, state.undo.offset ] ); /** @@ -1237,18 +1257,6 @@ export function getBlockPatternCategories( state: State ): Array< any > { return state.blockPatternCategories; } -/** - * Retrieve the fallback Navigation. - * - * @param state Data state. - * @return The ID for the fallback Navigation post. - */ -export function getNavigationFallbackId( - state: State -): EntityRecordKey | undefined { - return state.navigationFallbackId; -} - /** * Returns the revisions of the current global styles theme. * @@ -1258,7 +1266,7 @@ export function getNavigationFallbackId( */ export function getCurrentThemeGlobalStylesRevisions( state: State -): Object | null { +): Array< object > | null { const currentGlobalStylesId = __experimentalGetCurrentGlobalStylesId( state ); diff --git a/packages/core-data/src/sync.js b/packages/core-data/src/sync.js new file mode 100644 index 00000000000000..89ebdd605208d2 --- /dev/null +++ b/packages/core-data/src/sync.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { + createSyncProvider, + connectIndexDb, + connectWebRTC, +} from '@wordpress/sync'; + +let syncProvider; + +export function getSyncProvider() { + if ( ! syncProvider ) { + syncProvider = createSyncProvider( connectIndexDb, connectWebRTC ); + } + + return syncProvider; +} diff --git a/packages/core-data/src/test/entity-provider.js b/packages/core-data/src/test/entity-provider.js new file mode 100644 index 00000000000000..728630b482626c --- /dev/null +++ b/packages/core-data/src/test/entity-provider.js @@ -0,0 +1,272 @@ +/** + * External dependencies + */ +import { act, render } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { + createBlock, + registerBlockType, + unregisterBlockType, + getBlockTypes, +} from '@wordpress/blocks'; +import { RichText, useBlockProps } from '@wordpress/block-editor'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as coreDataStore } from '../index'; +import { useEntityBlockEditor } from '../entity-provider'; + +const postTypeConfig = { + kind: 'postType', + name: 'post', + baseURL: '/wp/v2/posts', + transientEdits: { blocks: true, selection: true }, + mergedEdits: { meta: true }, + rawAttributes: [ 'title', 'excerpt', 'content' ], +}; + +const postTypeEntity = { + slug: 'post', + rest_base: 'posts', + labels: { + item_updated: 'Updated Post', + item_published: 'Post published', + item_reverted_to_draft: 'Post reverted to draft.', + }, +}; + +const aSinglePost = { + id: 1, + type: 'post', + content: { + raw: '<!-- wp:test-block-with-array-of-strings --><div><p>apples</p><p></p><p>oranges</p></div><!-- /wp:test-block-with-array-of-strings --><!-- wp:test-block --><p>A paragraph</p><!-- /wp:test-block -->', + rendered: '<p>A paragraph</p>', + }, + meta: { + footnotes: '[]', + }, +}; + +function createRegistryWithStores() { + // Create a registry. + const registry = createRegistry(); + + // Register store. + registry.register( coreDataStore ); + + // Register post type entity. + registry.dispatch( coreDataStore ).addEntities( [ postTypeConfig ] ); + + // Store post type entity. + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'root', 'postType', [ postTypeEntity ] ); + + // Store a single post for use by the tests. + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'postType', 'post', [ aSinglePost ] ); + + return registry; +} + +describe( 'useEntityBlockEditor', () => { + let registry; + + beforeEach( () => { + registry = createRegistryWithStores(); + + const edit = ( { children } ) => <>{ children }</>; + + registerBlockType( 'core/test-block', { + supports: { + className: false, + }, + save: ( { attributes } ) => { + const { content } = attributes; + return ( + <p { ...useBlockProps.save() }> + <RichText.Content value={ content } /> + </p> + ); + }, + category: 'text', + attributes: { + content: { + type: 'string', + source: 'html', + selector: 'p', + default: '', + __experimentalRole: 'content', + }, + }, + title: 'block title', + edit, + } ); + + registerBlockType( 'core/test-block-with-array-of-strings', { + supports: { + className: false, + }, + save: ( { attributes } ) => { + const { items } = attributes; + return ( + <div> + { items.map( ( item, index ) => ( + <p key={ index }>{ item }</p> + ) ) } + </div> + ); + }, + category: 'text', + attributes: { + items: { + type: 'array', + items: { + type: 'string', + }, + default: [ 'apples', null, 'oranges' ], + }, + }, + title: 'block title', + edit, + } ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'does not mutate block attributes that include an array of strings or null values', async () => { + let blocks, onChange; + const TestComponent = () => { + [ blocks, , onChange ] = useEntityBlockEditor( 'postType', 'post', { + id: 1, + } ); + + return <div />; + }; + + render( + <RegistryProvider value={ registry }> + <TestComponent /> + </RegistryProvider> + ); + + expect( blocks[ 0 ].name ).toEqual( + 'core/test-block-with-array-of-strings' + ); + expect( blocks[ 0 ].attributes.items ).toEqual( [ + 'apples', + null, + 'oranges', + ] ); + + // Add a block with content that will match against footnotes logic, causing + // `updateFootnotes` to iterate over blocks and their attributes. + act( () => { + onChange( + [ + ...blocks, + createBlock( 'core/test-block', { + content: + '<p><sup data-fn="1234" class="fn"><a href="#1234" id="1234-link">1</a></sup></p>', + } ), + ], + { + selection: { + selectionStart: {}, + selectionEnd: {}, + initialPosition: {}, + }, + } + ); + } ); + + // Ensure the first block remains the same, with unaltered attributes. + expect( blocks[ 0 ].name ).toEqual( + 'core/test-block-with-array-of-strings' + ); + expect( blocks[ 0 ].attributes.items ).toEqual( [ + 'apples', + null, + 'oranges', + ] ); + } ); + + it( 'updates the order of footnotes when a new footnote is inserted', async () => { + // Start with a post containing a block with a single footnote (set to 1). + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'postType', 'post', [ + { + id: 1, + type: 'post', + content: { + raw: '<!-- wp:test-block --><p>A paragraph<sup data-fn="abcd" class="fn"><a href="#abcd" id="abcd-link">1</a></sup></p><!-- /wp:test-block -->', + rendered: '<p>A paragraph</p>', + }, + meta: { + footnotes: '[]', + }, + }, + ] ); + + let blocks, onChange; + + const TestComponent = () => { + [ blocks, , onChange ] = useEntityBlockEditor( 'postType', 'post', { + id: 1, + } ); + + return <div />; + }; + + render( + <RegistryProvider value={ registry }> + <TestComponent /> + </RegistryProvider> + ); + + // The first block should have the footnote number 1. + expect( blocks[ 0 ].attributes.content ).toEqual( + 'A paragraph<sup data-fn="abcd" class="fn"><a href="#abcd" id="abcd-link">1</a></sup>' + ); + + // Add a block with a new footnote with an arbitrary footnote number that will be overwritten after insertion. + act( () => { + onChange( + [ + createBlock( 'core/test-block', { + content: + 'A new paragraph<sup data-fn="xyz" class="xyz"><a href="#xyz" id="xyz-link">999</a></sup>', + } ), + ...blocks, + ], + { + selection: { + selectionStart: {}, + selectionEnd: {}, + initialPosition: {}, + }, + } + ); + } ); + + // The newly inserted block should have the footnote number 1, and the + // existing footnote number 1 should be updated to 2. + expect( blocks[ 0 ].attributes.content ).toEqual( + 'A new paragraph<sup data-fn="xyz" class="xyz"><a href="#xyz" id="xyz-link">1</a></sup>' + ); + expect( blocks[ 1 ].attributes.content ).toEqual( + 'A paragraph<sup data-fn="abcd" class="fn"><a href="#abcd" id="abcd-link">2</a></sup>' + ); + } ); +} ); diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 63caf5fb83b177..7fac52c33c4b36 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -143,28 +143,36 @@ describe( 'entities', () => { } ); describe( 'undo', () => { - let lastEdits; + let lastValues; let undoState; let expectedUndoState; - const createEditActionPart = ( edits ) => ( { + + const createExpectedDiff = ( property, { from, to } ) => ( { kind: 'someKind', name: 'someName', recordId: 'someRecordId', - edits, + property, + from, + to, } ); - const createNextEditAction = ( edits, transientEdits = {} ) => { + const createNextEditAction = ( edits, isCached ) => { let action = { - ...createEditActionPart( edits ), - transientEdits, + kind: 'someKind', + name: 'someName', + recordId: 'someRecordId', + edits, }; action = { type: 'EDIT_ENTITY_RECORD', ...action, meta: { - undo: { ...action, edits: lastEdits }, + undo: { + isCached, + edits: lastValues, + }, }, }; - lastEdits = { ...lastEdits, ...edits }; + lastValues = { ...lastValues, ...edits }; return action; }; const createNextUndoState = ( ...args ) => { @@ -172,17 +180,17 @@ describe( 'undo', () => { if ( args[ 0 ] === 'isUndo' || args[ 0 ] === 'isRedo' ) { // We need to "apply" the undo level here and build // the action to move the offset. - lastEdits = - undoState[ - undoState.length + - undoState.offset - - ( args[ 0 ] === 'isUndo' ? 2 : 0 ) - ].edits; + const lastEdits = + undoState.list[ + undoState.list.length - + ( args[ 0 ] === 'isUndo' ? 1 : 0 ) + + undoState.offset + ]; + lastEdits.forEach( ( { property, from, to } ) => { + lastValues[ property ] = args[ 0 ] === 'isUndo' ? from : to; + } ); action = { - type: 'EDIT_ENTITY_RECORD', - meta: { - [ args[ 0 ] ]: true, - }, + type: args[ 0 ] === 'isUndo' ? 'UNDO' : 'REDO', }; } else if ( args[ 0 ] === 'isCreate' ) { action = { type: 'CREATE_UNDO_LEVEL' }; @@ -192,10 +200,9 @@ describe( 'undo', () => { return deepFreeze( undo( undoState, action ) ); }; beforeEach( () => { - lastEdits = {}; + lastValues = {}; undoState = undefined; - expectedUndoState = []; - expectedUndoState.offset = 0; + expectedUndoState = { list: [], offset: 0 }; } ); it( 'initializes', () => { @@ -208,19 +215,41 @@ describe( 'undo', () => { // Check that the first edit creates an undo level for the current state and // one for the new one. undoState = createNextUndoState( { value: 1 } ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1 } ) - ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); // Check that the second and third edits just create an undo level for // themselves. undoState = createNextUndoState( { value: 2 } ); - expectedUndoState.push( createEditActionPart( { value: 2 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 1, to: 2 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.push( createEditActionPart( { value: 3 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 2, to: 3 } ), + ] ); + expect( undoState ).toEqual( expectedUndoState ); + } ); + + it( 'stacks multi-property undo levels', () => { + undoState = createNextUndoState(); + + undoState = createNextUndoState( { value: 1 } ); + undoState = createNextUndoState( { value2: 2 } ); + expectedUndoState.list.push( + [ createExpectedDiff( 'value', { from: undefined, to: 1 } ) ], + [ createExpectedDiff( 'value2', { from: undefined, to: 2 } ) ] + ); + expect( undoState ).toEqual( expectedUndoState ); + + // Check that that creating another undo level merges the "edits" + undoState = createNextUndoState( { value: 2 } ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 1, to: 2 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); } ); @@ -229,11 +258,10 @@ describe( 'undo', () => { undoState = createNextUndoState( { value: 1 } ); undoState = createNextUndoState( { value: 2 } ); undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1 } ), - createEditActionPart( { value: 2 } ), - createEditActionPart( { value: 3 } ) + expectedUndoState.list.push( + [ createExpectedDiff( 'value', { from: undefined, to: 1 } ) ], + [ createExpectedDiff( 'value', { from: 1, to: 2 } ) ], + [ createExpectedDiff( 'value', { from: 2, to: 3 } ) ] ); expect( undoState ).toEqual( expectedUndoState ); @@ -255,32 +283,39 @@ describe( 'undo', () => { // Check that another edit will go on top when there // is no undo level offset. undoState = createNextUndoState( { value: 4 } ); - expectedUndoState.push( createEditActionPart( { value: 4 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 3, to: 4 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); // Check that undoing and editing will slice of // all the levels after the current one. undoState = createNextUndoState( 'isUndo' ); undoState = createNextUndoState( 'isUndo' ); + undoState = createNextUndoState( { value: 5 } ); - expectedUndoState.pop(); - expectedUndoState.pop(); - expectedUndoState.push( createEditActionPart( { value: 5 } ) ); + expectedUndoState.list.pop(); + expectedUndoState.list.pop(); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 2, to: 5 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); } ); it( 'handles flattened undos/redos', () => { undoState = createNextUndoState(); undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( - { transientValue: 2 }, - { transientValue: true } - ); + undoState = createNextUndoState( { transientValue: 2 }, true ); undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1, transientValue: 2 } ), - createEditActionPart( { value: 3 } ) + expectedUndoState.list.push( + [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + createExpectedDiff( 'transientValue', { + from: undefined, + to: 2, + } ), + ], + [ createExpectedDiff( 'value', { from: 1, to: 3 } ) ] ); expect( undoState ).toEqual( expectedUndoState ); } ); @@ -292,46 +327,40 @@ describe( 'undo', () => { // transient edits. undoState = createNextUndoState( { value: 1 } ); undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1 } ) - ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); // Check that transient edits are merged into the last // edits. - undoState = createNextUndoState( - { transientValue: 2 }, - { transientValue: true } - ); + undoState = createNextUndoState( { transientValue: 2 }, true ); undoState = createNextUndoState( 'isCreate' ); - expectedUndoState[ - expectedUndoState.length - 1 - ].edits.transientValue = 2; + expectedUndoState.list[ expectedUndoState.list.length - 1 ].push( + createExpectedDiff( 'transientValue', { from: undefined, to: 2 } ) + ); expect( undoState ).toEqual( expectedUndoState ); - // Check that undo levels are created with the latest action, - // even if undone. + // Check that create after undo does nothing. undoState = createNextUndoState( { value: 3 } ); undoState = createNextUndoState( 'isUndo' ); undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.pop(); - expectedUndoState.push( createEditActionPart( { value: 3 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 1, to: 3 } ), + ] ); + expectedUndoState.offset = -1; expect( undoState ).toEqual( expectedUndoState ); } ); it( 'explicitly creates an undo level when undoing while there are pending transient edits', () => { undoState = createNextUndoState(); undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( - { transientValue: 2 }, - { transientValue: true } - ); + undoState = createNextUndoState( { transientValue: 2 }, true ); undoState = createNextUndoState( 'isUndo' ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1, transientValue: 2 } ) - ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + createExpectedDiff( 'transientValue', { from: undefined, to: 2 } ), + ] ); expectedUndoState.offset--; expect( undoState ).toEqual( expectedUndoState ); } ); @@ -341,7 +370,6 @@ describe( 'undo', () => { undoState = createNextUndoState(); undoState = createNextUndoState( { value } ); undoState = createNextUndoState( { value: () => {} } ); - expectedUndoState.push( createEditActionPart( { value } ) ); expect( undoState ).toEqual( expectedUndoState ); } ); } ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 0ea9e26e505437..84fecc7d07cda9 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -838,20 +838,20 @@ describe( 'getCurrentUser', () => { describe( 'getReferenceByDistinctEdits', () => { it( 'should return referentially equal values across empty states', () => { - const state = { undo: [] }; + const state = { undo: { list: [] } }; expect( getReferenceByDistinctEdits( state ) ).toBe( getReferenceByDistinctEdits( state ) ); - const beforeState = { undo: [] }; - const afterState = { undo: [] }; + const beforeState = { undo: { list: [] } }; + const afterState = { undo: { list: [] } }; expect( getReferenceByDistinctEdits( beforeState ) ).toBe( getReferenceByDistinctEdits( afterState ) ); } ); it( 'should return referentially equal values across unchanging non-empty state', () => { - const undoStates = [ {} ]; + const undoStates = { list: [ {} ] }; const state = { undo: undoStates }; expect( getReferenceByDistinctEdits( state ) ).toBe( getReferenceByDistinctEdits( state ) @@ -866,9 +866,9 @@ describe( 'getReferenceByDistinctEdits', () => { describe( 'when adding edits', () => { it( 'should return referentially different values across changing states', () => { - const beforeState = { undo: [ {} ] }; + const beforeState = { undo: { list: [ {} ] } }; beforeState.undo.offset = 0; - const afterState = { undo: [ {}, {} ] }; + const afterState = { undo: { list: [ {}, {} ] } }; afterState.undo.offset = 1; expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( getReferenceByDistinctEdits( afterState ) @@ -878,9 +878,9 @@ describe( 'getReferenceByDistinctEdits', () => { describe( 'when using undo', () => { it( 'should return referentially different values across changing states', () => { - const beforeState = { undo: [ {}, {} ] }; + const beforeState = { undo: { list: [ {}, {} ] } }; beforeState.undo.offset = 1; - const afterState = { undo: [ {}, {} ] }; + const afterState = { undo: { list: [ {}, {} ] } }; afterState.undo.offset = 0; expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( getReferenceByDistinctEdits( afterState ) diff --git a/packages/core-data/src/utils/set-nested-value.js b/packages/core-data/src/utils/set-nested-value.js index cb7db8a04b4b07..e90bf23e4dad8e 100644 --- a/packages/core-data/src/utils/set-nested-value.js +++ b/packages/core-data/src/utils/set-nested-value.js @@ -10,6 +10,8 @@ * * @see https://lodash.com/docs/4.17.15#set * + * @todo Needs to be deduplicated with its copy in `@wordpress/edit-site`. + * * @param {Object} object Object to modify * @param {Array} path Path of the property to set. * @param {*} value Value to set. diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index c712a2d1ad2d81..031d697f8dbe6b 100644 --- a/packages/core-data/tsconfig.json +++ b/packages/core-data/tsconfig.json @@ -10,12 +10,15 @@ "references": [ { "path": "../api-fetch" }, { "path": "../compose" }, + { "path": "../block-editor" }, { "path": "../data" }, { "path": "../deprecated" }, { "path": "../element" }, { "path": "../html-entities" }, { "path": "../i18n" }, { "path": "../is-shallow-equal" }, + { "path": "../private-apis" }, + { "path": "../sync" }, { "path": "../url" } ], "include": [ "src/**/*" ] diff --git a/packages/create-block-interactive-template/.npmrc b/packages/create-block-interactive-template/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/create-block-interactive-template/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md new file mode 100644 index 00000000000000..e5c32350167c92 --- /dev/null +++ b/packages/create-block-interactive-template/CHANGELOG.md @@ -0,0 +1,11 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased + +## 1.3.0 (2023-08-16) + +## 1.2.0 (2023-08-10) + +### Enhancement + +- Moves the `example` property into block.json by leveraging changes to create-block to now support `example`. [#52801](https://github.com/WordPress/gutenberg/pull/52801) diff --git a/packages/create-block-interactive-template/README.md b/packages/create-block-interactive-template/README.md new file mode 100644 index 00000000000000..cc0530c0630549 --- /dev/null +++ b/packages/create-block-interactive-template/README.md @@ -0,0 +1,19 @@ +# 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 + +## Usage + +This block template can be used by running the following command: + +```bash +npx @wordpress/create-block --template @wordpress/create-block-interactive-template +``` + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/create-block-interactive-template/block-templates/README.md.mustache b/packages/create-block-interactive-template/block-templates/README.md.mustache new file mode 100644 index 00000000000000..728f37b8c0e39f --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/README.md.mustache @@ -0,0 +1,16 @@ +# Interactive Block + +> **Warning** +> **This block requires Gutenberg 16.2 or superior to work**. The Interactivity API is, at the moment, not part of WordPress Core as it is still very experimental, and very likely to change. + +> **Note** +> This block uses 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/). + +{{#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}} + +Check the following resources for more info about the Interactivity API: +- [`@wordpress/interactivity` package](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/README.md) +- [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/) +- [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions \ No newline at end of file diff --git a/packages/create-block-interactive-template/block-templates/edit.js.mustache b/packages/create-block-interactive-template/block-templates/edit.js.mustache new file mode 100644 index 00000000000000..1a0aeeac8d6979 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/edit.js.mustache @@ -0,0 +1,36 @@ +/** + * Retrieves the translation of text. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/ + */ +import { __ } from '@wordpress/i18n'; + +/** + * React hook that is used to mark the block wrapper element. + * It provides all the necessary props like the class name. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops + */ +import { useBlockProps } from '@wordpress/block-editor'; + +/** + * The edit function describes the structure of your block in the context of the + * editor. This represents what the editor will render when the block is used. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit + * + * @param {Object} props Properties passed to the function. + * @param {Object} props.attributes Available block attributes. + * @param {Function} props.setAttributes Function that updates individual attributes. + * + * @return {WPElement} Element to render. + */ +export default function Edit( { attributes, setAttributes } ) { + const blockProps = useBlockProps(); + + return ( + <p { ...blockProps }> + { __( '{{title}} – hello from the editor!', '{{textdomain}}' ) } + </p> + ); +} diff --git a/packages/create-block-interactive-template/block-templates/editor.scss.mustache b/packages/create-block-interactive-template/block-templates/editor.scss.mustache new file mode 100644 index 00000000000000..bdb19abec84212 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/editor.scss.mustache @@ -0,0 +1,12 @@ +/** + * The following styles get applied inside the editor only. + * + * Replace them with your own styles or remove the file completely. + */ + +.wp-block-{{namespace}}-{{slug}} input[type="text"] { + font-size: 1em; + color: inherit; + background: inherit; + border: 0; +} diff --git a/packages/create-block-interactive-template/block-templates/index.js.mustache b/packages/create-block-interactive-template/block-templates/index.js.mustache new file mode 100644 index 00000000000000..5279d80f5754c8 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/index.js.mustache @@ -0,0 +1,35 @@ +/** + * Registers a new block provided a unique name and an object defining its behavior. + * + * @see https://developer.wordpress.org/block-editor/developers/block-api/#registering-a-block + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files. + * All files containing `style` keyword are bundled together. The code used + * gets applied both to the front of your site and to the editor. All other files + * get applied to the editor only. + * + * @see https://www.npmjs.com/package/@wordpress/scripts#using-css + */ +import './style.scss'; +import './editor.scss'; + +/** + * Internal dependencies + */ +import Edit from './edit'; +import metadata from './block.json'; + +/** + * Every block starts by registering a new block type definition. + * + * @see https://developer.wordpress.org/block-editor/developers/block-api/#registering-a-block + */ +registerBlockType( metadata.name, { + /** + * @see ./edit.js + */ + edit: Edit, +} ); diff --git a/packages/create-block-interactive-template/block-templates/render.php.mustache b/packages/create-block-interactive-template/block-templates/render.php.mustache new file mode 100644 index 00000000000000..c458473d565e00 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/render.php.mustache @@ -0,0 +1,40 @@ +{{#isBasicVariant}} +<?php +/** + * PHP file to use when rendering the block type on the server to show on the front end. + * + * The following variables are exposed to the file: + * $attributes (array): The block attributes. + * $content (string): The block default content. + * $block (WP_Block): The block instance. + * + * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render + */ + +$unique_id = uniqid( 'p-' ); +?> + +<div + <?php echo get_block_wrapper_attributes(); ?> + data-wp-interactive + data-wp-context='{ "{{namespace}}": { "isOpen": false } }' + data-wp-effect="effects.{{namespace}}.logIsOpen" +> + <button + data-wp-on--click="actions.{{namespace}}.toggle" + data-wp-bind--aria-expanded="context.{{namespace}}.isOpen" + aria-controls="p-<?php echo esc_attr( $unique_id ); ?>" + > + <?php esc_html_e( 'Toggle', '{{textdomain}}' ); ?> + </button> + + <p + id="p-<?php echo esc_attr( $unique_id ); ?>" + data-wp-bind--hidden="!context.{{namespace}}.isOpen" + > + <?php + esc_html_e( '{{title}} - hello from an interactive block!', '{{textdomain}}' ); + ?> + </p> +</div> +{{/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 new file mode 100644 index 00000000000000..1c73fa1c38ff94 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/style.scss.mustache @@ -0,0 +1,12 @@ +/** + * The following styles get applied both on the front of your site + * and in the editor. + * + * Replace them with your own styles or remove the file completely. + */ + +.wp-block-{{namespace}}-{{slug}} { + font-size: 1em; + background: #ffff001a; + padding: 1em; +} diff --git a/packages/create-block-interactive-template/block-templates/view.js.mustache b/packages/create-block-interactive-template/block-templates/view.js.mustache new file mode 100644 index 00000000000000..85d74fec190ba5 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/view.js.mustache @@ -0,0 +1,26 @@ +{{#isBasicVariant}} + +/** + * WordPress dependencies + */ +import { store } from "@wordpress/interactivity"; + +store( { + actions: { + '{{namespace}}': { + toggle: ( { context } ) => { + context[ '{{namespace}}' ].isOpen = !context[ '{{namespace}}' ].isOpen; + }, + }, + }, + effects: { + '{{namespace}}': { + logIsOpen: ( { context } ) => { + // Log the value of `isOpen` each time it changes. + console.log( `Is open: ${ context[ '{{namespace}}' ].isOpen }` ); + }, + }, + }, +} ); + +{{/isBasicVariant}} \ No newline at end of file diff --git a/packages/create-block-interactive-template/index.js b/packages/create-block-interactive-template/index.js new file mode 100644 index 00000000000000..5717b3e709723a --- /dev/null +++ b/packages/create-block-interactive-template/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +const { join } = require( 'path' ); + +module.exports = { + defaultValues: { + slug: 'example-interactive', + title: 'Example Interactive', + description: 'An interactive block with the Interactivity API', + dashicon: 'media-interactive', + npmDependencies: [ '@wordpress/interactivity' ], + supports: { + interactivity: true, + }, + render: 'file:./render.php', + viewScript: 'file:./view.js', + example: {}, + }, + variants: { + basic: {}, + }, + pluginTemplatesPath: join( __dirname, 'plugin-templates' ), + blockTemplatesPath: join( __dirname, 'block-templates' ), +}; diff --git a/packages/create-block-interactive-template/package.json b/packages/create-block-interactive-template/package.json new file mode 100644 index 00000000000000..8022c86ecb8a1c --- /dev/null +++ b/packages/create-block-interactive-template/package.json @@ -0,0 +1,25 @@ +{ + "name": "@wordpress/create-block-interactive-template", + "version": "1.3.0", + "description": "Template for @wordpress/create-block to create interactive blocks with the Interactivity API.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "create block", + "block template", + "Interactivity API" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/docs/getting-started/create-block", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/create-block-interactive-template" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache b/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache new file mode 100644 index 00000000000000..2322881ab0d710 --- /dev/null +++ b/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache @@ -0,0 +1,43 @@ +<?php +/** + * Plugin Name: {{title}} +{{#pluginURI}} + * Plugin URI: {{{pluginURI}}} +{{/pluginURI}} +{{#description}} + * Description: {{description}} +{{/description}} + * Version: {{version}} + * Requires at least: 6.1 + * Requires PHP: 7.0 +{{#author}} + * Author: {{author}} +{{/author}} +{{#license}} + * License: {{license}} +{{/license}} +{{#licenseURI}} + * License URI: {{{licenseURI}}} +{{/licenseURI}} + * Text Domain: {{textdomain}} +{{#domainPath}} + * Domain Path: {{{domainPath}}} +{{/domainPath}} +{{#updateURI}} + * Update URI: {{{updateURI}}} +{{/updateURI}} + * + * @package {{namespace}} + */ + +/** + * Registers the block using the metadata loaded from the `block.json` file. + * Behind the scenes, it registers also all assets so they can be enqueued + * through the block editor in the corresponding context. + * + * @see https://developer.wordpress.org/reference/functions/register_block_type/ + */ +function {{namespaceSnakeCase}}_{{slugSnakeCase}}_block_init() { + register_block_type( __DIR__ . '/build' ); +} +add_action( 'init', '{{namespaceSnakeCase}}_{{slugSnakeCase}}_block_init' ); diff --git a/packages/create-block-interactive-template/plugin-templates/readme.txt.mustache b/packages/create-block-interactive-template/plugin-templates/readme.txt.mustache new file mode 100644 index 00000000000000..f069906fb19fa5 --- /dev/null +++ b/packages/create-block-interactive-template/plugin-templates/readme.txt.mustache @@ -0,0 +1,61 @@ +=== {{title}} === +{{#author}} +Contributors: {{author}} +{{/author}} +Tags: block +Tested up to: 6.1 +Stable tag: {{version}} +{{#license}} +License: {{license}} +{{/license}} +{{#licenseURI}} +License URI: {{{licenseURI}}} +{{/licenseURI}} + +{{description}} + +== Description == + +This is the long description. No limit, and you can use Markdown (as well as in the following sections). + +For backwards compatibility, if this section is missing, the full length of the short description will be used, and +Markdown parsed. + +== Installation == + +This section describes how to install the plugin and get it working. + +e.g. + +1. Upload the plugin files to the `/wp-content/plugins/{{slug}}` directory, or install the plugin through the WordPress plugins screen directly. +1. Activate the plugin through the 'Plugins' screen in WordPress + + +== Frequently Asked Questions == + += A question that someone might have = + +An answer to that question. + += What about foo bar? = + +Answer to foo bar dilemma. + +== Screenshots == + +1. This screen shot description corresponds to screenshot-1.(png|jpg|jpeg|gif). Note that the screenshot is taken from +the /assets directory or the directory that contains the stable readme.txt (tags or trunk). Screenshots in the /assets +directory take precedence. For example, `/assets/screenshot-1.png` would win over `/tags/4.3/screenshot-1.png` +(or jpg, jpeg, gif). +2. This is the second screen shot + +== Changelog == + += {{version}} = +* Release + +== Arbitrary section == + +You may provide arbitrary sections, in the same format as the ones above. This may be of use for extremely complicated +plugins where more information needs to be conveyed that doesn't fit into the categories of "description" or +"installation." Arbitrary sections will be shown below the built-in sections outlined above. diff --git a/packages/create-block-tutorial-template/CHANGELOG.md b/packages/create-block-tutorial-template/CHANGELOG.md index 63853addfebb99..8fc38debebc194 100644 --- a/packages/create-block-tutorial-template/CHANGELOG.md +++ b/packages/create-block-tutorial-template/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.28.0 (2023-08-16) + +## 2.27.0 (2023-08-10) + +## 2.26.0 (2023-07-20) + +## 2.25.0 (2023-07-05) + +## 2.24.0 (2023-06-23) + +## 2.23.0 (2023-06-07) + ## 2.22.0 (2023-05-24) ## 2.21.0 (2023-05-10) diff --git a/packages/create-block-tutorial-template/package.json b/packages/create-block-tutorial-template/package.json index 6c09c3bc899314..cb48abc1d4650a 100644 --- a/packages/create-block-tutorial-template/package.json +++ b/packages/create-block-tutorial-template/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block-tutorial-template", - "version": "2.22.0", + "version": "2.28.0", "description": "Template for @wordpress/create-block used in the official WordPress tutorial.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block/CHANGELOG.md b/packages/create-block/CHANGELOG.md index a291f029527c41..af94e7a1400d38 100644 --- a/packages/create-block/CHANGELOG.md +++ b/packages/create-block/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +## 4.24.0 (2023-08-16) + +## 4.23.0 (2023-08-10) + +### Enhancement + +- Add support for the `example` property and add it to the default template ([#52803](https://github.com/WordPress/gutenberg/pull/52803)). + +## 4.22.0 (2023-07-20) + +### Enhancement + +- Add support for the `viewScript` property ([#52612](https://github.com/WordPress/gutenberg/pull/52612)). + +## 4.21.0 (2023-07-05) + +## 4.20.0 (2023-06-23) + +## 4.19.0 (2023-06-07) + ## 4.18.0 (2023-05-24) ## 4.17.0 (2023-05-10) diff --git a/packages/create-block/README.md b/packages/create-block/README.md index c90e4722de7b78..a2d5b314098377 100644 --- a/packages/create-block/README.md +++ b/packages/create-block/README.md @@ -23,7 +23,7 @@ _It is largely inspired by [create-react-app](https://create-react-app.dev/docs/ ## Quick start ```bash -$ npx @wordpress/create-block todo-list +$ npx @wordpress/create-block@latest todo-list $ cd todo-list $ npm start ``` @@ -41,7 +41,7 @@ _(requires `node` version `14.0.0` or above, and `npm` version `6.14.4` or above The `create-block` command generates a project with PHP, JS, and CSS code for registering a block with a WordPress plugin. ```bash -$ npx @wordpress/create-block [options] [slug] +$ npx @wordpress/create-block@latest [options] [slug] ``` ![Demo](https://user-images.githubusercontent.com/699132/103872910-4de15f00-50cf-11eb-8c74-67ca91a8c1a4.gif) @@ -89,13 +89,13 @@ The rest of the configuration is set to all default values unless overridden wit This argument specifies an _external npm package_ as a template. ```bash -$ npx @wordpress/create-block --template my-template-package +$ npx @wordpress/create-block@latest --template my-template-package ``` This argument also allows to pick a _local directory_ as a template. ```bash -$ npx @wordpress/create-block --template ./path/to/template-directory +$ npx @wordpress/create-block@latest --template ./path/to/template-directory ``` #### `--variant` @@ -103,7 +103,7 @@ $ npx @wordpress/create-block --template ./path/to/template-directory With this argument, `create-block` will generate a [dynamic block](https://developer.wordpress.org/block-editor/explanations/glossary/#dynamic-block) based on the built-in template. ```bash -$ npx @wordpress/create-block --variant dynamic +$ npx @wordpress/create-block@latest --variant dynamic ``` #### `--help` @@ -111,7 +111,7 @@ $ npx @wordpress/create-block --variant dynamic With this argument, the `create-block` package outputs usage information. ```bash -$ npx @wordpress/create-block --help +$ npx @wordpress/create-block@latest --help ``` #### `--no-plugin` @@ -119,14 +119,14 @@ $ npx @wordpress/create-block --help With this argument, the `create-block` package runs in _No plugin mode_ which only scaffolds block files into the current directory. ```bash -$ npx @wordpress/create-block --no-plugin +$ npx @wordpress/create-block@latest --no-plugin ``` #### `--wp-env` With this argument, the `create-block` package will add to the generated plugin the configuration and the script to run [`wp-env` package](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) within the plugin. This will allow you to easily set up a local WordPress environment (via Docker) for building and testing the generated plugin. ```bash -$ npx @wordpress/create-block --wp-env +$ npx @wordpress/create-block@latest --wp-env ``` ## Available commands in the scaffolded project diff --git a/packages/create-block/lib/init-block.js b/packages/create-block/lib/init-block.js index 2a83035f9dc6f2..9c0bda20a40097 100644 --- a/packages/create-block/lib/init-block.js +++ b/packages/create-block/lib/init-block.js @@ -30,7 +30,9 @@ async function initBlockJSON( { editorStyle, style, render, + viewScript, customBlockJSON, + example, } ) { info( '' ); info( 'Creating a "block.json" file.' ); @@ -52,6 +54,7 @@ async function initBlockJSON( { category, icon: dashicon, description, + example, attributes, supports, textdomain, @@ -59,6 +62,7 @@ async function initBlockJSON( { editorStyle, style, render, + viewScript, ...customBlockJSON, } ).filter( ( [ , value ] ) => !! value ) ), diff --git a/packages/create-block/lib/scaffold.js b/packages/create-block/lib/scaffold.js index cdeaf85a97bb4a..568ec2f0074579 100644 --- a/packages/create-block/lib/scaffold.js +++ b/packages/create-block/lib/scaffold.js @@ -44,9 +44,11 @@ module.exports = async ( editorStyle, style, render, + viewScript, variantVars, customPackageJSON, customBlockJSON, + example, } ) => { slug = slug.toLowerCase(); @@ -103,8 +105,10 @@ module.exports = async ( editorStyle, style, render, + viewScript, customPackageJSON, customBlockJSON, + example, ...variantVars, }; diff --git a/packages/create-block/lib/templates.js b/packages/create-block/lib/templates.js index 48649527ecc842..29f713499e3f23 100644 --- a/packages/create-block/lib/templates.js +++ b/packages/create-block/lib/templates.js @@ -33,6 +33,8 @@ const predefinedPluginTemplates = { editorScript: null, editorStyle: null, style: null, + viewScript: 'file:./view.js', + example: {}, }, templatesPath: join( __dirname, 'templates', 'es5' ), variants: { @@ -53,6 +55,8 @@ const predefinedPluginTemplates = { supports: { html: false, }, + viewScript: 'file:./view.js', + example: {}, }, variants: { static: {}, @@ -220,7 +224,7 @@ const getPluginTemplate = async ( templateName ) => { const getDefaultValues = ( pluginTemplate, variant ) => { return { $schema: 'https://schemas.wp.org/trunk/block.json', - apiVersion: 2, + apiVersion: 3, namespace: 'create-block', category: 'widgets', author: 'The WordPress Contributors', diff --git a/packages/create-block/lib/templates/block/view.js.mustache b/packages/create-block/lib/templates/block/view.js.mustache new file mode 100644 index 00000000000000..e8ea96c89216ee --- /dev/null +++ b/packages/create-block/lib/templates/block/view.js.mustache @@ -0,0 +1,21 @@ +/** + * Use this file for JavaScript code that you want to run in the front-end + * on posts/pages that contain this block. + * + * When this file is defined as the value of the `viewScript` property + * in `block.json` it will be enqueued on the front end of the site. + * + * Example: + * + * ```js + * { + * "viewScript": "file:./view.js" + * } + * ``` + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#view-script + */ + +/* eslint-disable no-console */ +console.log("Hello World! (from {{namespace}}-{{slug}} block)"); +/* eslint-enable no-console */ diff --git a/packages/create-block/package.json b/packages/create-block/package.json index e26a5b2cb30b5f..4dc0c99fd51c95 100644 --- a/packages/create-block/package.json +++ b/packages/create-block/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block", - "version": "4.18.0", + "version": "4.24.0", "description": "Generates PHP, JS and CSS code for registering a block for a WordPress plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/customize-widgets/CHANGELOG.md b/packages/customize-widgets/CHANGELOG.md index 5bf647a04e44ed..cd20fea8312055 100644 --- a/packages/customize-widgets/CHANGELOG.md +++ b/packages/customize-widgets/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.17.0 (2023-08-16) + +## 4.16.0 (2023-08-10) + +## 4.15.0 (2023-07-20) + +## 4.14.0 (2023-07-05) + +## 4.13.0 (2023-06-23) + +## 4.12.0 (2023-06-07) + ## 4.11.0 (2023-05-24) ## 4.10.0 (2023-05-10) diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json index 699f9d6bc67d53..497879afbf94e9 100644 --- a/packages/customize-widgets/package.json +++ b/packages/customize-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/customize-widgets", - "version": "4.11.0", + "version": "4.17.0", "description": "Widgets blocks in Customizer Module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/customize-widgets/src/components/keyboard-shortcuts/index.js b/packages/customize-widgets/src/components/keyboard-shortcuts/index.js index a83b730ddbb701..b7cdc1d42de863 100644 --- a/packages/customize-widgets/src/components/keyboard-shortcuts/index.js +++ b/packages/customize-widgets/src/components/keyboard-shortcuts/index.js @@ -127,7 +127,7 @@ function KeyboardShortcutsRegister() { } ); registerShortcut( { - name: `core/customize-widgets/transform-heading-to-paragraph`, + name: 'core/customize-widgets/transform-heading-to-paragraph', category: 'block-library', description: __( 'Transform heading to paragraph.' ), keyCombination: { diff --git a/packages/customize-widgets/src/components/sidebar-block-editor/index.js b/packages/customize-widgets/src/components/sidebar-block-editor/index.js index 6a5a734bef9db1..116c8684c89809 100644 --- a/packages/customize-widgets/src/components/sidebar-block-editor/index.js +++ b/packages/customize-widgets/src/components/sidebar-block-editor/index.js @@ -10,9 +10,7 @@ import { BlockSelectionClearer, BlockInspector, CopyHandler, - ObserveTyping, WritingFlow, - BlockEditorKeyboardShortcuts, __unstableBlockSettingsMenuFirstItem, __unstableEditorStyles as EditorStyles, } from '@wordpress/block-editor'; @@ -95,7 +93,6 @@ export default function SidebarBlockEditor( { return ( <> - <BlockEditorKeyboardShortcuts.Register /> <KeyboardShortcuts.Register /> <SidebarEditorProvider sidebar={ sidebar } settings={ settings }> @@ -118,11 +115,7 @@ export default function SidebarBlockEditor( { <EditorStyles styles={ settings.defaultEditorStyles } /> <BlockSelectionClearer> <WritingFlow className="editor-styles-wrapper"> - <ObserveTyping> - <BlockList - renderAppender={ BlockAppender } - /> - </ObserveTyping> + <BlockList renderAppender={ BlockAppender } /> </WritingFlow> </BlockSelectionClearer> </BlockTools> diff --git a/packages/customize-widgets/src/components/sidebar-block-editor/sidebar-editor-provider.js b/packages/customize-widgets/src/components/sidebar-block-editor/sidebar-editor-provider.js index 25b541c3d16dbe..bbdf1ce70a15bf 100644 --- a/packages/customize-widgets/src/components/sidebar-block-editor/sidebar-editor-provider.js +++ b/packages/customize-widgets/src/components/sidebar-block-editor/sidebar-editor-provider.js @@ -9,7 +9,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; import useSidebarBlockEditor from './use-sidebar-block-editor'; import useBlocksFocusControl from '../focus-control/use-blocks-focus-control'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); diff --git a/packages/customize-widgets/src/index.js b/packages/customize-widgets/src/index.js index 6a564f1560cc55..5b438cac86f49b 100644 --- a/packages/customize-widgets/src/index.js +++ b/packages/customize-widgets/src/index.js @@ -49,7 +49,7 @@ export function initialize( editorName, blockEditorSettings ) { welcomeGuide: true, } ); - dispatch( blocksStore ).__experimentalReapplyBlockTypeFilters(); + dispatch( blocksStore ).reapplyBlockTypeFilters(); const coreBlocks = __experimentalGetCoreBlocks().filter( ( block ) => { return ! ( DISABLED_BLOCKS.includes( block.name ) || @@ -100,3 +100,4 @@ export function initialize( editorName, blockEditorSettings ) { ); } ); } +export { store } from './store'; diff --git a/packages/customize-widgets/src/private-apis.js b/packages/customize-widgets/src/lock-unlock.js similarity index 100% rename from packages/customize-widgets/src/private-apis.js rename to packages/customize-widgets/src/lock-unlock.js diff --git a/packages/customize-widgets/src/store/actions.js b/packages/customize-widgets/src/store/actions.js index 30926701e1f26f..844617b4142aa0 100644 --- a/packages/customize-widgets/src/store/actions.js +++ b/packages/customize-widgets/src/store/actions.js @@ -8,6 +8,31 @@ * @param {string} value.rootClientId The root client ID to insert at. * @param {number} value.insertionIndex The index to insert at. * + * @example + * ```js + * import { store as customizeWidgetsStore } from '@wordpress/customize-widgets'; + * import { __ } from '@wordpress/i18n'; + * import { useDispatch } from '@wordpress/data'; + * import { Button } from '@wordpress/components'; + * import { useState } from '@wordpress/element'; + * + * const ExampleComponent = () => { + * const { setIsInserterOpened } = useDispatch( customizeWidgetsStore ); + * const [ isOpen, setIsOpen ] = useState( false ); + * + * return ( + * <Button + * onClick={ () => { + * setIsInserterOpened( ! isOpen ); + * setIsOpen( ! isOpen ); + * } } + * > + * { __( 'Open/close inserter' ) } + * </Button> + * ); + * }; + * ``` + * * @return {Object} Action object. */ export function setIsInserterOpened( value ) { diff --git a/packages/customize-widgets/src/store/selectors.js b/packages/customize-widgets/src/store/selectors.js index 63962af151d15d..5b1fe1aa4898f7 100644 --- a/packages/customize-widgets/src/store/selectors.js +++ b/packages/customize-widgets/src/store/selectors.js @@ -1,8 +1,31 @@ +const EMPTY_INSERTION_POINT = { + rootClientId: undefined, + insertionIndex: undefined, +}; + /** * Returns true if the inserter is opened. * * @param {Object} state Global application state. * + * @example + * ```js + * import { store as customizeWidgetsStore } from '@wordpress/customize-widgets'; + * import { __ } from '@wordpress/i18n'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const { isInserterOpened } = useSelect( + * ( select ) => select( customizeWidgetsStore ), + * [] + * ); + * + * return isInserterOpened() + * ? __( 'Inserter is open' ) + * : __( 'Inserter is closed.' ); + * }; + * ``` + * * @return {boolean} Whether the inserter is opened. */ export function isInserterOpened( state ) { @@ -17,6 +40,9 @@ export function isInserterOpened( state ) { * @return {Object} The root client ID and index to insert at. */ export function __experimentalGetInsertionPoint( state ) { - const { rootClientId, insertionIndex } = state.blockInserterPanel; - return { rootClientId, insertionIndex }; + if ( typeof state.blockInserterPanel === 'boolean' ) { + return EMPTY_INSERTION_POINT; + } + + return state.blockInserterPanel; } diff --git a/packages/customize-widgets/src/style.scss b/packages/customize-widgets/src/style.scss index 3bf341c34c0eb1..bd6d16b89c7fa7 100644 --- a/packages/customize-widgets/src/style.scss +++ b/packages/customize-widgets/src/style.scss @@ -17,3 +17,34 @@ .customize-widgets-popover { @include reset; } + +/** + Fixed bloock toolbar overrides. We can't detect each editor instance + in the styles of the block editor component so we need to override + the fixed styles here because the breakpoint css does not fire in the + customizer's left panel. +*/ +.block-editor-block-contextual-toolbar { + &.is-fixed { + position: sticky; + top: 0; + left: 0; + z-index: z-index(".block-editor-block-list__insertion-point"); + width: calc(100% + 2 * 12px); //12px is the padding of customizer sidebar content + + overflow-y: hidden; + + border: none; + border-bottom: $border-width solid $gray-200; + border-radius: 0; + + .block-editor-block-toolbar .components-toolbar-group, + .block-editor-block-toolbar .components-toolbar { + border-right-color: $gray-200; + } + + &.is-collapsed { + margin-left: -12px; //12px is the padding of customizer sidebar content + } + } +} diff --git a/packages/data-controls/CHANGELOG.md b/packages/data-controls/CHANGELOG.md index 34b0342d7b3956..5cefd056155b9e 100644 --- a/packages/data-controls/CHANGELOG.md +++ b/packages/data-controls/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.9.0 (2023-08-16) + +## 3.8.0 (2023-08-10) + +## 3.7.0 (2023-07-20) + +## 3.6.0 (2023-07-05) + +## 3.5.0 (2023-06-23) + +## 3.4.0 (2023-06-07) + ## 3.3.0 (2023-05-24) ## 3.2.0 (2023-05-10) diff --git a/packages/data-controls/package.json b/packages/data-controls/package.json index 45039c0697d697..71601bb4e08487 100644 --- a/packages/data-controls/package.json +++ b/packages/data-controls/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data-controls", - "version": "3.3.0", + "version": "3.9.0", "description": "A set of common controls for the @wordpress/data api.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -32,6 +32,9 @@ "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated" }, + "peerDependencies": { + "react": "^18.0.0" + }, "publishConfig": { "access": "public" } diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index ee240b974d7060..ba2abd614681fc 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +## 9.10.0 (2023-08-16) + +### Enhancements + +- Warn if the `useSelect` hook returns different values when called with the same state and parameters ([#53666](https://github.com/WordPress/gutenberg/pull/53666)). + +## 9.9.0 (2023-08-10) + +### Bug Fix + +- Update the type definitions for dispatched actions by accounting for Promisified return values and thunks. Previously, a dispatched action's return type was the same as the return type of the original action creator, which did not account for how dispatch works internally. (Plain actions get wrapped in a Promise, and thunk actions ultimately resolve to the innermost function's return type). +- Update the type definition for dispatch() to handle string store descriptors correctly. + +## 9.8.0 (2023-07-20) + +## 9.7.0 (2023-07-05) + +## 9.6.0 (2023-06-23) + +## 9.5.0 (2023-06-07) + ## 9.4.0 (2023-05-24) ## 9.3.0 (2023-05-10) @@ -54,7 +75,7 @@ ### Breaking Changes -– Add TypeScript types to the built package (via "types": "build-types" in the package.json) +– Add TypeScript types to the built package (via "types": "build-types" in the package.json) ### Bug Fix @@ -92,9 +113,9 @@ ### New Features -- Enabled thunks by default for all stores and removed the `__experimentalUseThunks` flag. -- Store the resolution errors in store metadata and expose them using `hasResolutionFailed` the `getResolutionError` meta-selectors ([#38669](https://github.com/WordPress/gutenberg/pull/38669)). -- Expose the resolution status (undefined, resolving, finished, error) via the `getResolutionState` meta-selector ([#38669](https://github.com/WordPress/gutenberg/pull/38669)). +- Enabled thunks by default for all stores and removed the `__experimentalUseThunks` flag. +- Store the resolution errors in store metadata and expose them using `hasResolutionFailed` the `getResolutionError` meta-selectors ([#38669](https://github.com/WordPress/gutenberg/pull/38669)). +- Expose the resolution status (undefined, resolving, finished, error) via the `getResolutionState` meta-selector ([#38669](https://github.com/WordPress/gutenberg/pull/38669)). ## 6.2.1 (2022-02-10) diff --git a/packages/data/README.md b/packages/data/README.md index 444548dfe982ff..8ee5213f7f66c4 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -499,11 +499,11 @@ dispatch( myCustomStore ).setPrice( 'hammer', 9.75 ); _Parameters_ -- _storeNameOrDescriptor_ `string | T`: The store descriptor. The legacy calling convention of passing the store name is also supported. +- _storeNameOrDescriptor_ `StoreNameOrDescriptor`: The store descriptor. The legacy calling convention of passing the store name is also supported. _Returns_ -- `ActionCreatorsOf< ConfigOf< T > >`: Object containing the action creators. +- `DispatchReturn< StoreNameOrDescriptor >`: Object containing the action creators. ### plugins @@ -1028,6 +1028,105 @@ function Component() { } ``` +## Selectors + +The following selectors are available on the object returned by `wp.data.select( 'core' )`. + +_Example_ + +```js +import { store as coreDataStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +function Component() { + const result = useSelect( ( select ) => { + const query = { per_page: 20 }; + const selectorArgs = [ 'postType', 'page', query ]; + + return { + pages: select( coreDataStore ).getEntityRecords( ...selectorArgs ), + hasStartedResolution: select( coreDataStore ).hasStartedResolution( + 'getEntityRecords', // _selectorName_ + selectorArgs + ), + hasFinishedResolution: select( + coreDataStore + ).hasFinishedResolution( 'getEntityRecords', selectorArgs ), + isResolving: select( coreDataStore ).isResolving( + 'getEntityRecords', + selectorArgs + ), + }; + } ); + + if ( result.hasStartedResolution ) { + return <>Fetching data...</>; + } + + if ( result.isResolving ) { + return ( + <> + { + // show a spinner + } + </> + ); + } + + if ( result.hasFinishedResolution ) { + return ( + <> + { + // data is ready + } + </> + ); + } +} +``` + +### hasFinishedResolution + +Returns true if resolution has completed for a given selector name, and arguments set. + +_Parameters_ + +- _state_ `State`: Data state. +- _selectorName_ `string`: Selector name. +- _args_ `unknown[]?`: Arguments passed to selector. + +_Returns_ + +- `boolean`: Whether resolution has completed. + +### hasStartedResolution + +Returns true if resolution has already been triggered for a given selector name, and arguments set. + +_Parameters_ + +- _state_ `State`: Data state. +- _selectorName_ `string`: Selector name. +- _args_ `unknown[]?`: Arguments passed to selector. + +_Returns_ + +- `boolean`: Whether resolution has been triggered. + +### isResolving + +Returns true if resolution has been triggered but has not yet completed for a given selector name, and arguments set. + +_Parameters_ + +- _state_ `State`: Data state. +- _selectorName_ `string`: Selector name. +- _args_ `unknown[]?`: Arguments passed to selector. + +_Returns_ + +- `boolean`: Whether resolution is in progress. + ## Going further - [What is WordPress Data?](https://unfoldingneurons.com/2020/what-is-wordpress-data/) diff --git a/packages/data/package.json b/packages/data/package.json index 9d6e5f7f967557..f6050a3abee341 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data", - "version": "9.4.0", + "version": "9.10.0", "description": "Data module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/data/src/components/use-select/index.js b/packages/data/src/components/use-select/index.js index e1082b50a54657..62da6a005c0d0b 100644 --- a/packages/data/src/components/use-select/index.js +++ b/packages/data/src/components/use-select/index.js @@ -43,6 +43,7 @@ function Store( registry, suspense ) { let lastMapResultValid = false; let lastIsAsync; let subscriber; + let didWarnUnstableReference; const createSubscriber = ( stores ) => { // The set of stores the `subscribe` function is supposed to subscribe to. Here it is @@ -134,6 +135,19 @@ function Store( registry, suspense ) { listeningStores ); + if ( process.env.NODE_ENV === 'development' ) { + if ( ! didWarnUnstableReference ) { + const secondMapResult = mapSelect( select, registry ); + if ( ! isShallowEqual( mapResult, secondMapResult ) ) { + // eslint-disable-next-line no-console + console.warn( + `The 'useSelect' hook returns different values when called with the same state and parameters. This can lead to unnecessary rerenders.` + ); + didWarnUnstableReference = true; + } + } + } + if ( ! subscriber ) { subscriber = createSubscriber( listeningStores.current ); } else { diff --git a/packages/data/src/dispatch.ts b/packages/data/src/dispatch.ts index e75eb8eb7bffcb..c1f28c21187cce 100644 --- a/packages/data/src/dispatch.ts +++ b/packages/data/src/dispatch.ts @@ -1,12 +1,7 @@ /** * Internal dependencies */ -import type { - ActionCreatorsOf, - AnyConfig, - ConfigOf, - StoreDescriptor, -} from './types'; +import type { AnyConfig, StoreDescriptor, DispatchReturn } from './types'; import defaultRegistry from './default-registry'; /** @@ -28,8 +23,10 @@ import defaultRegistry from './default-registry'; * ``` * @return Object containing the action creators. */ -export function dispatch< T extends StoreDescriptor< AnyConfig > >( - storeNameOrDescriptor: string | T -): ActionCreatorsOf< ConfigOf< T > > { +export function dispatch< + StoreNameOrDescriptor extends StoreDescriptor< AnyConfig > | string +>( + storeNameOrDescriptor: StoreNameOrDescriptor +): DispatchReturn< StoreNameOrDescriptor > { return defaultRegistry.dispatch( storeNameOrDescriptor ); } diff --git a/packages/data/src/factory.js b/packages/data/src/factory.js index 046836dd6d0332..a4adc4dcf42517 100644 --- a/packages/data/src/factory.js +++ b/packages/data/src/factory.js @@ -47,7 +47,7 @@ export function createRegistrySelector( registrySelector ) { /** * Flag indicating that the selector is a registry selector that needs the correct registry - * reference to be assigned to `selecto.registry` to make it work correctly. + * reference to be assigned to `selector.registry` to make it work correctly. * be mapped as a registry selector. * * @type {boolean} diff --git a/packages/data/src/private-apis.js b/packages/data/src/lock-unlock.js similarity index 100% rename from packages/data/src/private-apis.js rename to packages/data/src/lock-unlock.js diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js index e87bb8ead1115c..a1bf44a7aff696 100644 --- a/packages/data/src/plugins/persistence/index.js +++ b/packages/data/src/plugins/persistence/index.js @@ -21,7 +21,6 @@ import { combineReducers } from '../../'; * at least implement `getItem` and `setItem` of * the Web Storage API. * @property {string} storageKey Key on which to set in persistent storage. - * */ /** diff --git a/packages/data/src/redux-store/index.js b/packages/data/src/redux-store/index.js index 50a0ade0d551c9..43357b766f618e 100644 --- a/packages/data/src/redux-store/index.js +++ b/packages/data/src/redux-store/index.js @@ -15,7 +15,7 @@ import { compose } from '@wordpress/compose'; * Internal dependencies */ import { builtinControls } from '../controls'; -import { lock } from '../private-apis'; +import { lock } from '../lock-unlock'; import promise from '../promise-middleware'; import createResolversCacheMiddleware from '../resolvers-cache-middleware'; import createThunkMiddleware from './thunk-middleware'; @@ -24,6 +24,7 @@ import * as metadataSelectors from './metadata/selectors'; import * as metadataActions from './metadata/actions'; /** @typedef {import('../types').DataRegistry} DataRegistry */ +/** @typedef {import('../types').ListenerFunction} ListenerFunction */ /** * @typedef {import('../types').StoreDescriptor<C>} StoreDescriptor * @template {import('../types').AnyConfig} C @@ -54,12 +55,11 @@ const trimUndefinedValues = ( array ) => { * @return {Array} Transformed object. */ const mapValues = ( obj, callback ) => - Object.entries( obj ?? {} ).reduce( - ( acc, [ key, value ] ) => ( { - ...acc, - [ key ]: callback( value, key ), - } ), - {} + Object.fromEntries( + Object.entries( obj ?? {} ).map( ( [ key, value ] ) => [ + key, + callback( value, key ), + ] ) ); // Convert Map objects to plain objects @@ -102,6 +102,21 @@ function createResolversCache() { }; } +function createBindingCache( bind ) { + const cache = new WeakMap(); + + return { + get( item, itemName ) { + let boundItem = cache.get( item ); + if ( ! boundItem ) { + boundItem = bind( item, itemName ); + cache.set( item, boundItem ); + } + return boundItem; + }, + }; +} + /** * Creates a data store descriptor for the provided Redux store configuration containing * properties describing reducer, actions, selectors, controls and resolvers. @@ -144,21 +159,27 @@ export default function createReduxStore( key, options ) { const storeDescriptor = { name: key, instantiate: ( registry ) => { + /** + * Stores listener functions registered with `subscribe()`. + * + * When functions register to listen to store changes with + * `subscribe()` they get added here. Although Redux offers + * its own `subscribe()` function directly, by wrapping the + * subscription in this store instance it's possible to + * optimize checking if the state has changed before calling + * each listener. + * + * @type {Set<ListenerFunction>} + */ + const listeners = new Set(); const reducer = options.reducer; const thunkArgs = { registry, get dispatch() { - return Object.assign( - ( action ) => store.dispatch( action ), - getActions() - ); + return thunkActions; }, get select() { - return Object.assign( - ( selector ) => - selector( store.__unstableOriginalGetState() ), - getSelectors() - ); + return thunkSelectors; }, get resolveSelect() { return getResolveSelectors(); @@ -176,77 +197,101 @@ export default function createReduxStore( key, options ) { lock( store, privateRegistrationFunctions ); const resolversCache = createResolversCache(); - let resolvers; - const actions = mapActions( - { - ...metadataActions, - ...options.actions, - }, - store - ); - lock( - actions, - new Proxy( privateActions, { - get: ( target, prop ) => { - return ( - mapActions( privateActions, store )[ prop ] || - actions[ prop ] - ); - }, - } ) - ); + function bindAction( action ) { + return ( ...args ) => + Promise.resolve( store.dispatch( action( ...args ) ) ); + } + + const actions = { + ...mapValues( metadataActions, bindAction ), + ...mapValues( options.actions, bindAction ), + }; - let selectors = mapSelectors( - { - ...mapValues( - metadataSelectors, - ( selector ) => - ( state, ...args ) => - selector( state.metadata, ...args ) - ), - ...mapValues( options.selectors, ( selector ) => { - if ( selector.isRegistrySelector ) { - selector.registry = registry; - } - - return ( state, ...args ) => - selector( state.root, ...args ); - } ), + const boundPrivateActions = createBindingCache( bindAction ); + const allActions = new Proxy( () => {}, { + get: ( target, prop ) => { + const privateAction = privateActions[ prop ]; + return privateAction + ? boundPrivateActions.get( privateAction, prop ) + : actions[ prop ]; }, - store - ); - lock( - selectors, - new Proxy( privateSelectors, { - get: ( target, prop ) => { - return ( - mapSelectors( - mapValues( privateSelectors, ( selector ) => { - if ( selector.isRegistrySelector ) { - selector.registry = registry; - } - - return ( state, ...args ) => - selector( state.root, ...args ); - } ), - store - )[ prop ] || selectors[ prop ] - ); - }, - } ) - ); + } ); + + const thunkActions = new Proxy( allActions, { + apply: ( target, thisArg, [ action ] ) => + store.dispatch( action ), + } ); + + lock( actions, allActions ); + + const resolvers = options.resolvers + ? mapResolvers( options.resolvers ) + : {}; + + function bindSelector( selector, selectorName ) { + if ( selector.isRegistrySelector ) { + selector.registry = registry; + } + const boundSelector = ( ...args ) => { + const state = store.__unstableOriginalGetState(); + return selector( state.root, ...args ); + }; + + const resolver = resolvers[ selectorName ]; + if ( ! resolver ) { + boundSelector.hasResolver = false; + return boundSelector; + } - if ( options.resolvers ) { - const result = mapResolvers( - options.resolvers, - selectors, + return mapSelectorWithResolver( + boundSelector, + selectorName, + resolver, store, resolversCache ); - resolvers = result.resolvers; - selectors = result.selectors; } + function bindMetadataSelector( selector ) { + const boundSelector = ( ...args ) => { + const state = store.__unstableOriginalGetState(); + return selector( state.metadata, ...args ); + }; + boundSelector.hasResolver = false; + return boundSelector; + } + + const selectors = { + ...mapValues( metadataSelectors, bindMetadataSelector ), + ...mapValues( options.selectors, bindSelector ), + }; + + const boundPrivateSelectors = createBindingCache( bindSelector ); + + // Pre-bind the private selectors that have been registered by the time of + // instantiation, so that registry selectors are bound to the registry. + for ( const [ selectorName, selector ] of Object.entries( + privateSelectors + ) ) { + boundPrivateSelectors.get( selector, selectorName ); + } + + const allSelectors = new Proxy( () => {}, { + get: ( target, prop ) => { + const privateSelector = privateSelectors[ prop ]; + return privateSelector + ? boundPrivateSelectors.get( privateSelector, prop ) + : selectors[ prop ]; + }, + } ); + + const thunkSelectors = new Proxy( allSelectors, { + apply: ( target, thisArg, [ selector ] ) => + selector( store.__unstableOriginalGetState() ), + } ); + + lock( selectors, allSelectors ); + const resolveSelectors = mapResolveSelectors( selectors, store ); const suspendSelectors = mapSuspendSelectors( selectors, store ); @@ -266,18 +311,24 @@ export default function createReduxStore( key, options ) { const subscribe = store && ( ( listener ) => { - let lastState = store.__unstableOriginalGetState(); - return store.subscribe( () => { - const state = store.__unstableOriginalGetState(); - const hasChanged = state !== lastState; - lastState = state; - - if ( hasChanged ) { - listener(); - } - } ); + listeners.add( listener ); + + return () => listeners.delete( listener ); } ); + let lastState = store.__unstableOriginalGetState(); + store.subscribe( () => { + const state = store.__unstableOriginalGetState(); + const hasChanged = state !== lastState; + lastState = state; + + if ( hasChanged ) { + for ( const listener of listeners ) { + listener(); + } + } + } ); + // This can be simplified to just { subscribe, getSelectors, getActions } // Once we remove the use function. return { @@ -360,59 +411,6 @@ function instantiateReduxStore( key, options, registry, thunkArgs ) { ); } -/** - * Maps selectors to a store. - * - * @param {Object} selectors Selectors to register. Keys will be used as the - * public facing API. Selectors will get passed the - * state as first argument. - * @param {Object} store The store to which the selectors should be mapped. - * @return {Object} Selectors mapped to the provided store. - */ -function mapSelectors( selectors, store ) { - const createStateSelector = ( registrySelector ) => { - const selector = function runSelector() { - // This function is an optimized implementation of: - // - // selector( store.getState(), ...arguments ) - // - // Where the above would incur an `Array#concat` in its application, - // the logic here instead efficiently constructs an arguments array via - // direct assignment. - const argsLength = arguments.length; - const args = new Array( argsLength + 1 ); - args[ 0 ] = store.__unstableOriginalGetState(); - for ( let i = 0; i < argsLength; i++ ) { - args[ i + 1 ] = arguments[ i ]; - } - - return registrySelector( ...args ); - }; - selector.hasResolver = false; - return selector; - }; - - return mapValues( selectors, createStateSelector ); -} - -/** - * Maps actions to dispatch from a given store. - * - * @param {Object} actions Actions to register. - * @param {Object} store The redux store to which the actions should be mapped. - * - * @return {Object} Actions mapped to the redux store provided. - */ -function mapActions( actions, store ) { - const createBoundAction = - ( action ) => - ( ...args ) => { - return Promise.resolve( store.dispatch( action( ...args ) ) ); - }; - - return mapValues( actions, createBoundAction ); -} - /** * Maps selectors to functions that return a resolution promise for them * @@ -431,6 +429,7 @@ function mapResolveSelectors( selectors, store ) { getCachedResolvers, getResolutionState, getResolutionError, + hasResolvingSelectors, ...storeSelectors } = selectors; @@ -519,20 +518,13 @@ function mapSuspendSelectors( selectors, store ) { } /** - * Returns resolvers with matched selectors for a given namespace. - * Resolvers are side effects invoked once per argument set of a given selector call, - * used in ensuring that the data needs for the selector are satisfied. + * Convert resolvers to a normalized form, an object with `fulfill` method and + * optional methods like `isFulfilled`. * - * @param {Object} resolvers Resolvers to register. - * @param {Object} selectors The current selectors to be modified. - * @param {Object} store The redux store to which the resolvers should be mapped. - * @param {Object} resolversCache Resolvers Cache. + * @param {Object} resolvers Resolver to convert */ -function mapResolvers( resolvers, selectors, store, resolversCache ) { - // The `resolver` can be either a function that does the resolution, or, in more advanced - // cases, an object with a `fullfill` method and other optional methods like `isFulfilled`. - // Here we normalize the `resolver` function to an object with `fulfill` method. - const mappedResolvers = mapValues( resolvers, ( resolver ) => { +function mapResolvers( resolvers ) { + return mapValues( resolvers, ( resolver ) => { if ( resolver.fulfill ) { return resolver; } @@ -542,99 +534,76 @@ function mapResolvers( resolvers, selectors, store, resolversCache ) { fulfill: resolver, // Add the fulfill method. }; } ); +} - const mapSelector = ( selector, selectorName ) => { - const resolver = resolvers[ selectorName ]; - if ( ! resolver ) { - selector.hasResolver = false; - return selector; +/** + * Returns a selector with a matched resolver. + * Resolvers are side effects invoked once per argument set of a given selector call, + * used in ensuring that the data needs for the selector are satisfied. + * + * @param {Object} selector The selector function to be bound. + * @param {string} selectorName The selector name. + * @param {Object} resolver Resolver to call. + * @param {Object} store The redux store to which the resolvers should be mapped. + * @param {Object} resolversCache Resolvers Cache. + */ +function mapSelectorWithResolver( + selector, + selectorName, + resolver, + store, + resolversCache +) { + function fulfillSelector( args ) { + const state = store.getState(); + + if ( + resolversCache.isRunning( selectorName, args ) || + ( typeof resolver.isFulfilled === 'function' && + resolver.isFulfilled( state, ...args ) ) + ) { + return; } - const selectorResolver = ( ...args ) => { - async function fulfillSelector() { - const state = store.getState(); + const { metadata } = store.__unstableOriginalGetState(); - if ( - resolversCache.isRunning( selectorName, args ) || - ( typeof resolver.isFulfilled === 'function' && - resolver.isFulfilled( state, ...args ) ) - ) { - return; - } + if ( + metadataSelectors.hasStartedResolution( + metadata, + selectorName, + args + ) + ) { + return; + } - const { metadata } = store.__unstableOriginalGetState(); + resolversCache.markAsRunning( selectorName, args ); - if ( - metadataSelectors.hasStartedResolution( - metadata, - selectorName, - args - ) - ) { - return; + setTimeout( async () => { + resolversCache.clear( selectorName, args ); + store.dispatch( + metadataActions.startResolution( selectorName, args ) + ); + try { + const action = resolver.fulfill( ...args ); + if ( action ) { + await store.dispatch( action ); } - - resolversCache.markAsRunning( selectorName, args ); - - setTimeout( async () => { - resolversCache.clear( selectorName, args ); - store.dispatch( - metadataActions.startResolution( selectorName, args ) - ); - try { - await fulfillResolver( - store, - mappedResolvers, - selectorName, - ...args - ); - store.dispatch( - metadataActions.finishResolution( - selectorName, - args - ) - ); - } catch ( error ) { - store.dispatch( - metadataActions.failResolution( - selectorName, - args, - error - ) - ); - } - } ); + store.dispatch( + metadataActions.finishResolution( selectorName, args ) + ); + } catch ( error ) { + store.dispatch( + metadataActions.failResolution( selectorName, args, error ) + ); } - - fulfillSelector( ...args ); - return selector( ...args ); - }; - selectorResolver.hasResolver = true; - return selectorResolver; - }; - - return { - resolvers: mappedResolvers, - selectors: mapValues( selectors, mapSelector ), - }; -} - -/** - * Calls a resolver given arguments - * - * @param {Object} store Store reference, for fulfilling via resolvers - * @param {Object} resolvers Store Resolvers - * @param {string} selectorName Selector name to fulfill. - * @param {Array} args Selector Arguments. - */ -async function fulfillResolver( store, resolvers, selectorName, ...args ) { - const resolver = resolvers[ selectorName ]; - if ( ! resolver ) { - return; + }, 0 ); } - const action = resolver.fulfill( ...args ); - if ( action ) { - await store.dispatch( action ); - } + const selectorResolver = ( ...args ) => { + fulfillSelector( args ); + return selector( ...args ); + }; + selectorResolver.hasResolver = true; + return selectorResolver; } diff --git a/packages/data/src/redux-store/test/index.js b/packages/data/src/redux-store/test/index.js index e3cc2c727dbc5f..daca128480daeb 100644 --- a/packages/data/src/redux-store/test/index.js +++ b/packages/data/src/redux-store/test/index.js @@ -281,7 +281,6 @@ describe( 'resolveSelect', () => { it( 'returns only store native selectors and excludes all meta ones', () => { expect( Object.keys( registry.resolveSelect( 'store' ) ) ).toEqual( [ - 'hasResolvingSelectors', 'getItems', 'getItemsNoResolver', ] ); diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index 7a913da488f46e..02a0b19136a3e0 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -9,7 +9,7 @@ import deprecated from '@wordpress/deprecated'; import createReduxStore from './redux-store'; import coreDataStore from './store'; import { createEmitter } from './utils/emitter'; -import { lock, unlock } from './private-apis'; +import { lock, unlock } from './lock-unlock'; /** @typedef {import('./types').StoreDescriptor} StoreDescriptor */ @@ -314,6 +314,12 @@ export function createRegistry( storeConfigs = {}, parent = null ) { } function batch( callback ) { + // If we're already batching, just call the callback. + if ( emitter.isPaused ) { + callback(); + return; + } + emitter.pause(); Object.values( stores ).forEach( ( store ) => store.emitter.pause() ); callback(); diff --git a/packages/data/src/test/privateAPIs.js b/packages/data/src/test/privateAPIs.js index 93df00d287d600..22118ec58caa1d 100644 --- a/packages/data/src/test/privateAPIs.js +++ b/packages/data/src/test/privateAPIs.js @@ -3,7 +3,8 @@ */ import { createRegistry } from '../registry'; import createReduxStore from '../redux-store'; -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; +import { createRegistrySelector } from '../factory'; describe( 'Private data APIs', () => { let registry; @@ -32,23 +33,19 @@ describe( 'Private data APIs', () => { getState: ( state ) => state, }, actions: { setPublicPrice }, - reducer: ( state, action ) => { - if ( action?.type === 'SET_PRIVATE_PRICE' ) { + reducer: ( state = { price: 1000, secretDiscount: 800 }, action ) => { + if ( action.type === 'SET_PRIVATE_PRICE' ) { return { ...state, - secretDiscount: action?.price, + secretDiscount: action.price, }; - } else if ( action?.type === 'SET_PUBLIC_PRICE' ) { + } else if ( action.type === 'SET_PUBLIC_PRICE' ) { return { ...state, - price: action?.price, + price: action.price, }; } - return { - price: 1000, - secretDiscount: 800, - ...( state || {} ), - }; + return state; }, }; function createStore() { @@ -96,6 +93,37 @@ describe( 'Private data APIs', () => { ); } ); + it( 'should support combination of private selectors and resolvers', async () => { + const testStore = createReduxStore( 'test', { + reducer: ( state = {}, action ) => { + if ( action.type === 'RECEIVE' ) { + return { ...state, [ action.key ]: action.value }; + } + return state; + }, + selectors: { + get: ( state, key ) => state[ key ], + }, + resolvers: { + get: + ( key ) => + async ( { dispatch } ) => { + const value = await ( 'resolved-' + key ); + dispatch( { type: 'RECEIVE', key, value } ); + }, + }, + } ); + unlock( testStore ).registerPrivateSelectors( { + peek: ( state, key ) => state[ key ], + } ); + registry.register( testStore ); + + await registry.resolveSelect( testStore ).get( 'x' ); + expect( unlock( registry.select( testStore ) ).peek( 'x' ) ).toBe( + 'resolved-x' + ); + } ); + it( 'should give private selectors access to the state', () => { const groceryStore = createStore(); unlock( groceryStore ).registerPrivateSelectors( { @@ -116,6 +144,16 @@ describe( 'Private data APIs', () => { expect( unlockedSelectors.getPublicPrice() ).toEqual( 1000 ); } ); + it( 'should return stable references to selectors', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + const select = unlock( registry.select( groceryStore ) ); + expect( select.getPublicPrice ).toBe( select.getPublicPrice ); + expect( select.getSecretDiscount ).toBe( select.getSecretDiscount ); + } ); + it( 'should support registerStore', () => { const groceryStore = registry.registerStore( storeName, @@ -164,6 +202,42 @@ describe( 'Private data APIs', () => { ); expect( subPrivateSelectors.getSecretDiscount() ).toEqual( 800 ); } ); + + it( 'should support private registry selectors', () => { + const groceryStore = createStore(); + const otherStore = createReduxStore( 'other', { + reducer: ( state = {} ) => state, + } ); + unlock( otherStore ).registerPrivateSelectors( { + getPrice: createRegistrySelector( + ( select ) => () => select( groceryStore ).getPublicPrice() + ), + } ); + registry.register( otherStore ); + const privateSelectors = unlock( registry.select( otherStore ) ); + expect( privateSelectors.getPrice() ).toEqual( 1000 ); + } ); + + it( 'should support calling a private registry selector from a public selector', () => { + const groceryStore = createStore(); + const getPriceWithShipping = createRegistrySelector( + ( select ) => () => select( groceryStore ).getPublicPrice() + 5 + ); + const store = createReduxStore( 'a', { + reducer: ( state = {} ) => state, + selectors: { + getPriceWithShippingAndTax: ( state ) => + getPriceWithShipping( state ) * 1.1, + }, + } ); + unlock( store ).registerPrivateSelectors( { + getPriceWithShipping, + } ); + registry.register( store ); + expect( + registry.select( store ).getPriceWithShippingAndTax() + ).toEqual( 1105.5 ); + } ); } ); describe( 'private actions', () => { @@ -232,6 +306,16 @@ describe( 'Private data APIs', () => { ).toEqual( 400 ); } ); + it( 'should return stable references to actions', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + } ); + const disp = unlock( registry.dispatch( groceryStore ) ); + expect( disp.setPublicPrice ).toBe( disp.setPublicPrice ); + expect( disp.setSecretDiscount ).toBe( disp.setSecretDiscount ); + } ); + it( 'should dispatch public actions on the unlocked store', () => { const groceryStore = createStore(); unlock( groceryStore ).registerPrivateActions( { @@ -263,6 +347,29 @@ describe( 'Private data APIs', () => { ).toEqual( 100 ); } ); + it( 'should expose unlocked private selectors and actions to thunks', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + doubleSecretDiscount() { + return ( { dispatch, select } ) => { + dispatch.setSecretDiscount( + select.getSecretDiscount() * 2 + ); + }; + }, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + privateActions.setSecretDiscount( 100 ); + privateActions.doubleSecretDiscount(); + expect( + unlock( registry.select( groceryStore ) ).getSecretDiscount() + ).toEqual( 200 ); + } ); + it( 'should support registerStore', () => { const groceryStore = registry.registerStore( storeName, diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js index b9288eae821d8a..df9cb774dfc8cf 100644 --- a/packages/data/src/test/registry.js +++ b/packages/data/src/test/registry.js @@ -734,6 +734,27 @@ describe( 'createRegistry', () => { unsubscribe(); expect( listener2 ).toHaveBeenCalledTimes( 1 ); } ); + + it( 'should support nested batches', () => { + const store = registry.registerStore( 'myAwesomeReducer', { + reducer: ( state = 0 ) => state + 1, + } ); + const listener = jest.fn(); + subscribeWithUnsubscribe( listener ); + + registry.batch( () => {} ); + expect( listener ).not.toHaveBeenCalled(); + + registry.batch( () => { + store.dispatch( { type: 'dummy' } ); + registry.batch( () => { + store.dispatch( { type: 'dummy' } ); + store.dispatch( { type: 'dummy' } ); + } ); + store.dispatch( { type: 'dummy' } ); + } ); + expect( listener ).toHaveBeenCalledTimes( 1 ); + } ); } ); describe( 'use', () => { diff --git a/packages/data/src/types.ts b/packages/data/src/types.ts index fe7d9061d3c5d8..af8cf823852755 100644 --- a/packages/data/src/types.ts +++ b/packages/data/src/types.ts @@ -6,7 +6,7 @@ import type { combineReducers as reduxCombineReducers } from 'redux'; type MapOf< T > = { [ name: string ]: T }; -export type ActionCreator = Function | Generator; +export type ActionCreator = ( ...args: any[] ) => any | Generator; export type Resolver = Function | Generator; export type Selector = Function; @@ -43,6 +43,7 @@ export interface ReduxStoreConfig< controls?: MapOf< Function >; } +// Return type for the useSelect() hook. export type UseSelectReturn< F extends MapSelect | StoreDescriptor< any > > = F extends MapSelect ? ReturnType< F > @@ -50,6 +51,7 @@ export type UseSelectReturn< F extends MapSelect | StoreDescriptor< any > > = ? CurriedSelectorsOf< F > : never; +// Return type for the useDispatch() hook. export type UseDispatchReturn< StoreNameOrDescriptor > = StoreNameOrDescriptor extends StoreDescriptor< any > ? ActionCreatorsOf< ConfigOf< StoreNameOrDescriptor > > @@ -59,9 +61,12 @@ export type UseDispatchReturn< StoreNameOrDescriptor > = export type DispatchFunction = < StoreNameOrDescriptor >( store: StoreNameOrDescriptor -) => StoreNameOrDescriptor extends StoreDescriptor< any > - ? ActionCreatorsOf< ConfigOf< StoreNameOrDescriptor > > - : any; +) => DispatchReturn< StoreNameOrDescriptor >; + +export type DispatchReturn< StoreNameOrDescriptor > = + StoreNameOrDescriptor extends StoreDescriptor< any > + ? ActionCreatorsOf< ConfigOf< StoreNameOrDescriptor > > + : unknown; export type MapSelect = ( select: SelectFunction, @@ -70,6 +75,12 @@ export type MapSelect = ( export type SelectFunction = < S >( store: S ) => CurriedSelectorsOf< S >; +/** + * Callback for store's `subscribe()` method that + * runs when the store data has changed. + */ +export type ListenerFunction = () => void; + export type CurriedSelectorsOf< S > = S extends StoreDescriptor< ReduxStoreConfig< any, any, infer Selectors > > @@ -164,9 +175,37 @@ export type ConfigOf< S > = S extends StoreDescriptor< infer C > ? C : never; export type ActionCreatorsOf< Config extends AnyConfig > = Config extends ReduxStoreConfig< any, infer ActionCreators, any > - ? ActionCreators + ? PromisifiedActionCreators< ActionCreators > : never; +// Takes an object containing all action creators for a store and updates the +// return type of each action creator to account for internal registry details -- +// for example, dispatched actions are wrapped with a Promise. +export type PromisifiedActionCreators< + ActionCreators extends MapOf< ActionCreator > +> = { + [ Action in keyof ActionCreators ]: PromisifyActionCreator< + ActionCreators[ Action ] + >; +}; + +// Wraps action creator return types with a Promise and handles thunks. +export type PromisifyActionCreator< Action extends ActionCreator > = ( + ...args: Parameters< Action > +) => Promise< + ReturnType< Action > extends ( ..._args: any[] ) => any + ? ThunkReturnType< Action > + : ReturnType< Action > +>; + +// A thunk is an action creator which returns a function, which can optionally +// return a Promise. The double ReturnType unwraps the innermost function's +// return type, and Awaited gets the type the Promise resolves to. If the return +// type is not a Promise, Awaited returns that original type. +export type ThunkReturnType< Action extends ActionCreator > = Awaited< + ReturnType< ReturnType< Action > > +>; + type SelectorsOf< Config extends AnyConfig > = Config extends ReduxStoreConfig< any, any, diff --git a/packages/date/CHANGELOG.md b/packages/date/CHANGELOG.md index 1cccbad472132a..1ef50cd50cdee2 100644 --- a/packages/date/CHANGELOG.md +++ b/packages/date/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.40.0 (2023-08-16) + +## 4.39.0 (2023-08-10) + +## 4.38.0 (2023-07-20) + +## 4.37.0 (2023-07-05) + +## 4.36.0 (2023-06-23) + +## 4.35.0 (2023-06-07) + ## 4.34.0 (2023-05-24) ## 4.33.0 (2023-05-10) diff --git a/packages/date/package.json b/packages/date/package.json index 93df3a7be3b2ef..64f21a4b286fab 100644 --- a/packages/date/package.json +++ b/packages/date/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/date", - "version": "4.34.0", + "version": "4.40.0", "description": "Date module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/date/src/test/index.js b/packages/date/src/test/index.js index ff82748e02f23a..3c52aae1fbd0df 100644 --- a/packages/date/src/test/index.js +++ b/packages/date/src/test/index.js @@ -623,7 +623,7 @@ describe( 'Moment.js Localization', () => { } ); describe( 'humanTimeDiff', () => { - it( 'should return human readable time differences', () => { + it( 'should return human readable time differences in the past', () => { expect( humanTimeDiff( '2023-04-28T11:00:00.000Z', @@ -643,5 +643,20 @@ describe( 'Moment.js Localization', () => { ) ).toBe( '2 days ago' ); } ); + + it( 'should return human readable time differences in the future', () => { + // Future. + const now = new Date(); + const twoHoursLater = new Date( + now.getTime() + 2 * 60 * 60 * 1000 + ); + expect( humanTimeDiff( twoHoursLater ) ).toBe( 'in 2 hours' ); + + const twoDaysLater = new Date( + now.getTime() + 2 * 24 * 60 * 60 * 1000 + ); // Adding 2 days in milliseconds + + expect( humanTimeDiff( twoDaysLater ) ).toBe( 'in 2 days' ); + } ); } ); } ); diff --git a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md index d01cbc37bf54b6..b5d33757b0e297 100644 --- a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md +++ b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.23.0 (2023-08-16) + +## 4.22.0 (2023-08-10) + +## 4.21.0 (2023-07-20) + +## 4.20.0 (2023-07-05) + +## 4.19.0 (2023-06-23) + +## 4.18.0 (2023-06-07) + ## 4.17.0 (2023-05-24) ## 4.16.0 (2023-05-10) diff --git a/packages/dependency-extraction-webpack-plugin/lib/index.js b/packages/dependency-extraction-webpack-plugin/lib/index.js index 3da2286ddbd57d..581274c3684f93 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/index.js +++ b/packages/dependency-extraction-webpack-plugin/lib/index.js @@ -27,7 +27,6 @@ class DependencyExtractionWebpackPlugin { combinedOutputFile: null, externalizedReport: false, injectPolyfill: false, - __experimentalInjectInteractivityRuntime: false, outputFormat: 'php', outputFilename: null, useDefaults: true, @@ -143,7 +142,6 @@ class DependencyExtractionWebpackPlugin { combinedOutputFile, externalizedReport, injectPolyfill, - __experimentalInjectInteractivityRuntime, outputFormat, outputFilename, } = this.options; @@ -186,14 +184,6 @@ class DependencyExtractionWebpackPlugin { if ( injectPolyfill ) { chunkDeps.add( 'wp-polyfill' ); } - // Temporary fix for Interactivity API until it gets moved to its package. - if ( __experimentalInjectInteractivityRuntime ) { - if ( ! chunkJSFile.startsWith( './interactivity/' ) ) { - chunkDeps.add( 'wp-interactivity-runtime' ); - } else if ( './interactivity/runtime.min.js' === chunkJSFile ) { - chunkDeps.add( 'wp-interactivity-vendors' ); - } - } const processModule = ( { userRequest } ) => { if ( this.externalizedDeps.has( userRequest ) ) { diff --git a/packages/dependency-extraction-webpack-plugin/package.json b/packages/dependency-extraction-webpack-plugin/package.json index 2261413fd3b335..874c28165c56f3 100644 --- a/packages/dependency-extraction-webpack-plugin/package.json +++ b/packages/dependency-extraction-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dependency-extraction-webpack-plugin", - "version": "4.17.0", + "version": "4.23.0", "description": "Extract WordPress script dependencies from webpack bundles.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap index 42102ca2cb1322..0e4ff9e63a10a6 100644 --- a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap +++ b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DependencyExtractionWebpackPlugin Webpack \`combine-assets\` should produce expected output: Asset file 'assets.php' should match snapshot 1`] = ` -"<?php return array('fileA.js' => array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'cf268e19006bef774112'), 'fileB.js' => array('dependencies' => array('wp-token-list'), 'version' => '7f3970305cf0aecb54ab')); +"<?php return array('fileA.js' => array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'cbe985cf6e1a25d848e5'), 'fileB.js' => array('dependencies' => array('wp-token-list'), 'version' => '7f3970305cf0aecb54ab')); " `; @@ -32,7 +32,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`combine-assets\` should pro `; exports[`DependencyExtractionWebpackPlugin Webpack \`dynamic-import\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('wp-blob'), 'version' => 'c8be4fceac30d1d00ca7'); +"<?php return array('dependencies' => array('wp-blob'), 'version' => 'b0e5d8b4c38533765be8'); " `; @@ -50,7 +50,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`dynamic-import\` should pro `; exports[`DependencyExtractionWebpackPlugin Webpack \`function-output-filename\` should produce expected output: Asset file 'chunk--main--main.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '9b7ebe61044661fdabda'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'fc2d750fc9e08c5749db'); " `; @@ -73,7 +73,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`function-output-filename\` `; exports[`DependencyExtractionWebpackPlugin Webpack \`has-extension-suffix\` should produce expected output: Asset file 'index.min.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '49dba68ef238f954b358'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'c3f17b34fdd974d57d0f'); " `; @@ -96,7 +96,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`has-extension-suffix\` shou `; exports[`DependencyExtractionWebpackPlugin Webpack \`no-default\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array(), 'version' => 'f7e2cb527e601f74f8bd'); +"<?php return array('dependencies' => array(), 'version' => '43880e6c42e7c39fcdf1'); " `; @@ -110,7 +110,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`no-deps\` should produce ex exports[`DependencyExtractionWebpackPlugin Webpack \`no-deps\` should produce expected output: External modules should match snapshot 1`] = `[]`; exports[`DependencyExtractionWebpackPlugin Webpack \`option-function-output-filename\` should produce expected output: Asset file 'chunk--main--main.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '9b7ebe61044661fdabda'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'fc2d750fc9e08c5749db'); " `; @@ -133,7 +133,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`option-function-output-file `; exports[`DependencyExtractionWebpackPlugin Webpack \`option-output-filename\` should produce expected output: Asset file 'main-foo.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '9b7ebe61044661fdabda'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'fc2d750fc9e08c5749db'); " `; @@ -155,7 +155,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`option-output-filename\` sh ] `; -exports[`DependencyExtractionWebpackPlugin Webpack \`output-format-json\` should produce expected output: Asset file 'main.asset.json' should match snapshot 1`] = `"{"dependencies":["lodash"],"version":"4c42b9646049ad2e9438"}"`; +exports[`DependencyExtractionWebpackPlugin Webpack \`output-format-json\` should produce expected output: Asset file 'main.asset.json' should match snapshot 1`] = `"{"dependencies":["lodash"],"version":"7bd48470d799a795bf9a"}"`; exports[`DependencyExtractionWebpackPlugin Webpack \`output-format-json\` should produce expected output: External modules should match snapshot 1`] = ` [ @@ -207,17 +207,17 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`overrides\` should produce `; exports[`DependencyExtractionWebpackPlugin Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'a.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('wp-blob'), 'version' => '09a0c551770a351c5ca7'); +"<?php return array('dependencies' => array('wp-blob'), 'version' => 'd3cda564b538b44d38ef'); " `; exports[`DependencyExtractionWebpackPlugin Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'b.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'c9f00d690a9f72438910'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '420d636da562e71648f7'); " `; exports[`DependencyExtractionWebpackPlugin Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'runtime.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array(), 'version' => '46ea0ff11ac53fa5e88b'); +"<?php return array('dependencies' => array(), 'version' => '66079b05b32ae1e16886'); " `; @@ -240,7 +240,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`runtime-chunk-single\` shou `; exports[`DependencyExtractionWebpackPlugin Webpack \`style-imports\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'd8c0ee89d933a3809c0e'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '4c661914a4e4d80b8a0b'); " `; @@ -263,7 +263,7 @@ exports[`DependencyExtractionWebpackPlugin Webpack \`style-imports\` should prod `; exports[`DependencyExtractionWebpackPlugin Webpack \`wordpress\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` -"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => '9b7ebe61044661fdabda'); +"<?php return array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'fc2d750fc9e08c5749db'); " `; diff --git a/packages/dependency-extraction-webpack-plugin/test/build.js b/packages/dependency-extraction-webpack-plugin/test/build.js index 9ad88a26c10c33..6eeb051a07888f 100644 --- a/packages/dependency-extraction-webpack-plugin/test/build.js +++ b/packages/dependency-extraction-webpack-plugin/test/build.js @@ -3,7 +3,7 @@ */ const fs = require( 'fs' ); const glob = require( 'glob' ).sync; -const mkdirp = require( 'mkdirp' ).sync; +const mkdirp = require( 'mkdirp' ).mkdirp.sync; const path = require( 'path' ); const rimraf = require( 'rimraf' ).sync; const webpack = require( 'webpack' ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/file-a.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/file-a.js index aa683b111a5ab2..52384ea4de1a47 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/file-a.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/file-a.js @@ -1,6 +1,7 @@ /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import { isEmpty } from 'lodash'; /** diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/index.js index 917b4cd7e204bf..4545bcd0c19bc6 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/index.js @@ -6,6 +6,7 @@ import { isBlobURL } from '@wordpress/blob'; /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.isEmpty( isBlobURL( '' ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/index.js index aa683b111a5ab2..52384ea4de1a47 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import { isEmpty } from 'lodash'; /** diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js index 612e420c2a6c4d..f9eb6f42f0a2dc 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.map( [], _.identity ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/index.js index 917b4cd7e204bf..4545bcd0c19bc6 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/index.js @@ -6,6 +6,7 @@ import { isBlobURL } from '@wordpress/blob'; /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.isEmpty( isBlobURL( '' ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/index.js index 917b4cd7e204bf..4545bcd0c19bc6 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/index.js @@ -6,6 +6,7 @@ import { isBlobURL } from '@wordpress/blob'; /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.isEmpty( isBlobURL( '' ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/output-format-json/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/output-format-json/index.js index 612e420c2a6c4d..f9eb6f42f0a2dc 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/output-format-json/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/output-format-json/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.map( [], _.identity ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/b.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/b.js index 917b4cd7e204bf..4545bcd0c19bc6 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/b.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/b.js @@ -6,6 +6,7 @@ import { isBlobURL } from '@wordpress/blob'; /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.isEmpty( isBlobURL( '' ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/index.js index df02e0b35e6f8e..b4e8cb76d1fa56 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/index.js @@ -6,6 +6,7 @@ import { isBlobURL } from '@wordpress/blob'; /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; /** diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js index 917b4cd7e204bf..4545bcd0c19bc6 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js @@ -6,6 +6,7 @@ import { isBlobURL } from '@wordpress/blob'; /** * External dependencies */ +// eslint-disable-next-line no-restricted-imports import _ from 'lodash'; _.isEmpty( isBlobURL( '' ) ); diff --git a/packages/deprecated/CHANGELOG.md b/packages/deprecated/CHANGELOG.md index c8695921c212f3..00ec41b9cb5e74 100644 --- a/packages/deprecated/CHANGELOG.md +++ b/packages/deprecated/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/deprecated/package.json b/packages/deprecated/package.json index 80b787ec9afb4e..f5401e350629f3 100644 --- a/packages/deprecated/package.json +++ b/packages/deprecated/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/deprecated", - "version": "3.34.0", + "version": "3.40.0", "description": "Deprecation utility for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/docgen/CHANGELOG.md b/packages/docgen/CHANGELOG.md index efccecee44bf1b..43e411ec15cdee 100644 --- a/packages/docgen/CHANGELOG.md +++ b/packages/docgen/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 1.49.0 (2023-08-16) + +## 1.48.0 (2023-08-10) + +## 1.47.0 (2023-07-20) + +## 1.46.0 (2023-07-05) + +## 1.45.0 (2023-06-23) + +## 1.44.0 (2023-06-07) + ## 1.43.0 (2023-05-24) ## 1.42.0 (2023-05-10) diff --git a/packages/docgen/lib/markdown/formatter.js b/packages/docgen/lib/markdown/formatter.js index d9242cd13db9e3..ef8d99047c26a4 100644 --- a/packages/docgen/lib/markdown/formatter.js +++ b/packages/docgen/lib/markdown/formatter.js @@ -207,8 +207,10 @@ module.exports = ( ( tag ) => { const name = tag.name; const type = getTypeOutput( tag ); - - return `- *${ name }* ${ type }`; + const desc = cleanSpaces( tag.description ); + return `- *${ name }* ${ type }${ + desc ? `: ${ desc }` : '' + }`; }, docs ); diff --git a/packages/docgen/package.json b/packages/docgen/package.json index 2b866ebcad1520..c3333e5adad4f0 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/docgen", - "version": "1.43.0", + "version": "1.49.0", "description": "Autogenerate public API documentation from exports and JSDoc comments.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom-ready/CHANGELOG.md b/packages/dom-ready/CHANGELOG.md index 6587d13760d1e9..7adb8755b8cc12 100644 --- a/packages/dom-ready/CHANGELOG.md +++ b/packages/dom-ready/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/dom-ready/package.json b/packages/dom-ready/package.json index 5dfc7c6c4b9877..0dd190caf2ad52 100644 --- a/packages/dom-ready/package.json +++ b/packages/dom-ready/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom-ready", - "version": "3.34.0", + "version": "3.40.0", "description": "Execute callback after the DOM is loaded.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom/CHANGELOG.md b/packages/dom/CHANGELOG.md index d64d37ed25c2cf..dc51858e17ab2c 100644 --- a/packages/dom/CHANGELOG.md +++ b/packages/dom/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/dom/README.md b/packages/dom/README.md index f87ccbb3ac731e..5440ff2820ccc6 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -192,7 +192,7 @@ Check whether the selection is horizontally at the edge of the container. _Parameters_ -- _container_ `Element`: Focusable element. +- _container_ `HTMLElement`: Focusable element. - _isReverse_ `boolean`: Set to true to check left, false for right. _Returns_ @@ -269,7 +269,7 @@ Check whether the selection is vertically at the edge of the container. _Parameters_ -- _container_ `Element`: Focusable element. +- _container_ `HTMLElement`: Focusable element. - _isReverse_ `boolean`: Set to true to check top, false for bottom. _Returns_ diff --git a/packages/dom/package.json b/packages/dom/package.json index 4a047bd571456c..a7fec1b3e8b156 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom", - "version": "3.34.0", + "version": "3.40.0", "description": "DOM utilities module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom/src/dom/is-edge.js b/packages/dom/src/dom/is-edge.js index 51c160f915d58d..64df2bc139be56 100644 --- a/packages/dom/src/dom/is-edge.js +++ b/packages/dom/src/dom/is-edge.js @@ -8,15 +8,16 @@ import isSelectionForward from './is-selection-forward'; import hiddenCaretRangeFromPoint from './hidden-caret-range-from-point'; import { assertIsDefined } from '../utils/assert-is-defined'; import isInputOrTextArea from './is-input-or-text-area'; +import { scrollIfNoRange } from './scroll-if-no-range'; /** * Check whether the selection is at the edge of the container. Checks for * horizontal position by default. Set `onlyVertical` to true to check only * vertically. * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check left, false to check right. - * @param {boolean} [onlyVertical=false] Set to true to check only vertical position. + * @param {HTMLElement} container Focusable element. + * @param {boolean} isReverse Set to true to check left, false to check right. + * @param {boolean} [onlyVertical=false] Set to true to check only vertical position. * * @return {boolean} True if at the edge, false if not. */ @@ -96,11 +97,8 @@ export default function isEdge( container, isReverse, onlyVertical = false ) { // pixels. `getComputedStyle` may return a value with different units. const x = isReverseDir ? containerRect.left + 1 : containerRect.right - 1; const y = isReverse ? containerRect.top + 1 : containerRect.bottom - 1; - const testRange = hiddenCaretRangeFromPoint( - ownerDocument, - x, - y, - /** @type {HTMLElement} */ ( container ) + const testRange = scrollIfNoRange( container, isReverse, () => + hiddenCaretRangeFromPoint( ownerDocument, x, y, container ) ); if ( ! testRange ) { diff --git a/packages/dom/src/dom/is-horizontal-edge.js b/packages/dom/src/dom/is-horizontal-edge.js index a9b947d1b8643d..b767c2af5fb3c6 100644 --- a/packages/dom/src/dom/is-horizontal-edge.js +++ b/packages/dom/src/dom/is-horizontal-edge.js @@ -6,8 +6,8 @@ import isEdge from './is-edge'; /** * Check whether the selection is horizontally at the edge of the container. * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check left, false for right. + * @param {HTMLElement} container Focusable element. + * @param {boolean} isReverse Set to true to check left, false for right. * * @return {boolean} True if at the horizontal edge, false if not. */ diff --git a/packages/dom/src/dom/is-vertical-edge.js b/packages/dom/src/dom/is-vertical-edge.js index d1b74e4cb13755..814eb46c8c0850 100644 --- a/packages/dom/src/dom/is-vertical-edge.js +++ b/packages/dom/src/dom/is-vertical-edge.js @@ -6,8 +6,8 @@ import isEdge from './is-edge'; /** * Check whether the selection is vertically at the edge of the container. * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check top, false for bottom. + * @param {HTMLElement} container Focusable element. + * @param {boolean} isReverse Set to true to check top, false for bottom. * * @return {boolean} True if at the vertical edge, false if not. */ diff --git a/packages/dom/src/dom/place-caret-at-edge.js b/packages/dom/src/dom/place-caret-at-edge.js index adb69567e759a7..9cb9958dc88439 100644 --- a/packages/dom/src/dom/place-caret-at-edge.js +++ b/packages/dom/src/dom/place-caret-at-edge.js @@ -5,6 +5,7 @@ import hiddenCaretRangeFromPoint from './hidden-caret-range-from-point'; import { assertIsDefined } from '../utils/assert-is-defined'; import isInputOrTextArea from './is-input-or-text-area'; import isRTL from './is-rtl'; +import { scrollIfNoRange } from './scroll-if-no-range'; /** * Gets the range to place. @@ -70,26 +71,11 @@ export default function placeCaretAtEdge( container, isReverse, x ) { return; } - let range = getRange( container, isReverse, x ); + const range = scrollIfNoRange( container, isReverse, () => + getRange( container, isReverse, x ) + ); - // If no range range can be created or it is outside the container, the - // element may be out of view. - if ( - ! range || - ! range.startContainer || - ! container.contains( range.startContainer ) - ) { - container.scrollIntoView( isReverse ); - range = range = getRange( container, isReverse, x ); - - if ( - ! range || - ! range.startContainer || - ! container.contains( range.startContainer ) - ) { - return; - } - } + if ( ! range ) return; const { ownerDocument } = container; const { defaultView } = ownerDocument; diff --git a/packages/dom/src/dom/scroll-if-no-range.js b/packages/dom/src/dom/scroll-if-no-range.js new file mode 100644 index 00000000000000..13f5c4d86769e8 --- /dev/null +++ b/packages/dom/src/dom/scroll-if-no-range.js @@ -0,0 +1,34 @@ +/** + * If no range range can be created or it is outside the container, the element + * may be out of view, so scroll it into view and try again. + * + * @param {HTMLElement} container The container to scroll. + * @param {boolean} alignToTop True to align to top, false to bottom. + * @param {Function} callback The callback to create the range. + * + * @return {?Range} The range returned by the callback. + */ +export function scrollIfNoRange( container, alignToTop, callback ) { + let range = callback(); + + // If no range range can be created or it is outside the container, the + // element may be out of view. + if ( + ! range || + ! range.startContainer || + ! container.contains( range.startContainer ) + ) { + container.scrollIntoView( alignToTop ); + range = callback(); + + if ( + ! range || + ! range.startContainer || + ! container.contains( range.startContainer ) + ) { + return null; + } + } + + return range; +} diff --git a/packages/e2e-test-utils-playwright/CHANGELOG.md b/packages/e2e-test-utils-playwright/CHANGELOG.md index cfc7fa2c9e9bea..d889a1751a2a8f 100644 --- a/packages/e2e-test-utils-playwright/CHANGELOG.md +++ b/packages/e2e-test-utils-playwright/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.8.0 (2023-08-16) + +## 0.7.0 (2023-08-10) + +## 0.6.0 (2023-07-20) + +## 0.5.0 (2023-07-05) + +## 0.4.0 (2023-06-23) + +## 0.3.0 (2023-06-07) + ## 0.2.0 (2023-05-24) ## 0.1.0 (2023-05-10) diff --git a/packages/e2e-test-utils-playwright/package.json b/packages/e2e-test-utils-playwright/package.json index d767a16d1dabdd..584e90df0342ef 100644 --- a/packages/e2e-test-utils-playwright/package.json +++ b/packages/e2e-test-utils-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-test-utils-playwright", - "version": "0.2.0", + "version": "0.8.0", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils-playwright/src/admin/create-new-post.js b/packages/e2e-test-utils-playwright/src/admin/create-new-post.js index fe748929561114..ef077e8e935e39 100644 --- a/packages/e2e-test-utils-playwright/src/admin/create-new-post.js +++ b/packages/e2e-test-utils-playwright/src/admin/create-new-post.js @@ -30,37 +30,26 @@ export async function createNewPost( { await this.visitAdminPage( 'post-new.php', query ); - await this.page.waitForSelector( '.edit-post-layout' ); - - const isWelcomeGuideActive = await this.page.evaluate( () => - window.wp.data - .select( 'core/edit-post' ) - .isFeatureActive( 'welcomeGuide' ) - ); - const isFullscreenMode = await this.page.evaluate( () => + // Wait for both iframed and non-iframed canvas and resolve once the + // currently available one is ready. To make this work, we need an inner + // legacy canvas selector that is unavailable directly when the canvas is + // iframed. + await Promise.any( [ + this.page.locator( '.wp-block-post-content' ).waitFor(), + this.page + .frameLocator( '[name=editor-canvas]' ) + .locator( 'body > *' ) + .first() + .waitFor(), + ] ); + + await this.page.evaluate( ( welcomeGuide ) => { window.wp.data - .select( 'core/edit-post' ) - .isFeatureActive( 'fullscreenMode' ) - ); - - if ( showWelcomeGuide !== isWelcomeGuideActive ) { - await this.page.evaluate( () => - window.wp.data - .dispatch( 'core/edit-post' ) - .toggleFeature( 'welcomeGuide' ) - ); + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'welcomeGuide', welcomeGuide ); - await this.page.reload(); - await this.page.waitForSelector( '.edit-post-layout' ); - } - - if ( isFullscreenMode ) { - await this.page.evaluate( () => - window.wp.data - .dispatch( 'core/edit-post' ) - .toggleFeature( 'fullscreenMode' ) - ); - - await this.page.waitForSelector( 'body:not(.is-fullscreen-mode)' ); - } + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fullscreenMode', false ); + }, showWelcomeGuide ); } diff --git a/packages/e2e-test-utils-playwright/src/admin/visit-admin-page.ts b/packages/e2e-test-utils-playwright/src/admin/visit-admin-page.ts index 60aec50837694f..1ffb20c6cc3f86 100644 --- a/packages/e2e-test-utils-playwright/src/admin/visit-admin-page.ts +++ b/packages/e2e-test-utils-playwright/src/admin/visit-admin-page.ts @@ -18,7 +18,7 @@ import type { Admin } from './'; export async function visitAdminPage( this: Admin, adminPath: string, - query: string + query?: string ) { await this.page.goto( join( 'wp-admin', adminPath ) + ( query ? `?${ query }` : '' ) diff --git a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts index f703597edecf5b..327aa5877b2cd8 100644 --- a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts @@ -44,6 +44,14 @@ export async function visitSiteEditor( window.wp.data .dispatch( 'core/preferences' ) .set( 'core/edit-site', 'welcomeGuideStyles', false ); + + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuidePage', false ); + + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuideTemplate', false ); } ); } @@ -55,8 +63,11 @@ export async function visitSiteEditor( .first() .waitFor(); - // TODO: Ideally the content underneath the spinner should be marked inert until it's ready. + // TODO: Ideally the content underneath the canvas loader should be marked inert until it's ready. await this.page - .locator( '.edit-site-canvas-spinner' ) - .waitFor( { state: 'hidden' } ); + .locator( '.edit-site-canvas-loader' ) + // Bigger timeout is needed for larger entities, for example the large + // post html fixture that we load for performance tests, which often + // doesn't make it under the default 10 seconds. + .waitFor( { state: 'hidden', timeout: 60_000 } ); } diff --git a/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts b/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts index 2f66fac8692618..0e642a1de76626 100644 --- a/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts +++ b/packages/e2e-test-utils-playwright/src/editor/set-is-fixed-toolbar.ts @@ -11,11 +11,8 @@ import type { Editor } from './index'; */ export async function setIsFixedToolbar( this: Editor, isFixed: boolean ) { await this.page.evaluate( ( _isFixed ) => { - const { select, dispatch } = window.wp.data; - const isCurrentlyFixed = - select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ); - if ( isCurrentlyFixed !== _isFixed ) { - dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); - } + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fixedToolbar', _isFixed ); }, isFixed ); } diff --git a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts index 432e8c15b120a7..b81f9983e2813e 100644 --- a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts @@ -24,10 +24,8 @@ export async function saveSiteEditorEntities( this: Editor ) { .getByRole( 'button', { name: 'Save', exact: true } ) .click(); - // A role selector cannot be used here because it needs to check that the `is-busy` class is not present. await this.page - .locator( '[aria-label="Editor top bar"] [aria-label="Saved"].is-busy' ) - .waitFor( { - state: 'hidden', - } ); + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .getByText( 'Site updated.' ) + .waitFor(); } diff --git a/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts b/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts index ea59c4602b1f60..f6378b73e33ecc 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts @@ -9,7 +9,7 @@ import { getType } from 'mime'; * Internal dependencies */ import type { PageUtils } from './index'; -import type { Locator } from '@playwright/test'; +import type { ElementHandle, Locator } from '@playwright/test'; type FileObject = { name: string; @@ -141,21 +141,25 @@ async function dragFiles( /** * Drop the files at the current position. + * + * @param locator */ - drop: async () => { - const topMostElement = await this.page.evaluateHandle( - ( { x, y } ) => { - return document.elementFromPoint( x, y ); - }, - position - ); - const elementHandle = topMostElement.asElement(); + drop: async ( locator: Locator | ElementHandle | null ) => { + if ( ! locator ) { + const topMostElement = await this.page.evaluateHandle( + ( { x, y } ) => { + return document.elementFromPoint( x, y ); + }, + position + ); + locator = topMostElement.asElement(); + } - if ( ! elementHandle ) { + if ( ! locator ) { throw new Error( 'Element not found.' ); } - await elementHandle.dispatchEvent( 'drop', { dataTransfer } ); + await locator.dispatchEvent( 'drop', { dataTransfer } ); await cdpSession.detach(); }, diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 2d93a184913bba..9929ebf19d01a1 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -49,6 +49,9 @@ export function setClipboardData( async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { clipboardDataHolder = await page.evaluate( ( [ _type, _clipboardData ] ) => { + const canvasDoc = + // @ts-ignore + document.activeElement?.contentDocument ?? document; const clipboardDataTransfer = new DataTransfer(); if ( _type === 'paste' ) { @@ -61,7 +64,7 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { _clipboardData.html ); } else { - const selection = window.getSelection()!; + const selection = canvasDoc.defaultView.getSelection()!; const plainText = selection.toString(); let html = plainText; if ( selection.rangeCount ) { @@ -70,7 +73,8 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { html = Array.from( fragment.childNodes ) .map( ( node ) => - ( node as Element ).outerHTML ?? node.nodeValue + ( node as Element ).outerHTML ?? + ( node as Element ).nodeValue ) .join( '' ); } @@ -78,7 +82,7 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { clipboardDataTransfer.setData( 'text/html', html ); } - document.activeElement?.dispatchEvent( + canvasDoc.activeElement?.dispatchEvent( new ClipboardEvent( _type, { bubbles: true, cancelable: true, diff --git a/packages/e2e-test-utils-playwright/src/request-utils/plugins.ts b/packages/e2e-test-utils-playwright/src/request-utils/plugins.ts index 5f1639f109d2e3..f018d7d9f3903b 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/plugins.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/plugins.ts @@ -27,25 +27,55 @@ async function getPluginsMap( this: RequestUtils, forceRefetch = false ) { for ( const plugin of plugins ) { // Ideally, we should be using sanitize_title() in PHP rather than kebabCase(), // but we don't have the exact port of it in JS. - this.pluginsMap[ kebabCase( plugin.name ) ] = plugin.plugin; + // This is a good approximation though. + const slug = kebabCase( plugin.name.toLowerCase() ); + this.pluginsMap[ slug ] = plugin.plugin; } return this.pluginsMap; } /** - * Activates an installed plugin. + * Finds a plugin in the plugin map. * - * @param this RequestUtils. - * @param slug Plugin slug. + * Attempts to provide a helpful error message if not found. + * + * @param slug Plugin slug. + * @param pluginsMap Plugins map. */ -async function activatePlugin( this: RequestUtils, slug: string ) { - const pluginsMap = await this.getPluginsMap(); +function getPluginFromMap( + slug: string, + pluginsMap: Record< string, string > +) { const plugin = pluginsMap[ slug ]; if ( ! plugin ) { + for ( const key of Object.keys( pluginsMap ) ) { + if ( + key.toLowerCase().replace( /-/g, '' ) === + slug.toLowerCase().replace( /-/g, '' ) + ) { + throw new Error( + `The plugin "${ slug }" isn't installed. Did you perhaps mean "${ key }"?` + ); + } + } + throw new Error( `The plugin "${ slug }" isn't installed` ); } + return plugin; +} + +/** + * Activates an installed plugin. + * + * @param this RequestUtils. + * @param slug Plugin slug. + */ +async function activatePlugin( this: RequestUtils, slug: string ) { + const pluginsMap = await this.getPluginsMap(); + const plugin = getPluginFromMap( slug, pluginsMap ); + await this.rest( { method: 'PUT', path: `/wp/v2/plugins/${ plugin }`, @@ -61,11 +91,7 @@ async function activatePlugin( this: RequestUtils, slug: string ) { */ async function deactivatePlugin( this: RequestUtils, slug: string ) { const pluginsMap = await this.getPluginsMap(); - const plugin = pluginsMap[ slug ]; - - if ( ! plugin ) { - throw new Error( `The plugin "${ slug }" isn't installed` ); - } + const plugin = getPluginFromMap( slug, pluginsMap ); await this.rest( { method: 'PUT', diff --git a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts index e02dc11d3842a0..5e32c0c877555c 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts @@ -7,6 +7,7 @@ export interface Post { id: number; content: string; status: 'publish' | 'future' | 'draft' | 'pending' | 'private'; + link: string; } export interface CreatePostPayload { diff --git a/packages/e2e-test-utils-playwright/src/test.ts b/packages/e2e-test-utils-playwright/src/test.ts index 894abf93dcd02e..eec8e4e279c0ff 100644 --- a/packages/e2e-test-utils-playwright/src/test.ts +++ b/packages/e2e-test-utils-playwright/src/test.ts @@ -136,18 +136,6 @@ const test = base.extend< storageStatePath: STORAGE_STATE_PATH, } ); - await Promise.all( [ - requestUtils.activateTheme( 'twentytwentyone' ), - // Disable this test plugin as it's conflicting with some of the tests. - // We already have reduced motion enabled and Playwright will wait for most of the animations anyway. - requestUtils.deactivatePlugin( - 'gutenberg-test-plugin-disables-the-css-animations' - ), - requestUtils.deleteAllPosts(), - requestUtils.deleteAllBlocks(), - requestUtils.resetPreferences(), - ] ); - await use( requestUtils ); }, { scope: 'worker', auto: true }, diff --git a/packages/e2e-test-utils/CHANGELOG.md b/packages/e2e-test-utils/CHANGELOG.md index f342cc2b1cb1d1..ab52a15b3f7aa8 100644 --- a/packages/e2e-test-utils/CHANGELOG.md +++ b/packages/e2e-test-utils/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 10.11.0 (2023-08-16) + +## 10.10.0 (2023-08-10) + +## 10.9.0 (2023-07-20) + +## 10.8.0 (2023-07-05) + +## 10.7.0 (2023-06-23) + +## 10.6.0 (2023-06-07) + ## 10.5.0 (2023-05-24) ## 10.4.0 (2023-05-10) diff --git a/packages/e2e-test-utils/package.json b/packages/e2e-test-utils/package.json index 3e2ecf633538d8..135e25517b59f7 100644 --- a/packages/e2e-test-utils/package.json +++ b/packages/e2e-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-test-utils", - "version": "10.5.0", + "version": "10.11.0", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils/src/click-block-appender.js b/packages/e2e-test-utils/src/click-block-appender.js index caae3dbf11dc79..b1c69789544c78 100644 --- a/packages/e2e-test-utils/src/click-block-appender.js +++ b/packages/e2e-test-utils/src/click-block-appender.js @@ -1,3 +1,8 @@ +/** + * Internal dependencies + */ +import { canvas } from './canvas'; + /** * Clicks the default block appender. */ @@ -6,7 +11,7 @@ export async function clickBlockAppender() { await page.evaluate( () => window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock() ); - const appender = await page.waitForSelector( + const appender = await canvas().waitForSelector( '.block-editor-default-block-appender__content' ); await appender.click(); diff --git a/packages/e2e-test-utils/src/click-menu-item.js b/packages/e2e-test-utils/src/click-menu-item.js index b43629217c1c5f..f0a5f0d3258674 100644 --- a/packages/e2e-test-utils/src/click-menu-item.js +++ b/packages/e2e-test-utils/src/click-menu-item.js @@ -4,8 +4,8 @@ * @param {string} label The label to search the menu item for. */ export async function clickMenuItem( label ) { - const menuItems = await page.$x( + const menuItem = await page.waitForXPath( `//*[@role="menu"]//*[text()="${ label }"]` ); - await menuItems[ 0 ].click(); + await menuItem.click(); } diff --git a/packages/e2e-test-utils/src/create-reusable-block.js b/packages/e2e-test-utils/src/create-reusable-block.js index ec35e073908477..ed756353310dd9 100644 --- a/packages/e2e-test-utils/src/create-reusable-block.js +++ b/packages/e2e-test-utils/src/create-reusable-block.js @@ -14,23 +14,27 @@ import { canvas } from './canvas'; */ export const createReusableBlock = async ( content, title ) => { const reusableBlockNameInputSelector = - '.reusable-blocks-menu-items__convert-modal .components-text-control__input'; + '.patterns-menu-items__convert-modal .components-text-control__input'; + const syncToggleSelectorChecked = + '.patterns-menu-items__convert-modal .components-form-toggle.is-checked'; // Insert a paragraph block await insertBlock( 'Paragraph' ); await page.keyboard.type( content ); await clickBlockToolbarButton( 'Options' ); - await clickMenuItem( 'Create Reusable block' ); + await clickMenuItem( 'Create pattern' ); const nameInput = await page.waitForSelector( reusableBlockNameInputSelector ); await nameInput.click(); await page.keyboard.type( title ); + + await page.waitForSelector( syncToggleSelectorChecked ); await page.keyboard.press( 'Enter' ); // Wait for creation to finish await page.waitForXPath( - '//*[contains(@class, "components-snackbar")]/*[text()="Reusable block created."]' + '//*[contains(@class, "components-snackbar")]/*[contains(text(),"Pattern created:")]' ); // Check that we have a reusable block on the page diff --git a/packages/e2e-test-utils/src/inserter.js b/packages/e2e-test-utils/src/inserter.js index 84521728160bee..cdd1c534d928db 100644 --- a/packages/e2e-test-utils/src/inserter.js +++ b/packages/e2e-test-utils/src/inserter.js @@ -106,8 +106,8 @@ export async function selectGlobalInserterTab( label ) { case 'Media': labelSelector = `. = "${ label }"`; break; - case 'Reusable': - // Reusable tab label is an icon, hence the different selector. + case 'Synced patterns': + // Synced patterns tab label is an icon, hence the different selector. labelSelector = `@aria-label = "${ label }"`; break; } @@ -180,7 +180,7 @@ export async function searchGlobalInserter( category, searchTerm ) { switch ( category ) { case 'Blocks': case 'Patterns': - case 'Reusable': { + case 'Synced patterns': { waitForInsertElement = async () => { return await page.waitForXPath( `//*[@role='option' and contains(., '${ searchTerm }')]` @@ -220,7 +220,7 @@ export async function searchGlobalInserter( category, searchTerm ) { * If the entity is not instantly available in the open inserter, a search will * be performed. If the search returns no results, an error will be thrown. * - * Available categories: Blocks, Patterns, Reusable and Block Directory. + * Available categories: Blocks, Patterns, Synced patterns and Block Directory. * * @param {string} category The category to insert from. * @param {string} searchTerm The term by which to find the entity to insert. @@ -231,8 +231,8 @@ export async function insertFromGlobalInserter( category, searchTerm ) { let insertButton; - if ( [ 'Blocks', 'Reusable' ].includes( category ) ) { - // If it's a block, see it it's insertable without searching... + if ( [ 'Blocks', 'Synced patterns' ].includes( category ) ) { + // If it's a block, see if it's insertable without searching... try { insertButton = ( await page.$x( @@ -260,8 +260,8 @@ export async function insertFromGlobalInserter( category, searchTerm ) { await insertButton.click(); // Extra wait for the reusable block to be ready. - if ( category === 'Reusable' ) { - await page.waitForSelector( + if ( category === 'Synced patterns' ) { + await canvas().waitForSelector( '.block-library-block__reusable-block-container' ); } @@ -347,7 +347,7 @@ export async function insertPattern( searchTerm ) { * insert. */ export async function insertReusableBlock( searchTerm ) { - await insertFromGlobalInserter( 'Reusable', searchTerm ); + await insertFromGlobalInserter( 'Synced patterns', searchTerm ); } /** diff --git a/packages/e2e-test-utils/src/install-plugin.js b/packages/e2e-test-utils/src/install-plugin.js index a2f8e493294196..5edfbb54f6642a 100644 --- a/packages/e2e-test-utils/src/install-plugin.js +++ b/packages/e2e-test-utils/src/install-plugin.js @@ -20,6 +20,8 @@ export async function installPlugin( slug, searchTerm ) { '&tab=search&type=term' ); await page.click( `.install-now[data-slug="${ slug }"]` ); - await page.waitForSelector( `.activate-now[data-slug="${ slug }"]` ); + await page.waitForSelector( `.activate-now[data-slug="${ slug }"]`, { + timeout: 60000, + } ); await switchUserToTest(); } diff --git a/packages/e2e-test-utils/src/install-theme.js b/packages/e2e-test-utils/src/install-theme.js index d030165c7cb97c..7d001d395bda7f 100644 --- a/packages/e2e-test-utils/src/install-theme.js +++ b/packages/e2e-test-utils/src/install-theme.js @@ -37,6 +37,8 @@ export async function installTheme( slug, { searchTerm } = {} ) { await page.waitForSelector( `.theme-install[data-slug="${ slug }"]` ); await page.click( `.theme-install[data-slug="${ slug }"]` ); - await page.waitForSelector( `.theme[data-slug="${ slug }"] .activate` ); + await page.waitForSelector( `.theme[data-slug="${ slug }"] .activate`, { + timeout: 60000, + } ); await switchUserToTest(); } diff --git a/packages/e2e-test-utils/src/login-user.js b/packages/e2e-test-utils/src/login-user.js index aeecfd9d5cc612..976153bce8f616 100644 --- a/packages/e2e-test-utils/src/login-user.js +++ b/packages/e2e-test-utils/src/login-user.js @@ -17,7 +17,9 @@ export async function loginUser( password = WP_PASSWORD ) { if ( ! isCurrentURL( 'wp-login.php' ) ) { + const waitForLoginPageNavigation = page.waitForNavigation(); await page.goto( createURL( 'wp-login.php' ) ); + await waitForLoginPageNavigation; } await page.focus( '#user_login' ); @@ -27,8 +29,6 @@ export async function loginUser( await pressKeyWithModifier( 'primary', 'a' ); await page.type( '#user_pass', password ); - await Promise.all( [ - page.waitForNavigation(), - page.click( '#wp-submit' ), - ] ); + const waitForLoginNavigation = page.waitForNavigation(); + await Promise.all( [ waitForLoginNavigation, page.click( '#wp-submit' ) ] ); } diff --git a/packages/e2e-test-utils/src/press-key-with-modifier.js b/packages/e2e-test-utils/src/press-key-with-modifier.js index 265c186752f8dd..71d8ed72e8de75 100644 --- a/packages/e2e-test-utils/src/press-key-with-modifier.js +++ b/packages/e2e-test-utils/src/press-key-with-modifier.js @@ -24,8 +24,9 @@ import { modifiers, SHIFT, ALT, CTRL } from '@wordpress/keycodes'; async function emulateSelectAll() { await page.evaluate( () => { const isMac = /Mac|iPod|iPhone|iPad/.test( window.navigator.platform ); + const canvasDoc = document.activeElement.contentDocument ?? document; - document.activeElement.dispatchEvent( + canvasDoc.activeElement.dispatchEvent( new KeyboardEvent( 'keydown', { bubbles: true, cancelable: true, @@ -58,14 +59,14 @@ async function emulateSelectAll() { } ); const wasPrevented = - ! document.activeElement.dispatchEvent( preventableEvent ) || + ! canvasDoc.activeElement.dispatchEvent( preventableEvent ) || preventableEvent.defaultPrevented; if ( ! wasPrevented ) { - document.execCommand( 'selectall', false, null ); + canvasDoc.execCommand( 'selectall', false, null ); } - document.activeElement.dispatchEvent( + canvasDoc.activeElement.dispatchEvent( new KeyboardEvent( 'keyup', { bubbles: true, cancelable: true, @@ -103,10 +104,12 @@ export async function setClipboardData( { plainText = '', html = '' } ) { async function emulateClipboard( type ) { await page.evaluate( ( _type ) => { + const canvasDoc = document.activeElement.contentDocument ?? document; + if ( _type !== 'paste' ) { window._clipboardData = new DataTransfer(); - const selection = window.getSelection(); + const selection = canvasDoc.defaultView.getSelection(); const plainText = selection.toString(); let html = plainText; @@ -123,7 +126,7 @@ async function emulateClipboard( type ) { window._clipboardData.setData( 'text/html', html ); } - document.activeElement.dispatchEvent( + canvasDoc.activeElement.dispatchEvent( new ClipboardEvent( _type, { bubbles: true, cancelable: true, diff --git a/packages/e2e-test-utils/src/set-option.js b/packages/e2e-test-utils/src/set-option.js index 0f1abbc9fd6c5b..e008fa7394b73e 100644 --- a/packages/e2e-test-utils/src/set-option.js +++ b/packages/e2e-test-utils/src/set-option.js @@ -13,7 +13,6 @@ import { pressKeyWithModifier } from './press-key-with-modifier'; * @param {string} value The value to set the option to. * * @return {string} The previous value of the option. - * */ export async function setOption( setting, value ) { await switchUserToAdmin(); diff --git a/packages/e2e-test-utils/src/site-editor.js b/packages/e2e-test-utils/src/site-editor.js index 619fc8b9cf630b..02c3d05f74cc16 100644 --- a/packages/e2e-test-utils/src/site-editor.js +++ b/packages/e2e-test-utils/src/site-editor.js @@ -10,75 +10,30 @@ import { addQueryArgs } from '@wordpress/url'; const SELECTORS = { visualEditor: '.edit-site-visual-editor iframe', - loadingSpinner: '.edit-site-canvas-spinner', + canvasLoader: '.edit-site-canvas-loader', }; /** * Skips the welcome guide popping up to first time users of the site editor */ export async function disableSiteEditorWelcomeGuide() { - // This code prioritizes using the preferences store. However, performance - // tests run on older versions of the codebase where the preferences store - // doesn't exist. Some backwards compatibility has been built-in so that - // those tests continue to work there. This can be removed once WordPress - // 6.0 is released, as the older version used by the performance tests will - // then include the preferences store. - // See https://github.com/WordPress/gutenberg/pull/39300. - const isWelcomeGuideActive = await page.evaluate( () => { - // TODO - remove if statement after WordPress 6.0 is released. - if ( ! wp.data.select( 'core/preferences' ) ) { - return wp.data - .select( 'core/edit-site' ) - .isFeatureActive( 'welcomeGuide' ); - } - - return !! wp.data - .select( 'core/preferences' ) - ?.get( 'core/edit-site', 'welcomeGuide' ); + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuide', false ); + + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuideStyles', false ); + + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuidePage', false ); + + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuideTemplate', false ); } ); - const isWelcomeGuideStyesActive = await page.evaluate( () => { - // TODO - remove if statement after WordPress 6.0 is released. - if ( ! wp.data.select( 'core/preferences' ) ) { - return wp.data - .select( 'core/edit-site' ) - .isFeatureActive( 'welcomeGuideStyles' ); - } - - return !! wp.data - .select( 'core/preferences' ) - ?.get( 'core/edit-site', 'welcomeGuideStyles' ); - } ); - - if ( isWelcomeGuideActive ) { - await page.evaluate( () => { - // TODO - remove if statement after WordPress 6.0 is released. - if ( ! wp.data.dispatch( 'core/preferences' ) ) { - wp.data - .dispatch( 'core/edit-site' ) - .toggleFeature( 'welcomeGuide' ); - return; - } - - wp.data - .dispatch( 'core/preferences' ) - .toggle( 'core/edit-site', 'welcomeGuide' ); - } ); - } - - if ( isWelcomeGuideStyesActive ) { - await page.evaluate( () => { - // TODO - remove if statement after WordPress 6.0 is released. - if ( ! wp.data.dispatch( 'core/preferences' ) ) { - wp.data - .dispatch( 'core/edit-site' ) - .toggleFeature( 'welcomeGuideStyles' ); - return; - } - wp.data - .dispatch( 'core/preferences' ) - .toggle( 'core/edit-site', 'welcomeGuideStyles' ); - } ); - } } /** @@ -129,7 +84,9 @@ export async function visitSiteEditor( query, skipWelcomeGuide = true ) { await visitAdminPage( 'site-editor.php', query ); await page.waitForSelector( SELECTORS.visualEditor ); - await page.waitForSelector( SELECTORS.loadingSpinner, { hidden: true } ); + await page.waitForSelector( SELECTORS.canvasLoader, { + hidden: true, + } ); if ( skipWelcomeGuide ) { await disableSiteEditorWelcomeGuide(); diff --git a/packages/e2e-tests/CHANGELOG.md b/packages/e2e-tests/CHANGELOG.md index 384fc34ef7562c..4dc2746182f9f5 100644 --- a/packages/e2e-tests/CHANGELOG.md +++ b/packages/e2e-tests/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 7.11.0 (2023-08-16) + +## 7.10.0 (2023-08-10) + +## 7.9.0 (2023-07-20) + +## 7.8.0 (2023-07-05) + +## 7.7.0 (2023-06-23) + +## 7.6.0 (2023-06-07) + ## 7.5.0 (2023-05-24) ## 7.4.0 (2023-05-10) diff --git a/packages/e2e-tests/config/performance-reporter.js b/packages/e2e-tests/config/performance-reporter.js deleted file mode 100644 index a382229e201bf9..00000000000000 --- a/packages/e2e-tests/config/performance-reporter.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * External dependencies - */ -const { readFileSync, existsSync } = require( 'fs' ); -const path = require( 'path' ); -const chalk = require( 'chalk' ); - -function average( array ) { - return array.reduce( ( a, b ) => a + b ) / array.length; -} - -function round( number, decimalPlaces = 2 ) { - const factor = Math.pow( 10, decimalPlaces ); - return Math.round( number * factor ) / factor; -} - -const title = chalk.bold; -const success = chalk.bold.green; - -class PerformanceReporter { - onTestResult( test ) { - const basename = path.basename( test.path, '.js' ); - const filepath = path.join( - process.env.WP_ARTIFACTS_PATH, - basename + '.performance-results.json' - ); - - if ( ! existsSync( filepath ) ) { - return; - } - - const results = readFileSync( filepath, 'utf8' ); - const { - serverResponse, - firstPaint, - domContentLoaded, - loaded, - firstContentfulPaint, - firstBlock, - type, - typeContainer, - focus, - listViewOpen, - inserterOpen, - inserterHover, - inserterSearch, - } = JSON.parse( results ); - - if ( serverResponse && serverResponse.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Loading Time:' ) } -Average time to server response (subtracted from client side metrics): ${ success( - round( average( serverResponse ) ) + 'ms' - ) } -Average time to first paint: ${ success( - round( average( firstPaint ) ) + 'ms' - ) } -Average time to DOM content load: ${ success( - round( average( domContentLoaded ) ) + 'ms' - ) } -Average time to load: ${ success( round( average( loaded ) ) + 'ms' ) } -Average time to first contentful paint: ${ success( - round( average( firstContentfulPaint ) ) + 'ms' - ) } -Average time to first block: ${ success( - round( average( firstBlock ) ) + 'ms' - ) }` ); - } - - if ( type && type.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Typing:' ) } -Average time to type character: ${ success( round( average( type ) ) + 'ms' ) } -Slowest time to type character: ${ success( - round( Math.max( ...type ) ) + 'ms' - ) } -Fastest time to type character: ${ success( - round( Math.min( ...type ) ) + 'ms' - ) }` ); - } - - if ( typeContainer && typeContainer.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Typing within a container:' ) } -Average time to type within a container: ${ success( - round( average( typeContainer ) ) + 'ms' - ) } -Slowest time to type within a container: ${ success( - round( Math.max( ...typeContainer ) ) + 'ms' - ) } -Fastest time to type within a container: ${ success( - round( Math.min( ...typeContainer ) ) + 'ms' - ) }` ); - } - - if ( focus && focus.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Block Selection:' ) } -Average time to select a block: ${ success( round( average( focus ) ) + 'ms' ) } -Slowest time to select a block: ${ success( - round( Math.max( ...focus ) ) + 'ms' - ) } -Fastest time to select a block: ${ success( - round( Math.min( ...focus ) ) + 'ms' - ) }` ); - } - - if ( listViewOpen && listViewOpen.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Opening List View:' ) } -Average time to open list view: ${ success( - round( average( listViewOpen ) ) + 'ms' - ) } -Slowest time to open list view: ${ success( - round( Math.max( ...listViewOpen ) ) + 'ms' - ) } -Fastest time to open list view: ${ success( - round( Math.min( ...listViewOpen ) ) + 'ms' - ) }` ); - } - - if ( inserterOpen && inserterOpen.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Opening Global Inserter:' ) } -Average time to open global inserter: ${ success( - round( average( inserterOpen ) ) + 'ms' - ) } -Slowest time to open global inserter: ${ success( - round( Math.max( ...inserterOpen ) ) + 'ms' - ) } -Fastest time to open global inserter: ${ success( - round( Math.min( ...inserterOpen ) ) + 'ms' - ) }` ); - } - - if ( inserterSearch && inserterSearch.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Inserter Search:' ) } -Average time to type the inserter search input: ${ success( - round( average( inserterSearch ) ) + 'ms' - ) } -Slowest time to type the inserter search input: ${ success( - round( Math.max( ...inserterSearch ) ) + 'ms' - ) } -Fastest time to type the inserter search input: ${ success( - round( Math.min( ...inserterSearch ) ) + 'ms' - ) }` ); - } - - if ( inserterHover && inserterHover.length ) { - // eslint-disable-next-line no-console - console.log( ` -${ title( 'Inserter Block Item Hover:' ) } -Average time to move mouse between two block item in the inserter: ${ success( - round( average( inserterHover ) ) + 'ms' - ) } -Slowest time to move mouse between two block item in the inserter: ${ success( - round( Math.max( ...inserterHover ) ) + 'ms' - ) } -Fastest time to move mouse between two block item in the inserter: ${ success( - round( Math.min( ...inserterHover ) ) + 'ms' - ) }` ); - } - - // eslint-disable-next-line no-console - console.log( '' ); - } -} - -module.exports = PerformanceReporter; diff --git a/packages/e2e-tests/config/setup-performance-test.js b/packages/e2e-tests/config/setup-performance-test.js deleted file mode 100644 index 655a945143c2bc..00000000000000 --- a/packages/e2e-tests/config/setup-performance-test.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - clearLocalStorage, - enablePageDialogAccept, - setBrowserViewport, - trashAllPosts, -} from '@wordpress/e2e-test-utils'; - -/** - * Timeout, in seconds, that the test should be allowed to run. - * - * @type {string|undefined} - */ -const PUPPETEER_TIMEOUT = process.env.PUPPETEER_TIMEOUT; - -// The Jest timeout is increased because these tests are a bit slow. -jest.setTimeout( PUPPETEER_TIMEOUT || 100000 ); - -async function setupPage() { - await setBrowserViewport( 'large' ); - await page.emulateMediaFeatures( [ - { name: 'prefers-reduced-motion', value: 'reduce' }, - ] ); -} - -// Before every test suite run, delete all content created by the test. This -// ensures other posts/comments/etc. aren't dirtying tests and tests don't -// depend on each other's side-effects. -beforeAll( async () => { - enablePageDialogAccept(); - - await trashAllPosts(); - await trashAllPosts( 'wp_block' ); - await activatePlugin( 'gutenberg-test-plugin-disables-the-css-animations' ); - await clearLocalStorage(); - await setupPage(); -} ); - -afterEach( async () => { - // Clear localStorage between tests so that the next test starts clean. - await clearLocalStorage(); - // Close the previous page entirely and create a new page, so that the next - // test isn't affected by page unload work. - await page.close(); - page = await browser.newPage(); - // Set up testing config on new page. - await setupPage(); -} ); diff --git a/packages/e2e-tests/config/setup-test-framework.js b/packages/e2e-tests/config/setup-test-framework.js index 6f5a264ad4da63..631aa1f7f5b0e2 100644 --- a/packages/e2e-tests/config/setup-test-framework.js +++ b/packages/e2e-tests/config/setup-test-framework.js @@ -158,6 +158,15 @@ function observeConsoleLogging() { return; } + // Ignore framer-motion warnings about reduced motion. + if ( + text.includes( + 'You have Reduced Motion enabled on your device. Animations may not appear as expected.' + ) + ) { + return; + } + const logFunction = OBSERVED_CONSOLE_MESSAGE_TYPES[ type ]; // As of Puppeteer 1.6.1, `message.text()` wrongly returns an object of diff --git a/packages/e2e-tests/jest.config.js b/packages/e2e-tests/jest.config.js index 0c88768f0f58aa..0800a62e4869c5 100644 --- a/packages/e2e-tests/jest.config.js +++ b/packages/e2e-tests/jest.config.js @@ -14,10 +14,7 @@ module.exports = { 'expect-puppeteer', 'puppeteer-testing-library/extend-expect', ], - testPathIgnorePatterns: [ - '/node_modules/', - 'e2e-tests/specs/performance/', - ], + testPathIgnorePatterns: [ '/node_modules/' ], snapshotFormat: { escapeString: false, printBasicPrototype: false, diff --git a/packages/e2e-tests/jest.performance.config.js b/packages/e2e-tests/jest.performance.config.js deleted file mode 100644 index 743836ee2e82fe..00000000000000 --- a/packages/e2e-tests/jest.performance.config.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - ...require( '@wordpress/scripts/config/jest-e2e.config' ), - testMatch: [ '**/performance/*.test.js' ], - setupFiles: [ '<rootDir>/config/is-gutenberg-plugin.js' ], - setupFilesAfterEnv: [ - '<rootDir>/config/setup-performance-test.js', - '@wordpress/jest-console', - '@wordpress/jest-puppeteer-axe', - 'expect-puppeteer', - ], - transformIgnorePatterns: [ - 'node_modules', - 'scripts/config/puppeteer.config.js', - ], - reporters: [ 'default', '<rootDir>/config/performance-reporter.js' ], -}; diff --git a/packages/e2e-tests/mu-plugins/nocache-headers.php b/packages/e2e-tests/mu-plugins/nocache-headers.php new file mode 100644 index 00000000000000..0bb88030e68f5b --- /dev/null +++ b/packages/e2e-tests/mu-plugins/nocache-headers.php @@ -0,0 +1,21 @@ +<?php +/** + * Plugin Name: Gutenberg Test Plugin, No-cache Headers + * Plugin URI: https://github.com/WordPress/gutenberg + * Author: Gutenberg Team + * + * @package gutenberg-test-nocache-headers + */ + +// Remove 'no-store' from the Cache-Control header set by WordPress when running +// E2E tests. This is a workaround for an issue where E2E tests time out waiting +// for 'networkidle'. +add_filter( + 'nocache_headers', + static function( $headers ) { + $cache_control_parts = explode( ', ', $headers['Cache-Control'] ); + $cache_control_parts = array_diff( $cache_control_parts, array( 'no-store' ) ); + $headers['Cache-Control'] = implode( ', ', $cache_control_parts ); + return $headers; + } +); diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index 6cc161a32a5344..f9ad6efcf0e2c3 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-tests", - "version": "7.5.0", + "version": "7.11.0", "description": "End-To-End (E2E) tests for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -31,8 +31,8 @@ "chalk": "^4.0.0", "expect-puppeteer": "^4.4.0", "filenamify": "^4.2.0", - "jest-message-util": "^29.5.0", - "jest-snapshot": "^29.5.0", + "jest-message-util": "^29.6.2", + "jest-snapshot": "^29.6.2", "puppeteer-testing-library": "^0.5.0", "uuid": "^8.3.0" }, diff --git a/packages/e2e-tests/plugins/block-api.php b/packages/e2e-tests/plugins/block-api.php index e0609f7adc0ffc..265ada8ab8ed94 100644 --- a/packages/e2e-tests/plugins/block-api.php +++ b/packages/e2e-tests/plugins/block-api.php @@ -16,6 +16,8 @@ function enqueue_block_api_plugin_script() { plugins_url( 'block-api/index.js', __FILE__ ), array( 'wp-blocks', + 'wp-block-editor', + 'wp-element', 'wp-hooks', ), filemtime( plugin_dir_path( __FILE__ ) . 'block-api/index.js' ), diff --git a/packages/e2e-tests/plugins/block-api/index.js b/packages/e2e-tests/plugins/block-api/index.js index 99a6ab3cdcc838..cb171ab1b442bd 100644 --- a/packages/e2e-tests/plugins/block-api/index.js +++ b/packages/e2e-tests/plugins/block-api/index.js @@ -1,13 +1,16 @@ ( function () { const { registerBlockType } = wp.blocks; + const { useBlockProps } = wp.blockEditor; + const { createElement: el } = wp.element; const { addFilter } = wp.hooks; registerBlockType( 'e2e-tests/hello-world', { + apiVersion: 3, title: 'Hello World', description: 'Hello World test block.', category: 'widgets', - edit() { - return 'Hello Editor!'; + edit: function Edit() { + return el( 'p', useBlockProps(), 'Hello Editor!' ); }, save() { return 'Hello Frontend!'; diff --git a/packages/e2e-tests/plugins/block-context.php b/packages/e2e-tests/plugins/block-context.php index d1cc347640718b..d245efce2a6156 100644 --- a/packages/e2e-tests/plugins/block-context.php +++ b/packages/e2e-tests/plugins/block-context.php @@ -8,10 +8,10 @@ */ /** - * Enqueues a custom script for the plugin. + * Registers plugin test context blocks. */ -function gutenberg_test_enqueue_block_context_script() { - wp_enqueue_script( +function gutenberg_test_register_context_blocks() { + wp_register_script( 'gutenberg-test-block-context', plugins_url( 'block-context/index.js', __FILE__ ), array( @@ -22,37 +22,32 @@ function gutenberg_test_enqueue_block_context_script() { filemtime( plugin_dir_path( __FILE__ ) . 'block-context/index.js' ), true ); -} -add_action( 'init', 'gutenberg_test_enqueue_block_context_script' ); -/** - * Registers plugin test context blocks. - */ -function gutenberg_test_register_context_blocks() { register_block_type( 'gutenberg/test-context-provider', array( - 'attributes' => array( + 'attributes' => array( 'recordId' => array( 'type' => 'number', 'default' => 0, ), ), - 'provides_context' => array( + 'provides_context' => array( 'gutenberg/recordId' => 'recordId', ), + 'editor_script_handles' => array( 'gutenberg-test-block-context' ), ) ); register_block_type( 'gutenberg/test-context-consumer', array( - 'uses_context' => array( + 'uses_context' => array( 'gutenberg/recordId', 'postId', 'postType', ), - 'render_callback' => function( $attributes, $content, $block ) { + 'render_callback' => static function( $attributes, $content, $block ) { $ordered_context = array( $block->context['gutenberg/recordId'], $block->context['postId'], @@ -61,6 +56,7 @@ function gutenberg_test_register_context_blocks() { return implode( ',', $ordered_context ); }, + 'editor_script_handles' => array( 'gutenberg-test-block-context' ), ) ); } diff --git a/packages/e2e-tests/plugins/deprecated-node-matcher.php b/packages/e2e-tests/plugins/deprecated-node-matcher.php index 7e0a6e3d2c5578..0356718b02e974 100644 --- a/packages/e2e-tests/plugins/deprecated-node-matcher.php +++ b/packages/e2e-tests/plugins/deprecated-node-matcher.php @@ -15,7 +15,6 @@ function enqueue_deprecated_node_matcher_plugin_script() { 'gutenberg-test-deprecated-node-matcher', plugins_url( 'deprecated-node-matcher/index.js', __FILE__ ), array( - 'lodash', 'wp-blocks', 'wp-element', 'wp-block-editor', diff --git a/packages/e2e-tests/plugins/deprecated-node-matcher/index.js b/packages/e2e-tests/plugins/deprecated-node-matcher/index.js index a7edacf6739b86..94b59f6190166e 100644 --- a/packages/e2e-tests/plugins/deprecated-node-matcher/index.js +++ b/packages/e2e-tests/plugins/deprecated-node-matcher/index.js @@ -1,9 +1,10 @@ ( function () { const registerBlockType = wp.blocks.registerBlockType; - const RichText = wp.blockEditor.RichText; + const { useBlockProps, RichText } = wp.blockEditor; const el = wp.element.createElement; registerBlockType( 'core/deprecated-children-matcher', { + apiVersion: 3, title: 'Deprecated Children Matcher', attributes: { value: { @@ -13,40 +14,35 @@ }, }, category: 'text', - edit( { attributes, setAttributes } ) { + edit: function EditChildrenMatcher( { attributes, setAttributes } ) { return el( RichText, { tagName: 'p', value: attributes.value, onChange( nextValue ) { setAttributes( { value: nextValue } ); }, + ...useBlockProps(), } ); }, save( { attributes } ) { return el( RichText.Content, { tagName: 'p', value: attributes.value, + ...useBlockProps.save(), } ); }, } ); function toRichTextValue( value ) { - // eslint-disable-next-line no-undef - return _.map( value, function ( subValue ) { - return subValue.children; - } ); + return value?.map( ( { children } ) => children ) ?? []; } function fromRichTextValue( value ) { - // eslint-disable-next-line no-undef - return _.map( value, function ( subValue ) { - return { - children: subValue, - }; - } ); + return value.map( ( subValue ) => ( { children: subValue } ) ); } registerBlockType( 'core/deprecated-node-matcher', { + apiVersion: 3, title: 'Deprecated Node Matcher', attributes: { value: { @@ -61,10 +57,10 @@ }, }, category: 'text', - edit( { attributes, setAttributes } ) { + edit: function EditNodeMatcher( { attributes, setAttributes } ) { return el( 'blockquote', - {}, + useBlockProps(), el( RichText, { multiline: 'p', value: toRichTextValue( attributes.value ), @@ -74,12 +70,12 @@ } ); }, } ) - ); + ) }, save( { attributes } ) { return el( 'blockquote', - {}, + useBlockProps.save(), el( RichText.Content, { value: toRichTextValue( attributes.value ), } ) diff --git a/packages/e2e-tests/plugins/iframed-block.php b/packages/e2e-tests/plugins/iframed-block.php index 29e71fa75a2ec8..574a48400ea2fb 100644 --- a/packages/e2e-tests/plugins/iframed-block.php +++ b/packages/e2e-tests/plugins/iframed-block.php @@ -9,14 +9,14 @@ add_action( 'setup_theme', - function() { + static function() { add_theme_support( 'block-templates' ); } ); add_action( 'init', - function() { + static function() { wp_register_script( 'iframed-block-jquery-test', plugin_dir_url( __FILE__ ) . 'iframed-block/jquery.test.js', diff --git a/packages/e2e-tests/plugins/iframed-block/block.json b/packages/e2e-tests/plugins/iframed-block/block.json index f1110534e27dc1..85f86dea8131d9 100644 --- a/packages/e2e-tests/plugins/iframed-block/block.json +++ b/packages/e2e-tests/plugins/iframed-block/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "test/iframed-block", "title": "Iframed Block", "category": "text", diff --git a/packages/e2e-tests/plugins/iframed-enqueue-block-assets.php b/packages/e2e-tests/plugins/iframed-enqueue-block-assets.php index c85c77fe11d0e9..3f24a6e25cfcb5 100644 --- a/packages/e2e-tests/plugins/iframed-enqueue-block-assets.php +++ b/packages/e2e-tests/plugins/iframed-enqueue-block-assets.php @@ -9,7 +9,7 @@ add_action( 'enqueue_block_assets', - function() { + static function() { wp_enqueue_style( 'iframed-enqueue-block-assets', plugin_dir_url( __FILE__ ) . 'iframed-enqueue-block-assets/style.css', @@ -17,5 +17,18 @@ function() { filemtime( plugin_dir_path( __FILE__ ) . 'iframed-enqueue-block-assets/style.css' ) ); wp_add_inline_style( 'iframed-enqueue-block-assets', 'body{padding:20px!important}' ); + wp_enqueue_script( + 'iframed-enqueue-block-assets-script', + plugin_dir_url( __FILE__ ) . 'iframed-enqueue-block-assets/script.js', + array(), + filemtime( plugin_dir_path( __FILE__ ) . 'iframed-enqueue-block-assets/script.js' ) + ); + wp_localize_script( + 'iframed-enqueue-block-assets-script', + 'iframedEnqueueBlockAssetsL10n', + array( + 'test' => 'Iframed Enqueue Block Assets!', + ) + ); } ); diff --git a/packages/e2e-tests/plugins/iframed-enqueue-block-assets/script.js b/packages/e2e-tests/plugins/iframed-enqueue-block-assets/script.js new file mode 100644 index 00000000000000..f0eddd65c70ebe --- /dev/null +++ b/packages/e2e-tests/plugins/iframed-enqueue-block-assets/script.js @@ -0,0 +1,3 @@ +window.addEventListener( 'load', () => { + document.body.dataset.iframedEnqueueBlockAssetsL10n = window.iframedEnqueueBlockAssetsL10n.test; +} ); diff --git a/packages/e2e-tests/plugins/iframed-enqueue-block-editor-settings.php b/packages/e2e-tests/plugins/iframed-enqueue-block-editor-settings.php new file mode 100644 index 00000000000000..2e5eb32c042784 --- /dev/null +++ b/packages/e2e-tests/plugins/iframed-enqueue-block-editor-settings.php @@ -0,0 +1,19 @@ +<?php +/** + * Plugin Name: Gutenberg Test Iframed enqueue block editor settings + * Plugin URI: https://github.com/WordPress/gutenberg + * Author: Gutenberg Team + * + * @package gutenberg-test-iframed-iframed-enqueue-block-editor-settings + */ + +add_action( + 'block_editor_settings_all', + function( $settings ) { + $settings['styles'][] = array( + 'css' => 'p { border: 1px solid red }', + '__unstableType' => 'plugin', + ); + return $settings; + } +); diff --git a/packages/e2e-tests/plugins/iframed-inline-styles.php b/packages/e2e-tests/plugins/iframed-inline-styles.php index f54c8eb83e6233..ec44e371af6f08 100644 --- a/packages/e2e-tests/plugins/iframed-inline-styles.php +++ b/packages/e2e-tests/plugins/iframed-inline-styles.php @@ -9,14 +9,14 @@ add_action( 'setup_theme', - function() { + static function() { add_theme_support( 'block-templates' ); } ); add_action( 'init', - function() { + static function() { wp_register_script( 'iframed-inline-styles-editor-script', plugin_dir_url( __FILE__ ) . 'iframed-inline-styles/editor.js', @@ -40,7 +40,7 @@ function() { add_action( 'enqueue_block_editor_assets', - function() { + static function() { wp_enqueue_style( 'iframed-inline-styles-compat-style', plugin_dir_url( __FILE__ ) . 'iframed-inline-styles/compat-style.css', diff --git a/packages/e2e-tests/plugins/iframed-inline-styles/block.json b/packages/e2e-tests/plugins/iframed-inline-styles/block.json index 9e30ecfd6ca544..293b5af5e971f4 100644 --- a/packages/e2e-tests/plugins/iframed-inline-styles/block.json +++ b/packages/e2e-tests/plugins/iframed-inline-styles/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "test/iframed-inline-styles", "title": "Iframed Inline Styles", "category": "text", diff --git a/packages/e2e-tests/plugins/iframed-inline-styles/editor.js b/packages/e2e-tests/plugins/iframed-inline-styles/editor.js index 1898e630758957..719c36a912b5d8 100644 --- a/packages/e2e-tests/plugins/iframed-inline-styles/editor.js +++ b/packages/e2e-tests/plugins/iframed-inline-styles/editor.js @@ -4,7 +4,7 @@ const { useBlockProps } = blockEditor; registerBlockType( 'test/iframed-inline-styles', { - apiVersion: 2, + apiVersion: 3, edit: function Edit() { return el( 'div', useBlockProps(), 'Edit' ); }, diff --git a/packages/e2e-tests/plugins/iframed-masonry-block.php b/packages/e2e-tests/plugins/iframed-masonry-block.php index d8d6ea14120f85..3e7948ae2fa127 100644 --- a/packages/e2e-tests/plugins/iframed-masonry-block.php +++ b/packages/e2e-tests/plugins/iframed-masonry-block.php @@ -9,14 +9,14 @@ add_action( 'setup_theme', - function() { + static function() { add_theme_support( 'block-templates' ); } ); add_action( 'init', - function() { + static function() { wp_register_script( 'iframed-masonry-block-editor', plugin_dir_url( __FILE__ ) . 'iframed-masonry-block/editor.js', diff --git a/packages/e2e-tests/plugins/iframed-masonry-block/block.json b/packages/e2e-tests/plugins/iframed-masonry-block/block.json index 6b7c995df96494..b9b3f17234e30d 100644 --- a/packages/e2e-tests/plugins/iframed-masonry-block/block.json +++ b/packages/e2e-tests/plugins/iframed-masonry-block/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "test/iframed-masonry-block", "title": "Iframed Masonry Block", "category": "text", diff --git a/packages/e2e-tests/plugins/iframed-masonry-block/editor.js b/packages/e2e-tests/plugins/iframed-masonry-block/editor.js index 82b0c8d83a7da6..eebd7d370e085a 100644 --- a/packages/e2e-tests/plugins/iframed-masonry-block/editor.js +++ b/packages/e2e-tests/plugins/iframed-masonry-block/editor.js @@ -31,7 +31,7 @@ ]; registerBlockType( 'test/iframed-masonry-block', { - apiVersion: 2, + apiVersion: 3, edit: function Edit() { const ref = useRefEffect( ( node ) => { const { ownerDocument } = node; diff --git a/packages/e2e-tests/plugins/iframed-multiple-stylesheets.php b/packages/e2e-tests/plugins/iframed-multiple-stylesheets.php index 40d7d5040083cf..e70ba31938d14b 100644 --- a/packages/e2e-tests/plugins/iframed-multiple-stylesheets.php +++ b/packages/e2e-tests/plugins/iframed-multiple-stylesheets.php @@ -9,14 +9,14 @@ add_action( 'setup_theme', - function() { + static function() { add_theme_support( 'block-templates' ); } ); add_action( 'init', - function() { + static function() { wp_register_script( 'iframed-multiple-stylesheets-editor-script', plugin_dir_url( __FILE__ ) . 'iframed-multiple-stylesheets/editor.js', diff --git a/packages/e2e-tests/plugins/iframed-multiple-stylesheets/block.json b/packages/e2e-tests/plugins/iframed-multiple-stylesheets/block.json index fbd2e65e133970..4db03f471177d7 100644 --- a/packages/e2e-tests/plugins/iframed-multiple-stylesheets/block.json +++ b/packages/e2e-tests/plugins/iframed-multiple-stylesheets/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "test/iframed-multiple-stylesheets", "title": "Iframed Multiple Stylesheets", "category": "text", diff --git a/packages/e2e-tests/plugins/iframed-multiple-stylesheets/editor.js b/packages/e2e-tests/plugins/iframed-multiple-stylesheets/editor.js index be3ded2d9e2b73..fce08fadff850a 100644 --- a/packages/e2e-tests/plugins/iframed-multiple-stylesheets/editor.js +++ b/packages/e2e-tests/plugins/iframed-multiple-stylesheets/editor.js @@ -4,7 +4,7 @@ const { useBlockProps } = blockEditor; registerBlockType( 'test/iframed-multiple-stylesheets', { - apiVersion: 2, + apiVersion: 3, edit: function Edit() { return el( 'div', useBlockProps(), 'Edit' ); }, diff --git a/packages/e2e-tests/plugins/inner-blocks-allowed-blocks.php b/packages/e2e-tests/plugins/inner-blocks-allowed-blocks.php index a8a0e6ee9a254c..0cc7566f3e5f10 100644 --- a/packages/e2e-tests/plugins/inner-blocks-allowed-blocks.php +++ b/packages/e2e-tests/plugins/inner-blocks-allowed-blocks.php @@ -12,7 +12,7 @@ */ function enqueue_inner_blocks_allowed_blocks_script() { wp_enqueue_script( - 'gutenberg-test-block-icons', + 'gutenberg-test-inner-blocks-allowed-blocks', plugins_url( 'inner-blocks-allowed-blocks/index.js', __FILE__ ), array( 'wp-blocks', @@ -24,5 +24,4 @@ function enqueue_inner_blocks_allowed_blocks_script() { true ); } - -add_action( 'init', 'enqueue_inner_blocks_allowed_blocks_script' ); +add_action( 'enqueue_block_assets', 'enqueue_inner_blocks_allowed_blocks_script' ); diff --git a/packages/e2e-tests/plugins/inner-blocks-allowed-blocks/index.js b/packages/e2e-tests/plugins/inner-blocks-allowed-blocks/index.js index 88585a87a84882..c32307b2d49ff1 100644 --- a/packages/e2e-tests/plugins/inner-blocks-allowed-blocks/index.js +++ b/packages/e2e-tests/plugins/inner-blocks-allowed-blocks/index.js @@ -1,87 +1,47 @@ ( function () { - const { withSelect } = wp.data; + const { useSelect } = wp.data; const { registerBlockType } = wp.blocks; const { createElement: el } = wp.element; const { InnerBlocks } = wp.blockEditor; - const __ = wp.i18n.__; const divProps = { className: 'product', style: { outline: '1px solid gray', padding: 5 }, }; - const template = [ - [ 'core/image' ], - [ 'core/paragraph', { placeholder: __( 'Add a description' ) } ], - [ 'core/quote' ], - ]; + const allowedBlocksWhenSingleEmptyChild = [ 'core/image', 'core/list' ]; const allowedBlocksWhenMultipleChildren = [ 'core/gallery', 'core/video' ]; - const save = function () { - return el( 'div', divProps, el( InnerBlocks.Content ) ); - }; - registerBlockType( 'test/allowed-blocks-unset', { - title: 'Allowed Blocks Unset', - icon: 'carrot', - category: 'text', - - edit() { - return el( 'div', divProps, el( InnerBlocks, { template } ) ); - }, - - save, - } ); - - registerBlockType( 'test/allowed-blocks-set', { - title: 'Allowed Blocks Set', - icon: 'carrot', - category: 'text', - - edit() { - return el( - 'div', - divProps, - el( InnerBlocks, { - template, - allowedBlocks: [ - 'core/button', - 'core/gallery', - 'core/list', - 'core/media-text', - 'core/quote', - ], - } ) - ); - }, - - save, - } ); - registerBlockType( 'test/allowed-blocks-dynamic', { + apiVersion: 3, title: 'Allowed Blocks Dynamic', icon: 'carrot', category: 'text', - edit: withSelect( function ( select, ownProps ) { - const getBlockOrder = select( 'core/block-editor' ).getBlockOrder; - return { - numberOfChildren: getBlockOrder( ownProps.clientId ).length, - }; - } )( function ( props ) { + edit: function Edit( props ) { + const numberOfChildren = useSelect( + ( select ) => { + const { getBlockCount } = select( 'core/block-editor' ); + return getBlockCount( props.clientId ); + }, + [ props.clientId ] + ); + return el( 'div', { ...divProps, - 'data-number-of-children': props.numberOfChildren, + 'data-number-of-children': numberOfChildren, }, el( InnerBlocks, { allowedBlocks: - props.numberOfChildren < 2 + numberOfChildren < 2 ? allowedBlocksWhenSingleEmptyChild : allowedBlocksWhenMultipleChildren, } ) ); - } ), - - save, + }, + save() { + return el( 'div', divProps, el( InnerBlocks.Content ) ); + } } ); } )(); diff --git a/packages/e2e-tests/plugins/inner-blocks-templates/index.js b/packages/e2e-tests/plugins/inner-blocks-templates/index.js index 959f4a3eeb5a0b..5a49a0fa0aa057 100644 --- a/packages/e2e-tests/plugins/inner-blocks-templates/index.js +++ b/packages/e2e-tests/plugins/inner-blocks-templates/index.js @@ -2,7 +2,7 @@ const registerBlockType = wp.blocks.registerBlockType; const createBlock = wp.blocks.createBlock; const el = wp.element.createElement; - const InnerBlocks = wp.blockEditor.InnerBlocks; + const { InnerBlocks, useBlockProps } = wp.blockEditor; const useState = window.wp.element.useState; const TEMPLATE = [ @@ -47,35 +47,38 @@ }; registerBlockType( 'test/test-inner-blocks-no-locking', { + apiVersion: 3, title: 'Test Inner Blocks no locking', icon: 'cart', category: 'text', - edit() { - return el( InnerBlocks, { + edit: function InnerBlocksNoLockingEdit() { + return el( 'div', useBlockProps(), el( InnerBlocks, { template: TEMPLATE, - } ); + } ) ); }, save, } ); registerBlockType( 'test/test-inner-blocks-locking-all', { + apiVersion: 3, title: 'Test InnerBlocks locking all', icon: 'cart', category: 'text', - edit() { - return el( InnerBlocks, { + edit: function InnerBlocksBlocksLockingAllEdit() { + return el( 'div', useBlockProps(), el( InnerBlocks, { template: TEMPLATE, templateLock: 'all', - } ); + } ) ); }, save, } ); registerBlockType( 'test/test-inner-blocks-update-locked-template', { + apiVersion: 3, title: 'Test Inner Blocks update locked template', icon: 'cart', category: 'text', @@ -87,7 +90,7 @@ }, }, - edit( props ) { + edit: function InnerBlocksUpdateLockedTemplateEdit( props ) { const hasUpdatedTemplated = props.attributes.hasUpdatedTemplate; return el( 'div', null, [ el( @@ -99,12 +102,12 @@ }, 'Update template' ), - el( InnerBlocks, { + el( 'div', useBlockProps(), el( InnerBlocks, { template: hasUpdatedTemplated ? TEMPLATE_TWO_PARAGRAPHS : TEMPLATE, templateLock: 'all', - } ), + } ) ), ] ); }, @@ -112,21 +115,23 @@ } ); registerBlockType( 'test/test-inner-blocks-paragraph-placeholder', { + apiVersion: 3, title: 'Test Inner Blocks Paragraph Placeholder', icon: 'cart', category: 'text', - edit() { - return el( InnerBlocks, { + edit: function InnerBlocksParagraphPlaceholderEdit() { + return el( 'div', useBlockProps(), el( InnerBlocks, { template: TEMPLATE_PARAGRAPH_PLACEHOLDER, templateInsertUpdatesSelection: true, - } ); + } ) ); }, save, } ); registerBlockType( 'test/test-inner-blocks-transformer-target', { + apiVersion: 3, title: 'Test Inner Blocks transformer target', icon: 'cart', category: 'text', @@ -165,36 +170,34 @@ ], }, - edit() { - return el( InnerBlocks, { + edit: function InnerBlocksTransformerTargetEdit() { + return el( 'div', useBlockProps(), el( InnerBlocks, { template: TEMPLATE, - } ); + } ) ); }, save, } ); - - function InnerBlocksAsyncTemplateEdit() { - const [ template, setTemplate ] = useState( [] ); - - setInterval( () => { - setTemplate( TEMPLATE_TWO_PARAGRAPHS ); - }, 1000 ); - - return el( InnerBlocks, { - template, - } ); - } - registerBlockType( 'test/test-inner-blocks-async-template', { + apiVersion: 3, title: 'Test Inner Blocks Async Template', icon: 'cart', category: 'text', - edit: InnerBlocksAsyncTemplateEdit, + edit: function InnerBlocksAsyncTemplateEdit() { + const [ template, setTemplate ] = useState( [] ); + + setInterval( () => { + setTemplate( TEMPLATE_TWO_PARAGRAPHS ); + }, 1000 ); + + return el('div', useBlockProps(), el( InnerBlocks, { + template, + } ) ); + }, // Purposely do not save inner blocks so that it's possible to test template resolution. save() {}, diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php new file mode 100644 index 00000000000000..8a44e5a0efd4ac --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks.php @@ -0,0 +1,48 @@ +<?php +/** + * Plugin Name: Gutenberg Test Interactive Blocks + * Plugin URI: https://github.com/WordPress/gutenberg + * Author: Gutenberg Team + * + * @package gutenberg-test-interactive-blocks + */ + +add_action( + 'init', + function() { + // Register all blocks found in the `interactive-blocks` folder. + if ( file_exists( __DIR__ . '/interactive-blocks/' ) ) { + $block_json_files = glob( __DIR__ . '/interactive-blocks/**/block.json' ); + + // Auto register all blocks that were found. + foreach ( $block_json_files as $filename ) { + $block_folder = dirname( $filename ); + $name = basename( $block_folder ); + + $view_file = plugin_dir_url( $block_folder ) . $name . '/' . 'view.js'; + + wp_register_script( + $name . '-view', + $view_file, + array( 'wp-interactivity' ), + filemtime( $view_file ), + true + ); + + register_block_type_from_metadata( $block_folder ); + }; + }; + + // Temporary fix to disable SSR of directives during E2E testing. This + // is required at this moment, as SSR for directives is not stabilized + // yet and we need to ensure hydration works, even when the rendered + // HTML is not correct or malformed. + if ( 'true' === $_GET['disable_directives_ssr'] ) { + remove_filter( + 'render_block', + 'gutenberg_interactivity_process_directives_in_root_blocks' + ); + } + + } +); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/block.json new file mode 100644 index 00000000000000..f0775cecd8ae69 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-bind", + "title": "E2E Interactivity tests - directive bind", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-bind-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php new file mode 100644 index 00000000000000..a94eb20bfa6d54 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php @@ -0,0 +1,99 @@ +<?php +/** + * HTML for testing the directive `data-wp-bind`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <a + data-wp-bind--href="state.url" + data-testid="add missing href at hydration" + ></a> + + <a + href="/other-url" + data-wp-bind--href="state.url" + data-testid="change href at hydration" + ></a> + + <input + type="checkbox" + data-wp-bind--checked="state.checked" + data-testid="add missing checked at hydration" + /> + + <input + type="checkbox" + checked + data-wp-bind--checked="!state.checked" + data-testid="remove existing checked at hydration" + /> + + <a + href="/other-url" + data-wp-bind--href="state.url" + data-testid="nested binds - 1" + > + <img + width="1" + data-wp-bind--width="state.width" + data-testid="nested binds - 2" + /> + </a> + + <button data-testid="toggle" data-wp-on--click="actions.toggle"> + Update + </button> + + <p + data-wp-bind--hidden="!state.show" + data-wp-bind--aria-hidden="!state.show" + data-wp-bind--aria-expanded="state.show" + data-wp-bind--data-some-value="state.show" + data-testid="check enumerated attributes with true/false exist and have a string value" + > + Some Text + </p> + + <?php + $hydration_cases = array( + 'false' => '{ "value": false }', + 'true' => '{ "value": true }', + 'null' => '{ "value": null }', + 'undef' => '{ "__any": "any" }', + 'emptyString' => '{ "value": "" }', + 'anyString' => '{ "value": "any" }', + 'number' => '{ "value": 10 }', + ); + ?> + + <?php foreach ( $hydration_cases as $type => $context ) : ?> + <div + data-testid='hydrating <?php echo $type; ?>' + data-wp-context='<?php echo $context; ?>' + > + <img + alt="Red dot" + data-testid="image" + data-wp-bind--width="context.value" + src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA + AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO + 9TXL0Y4OHwAAAABJRU5ErkJggg==" + > + <input + type="text" + data-testid="input" + data-wp-bind--name="context.value" + data-wp-bind--value="context.value" + data-wp-bind--disabled="context.value" + data-wp-bind--aria-disabled="context.value" + > + <button + data-testid="toggle value" + data-wp-on--click="actions.toggleValue" + >Toggle</button> + </div> + <?php endforeach; ?> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js new file mode 100644 index 00000000000000..cbe562f5e25499 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js @@ -0,0 +1,34 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + url: '/some-url', + checked: true, + show: false, + width: 1, + }, + foo: { + bar: 1, + }, + actions: { + toggle: ( { state, foo } ) => { + state.url = '/some-other-url'; + state.checked = ! state.checked; + state.show = ! state.show; + state.width += foo.bar; + }, + toggleValue: ( { context } ) => { + const previousValue = ( 'previousValue' in context ) + ? context.previousValue + // Any string works here; we just want to toggle the value + // to ensure Preact renders the same we are hydrating in the + // first place. + : 'tacocat'; + + context.previousValue = context.value; + context.value = previousValue; + } + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-body/block.json new file mode 100644 index 00000000000000..61b48396be08ae --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-body", + "title": "E2E Interactivity tests - directive body", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-body-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php new file mode 100644 index 00000000000000..5e24b7d7a3b9b5 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php @@ -0,0 +1,22 @@ +<?php +/** + * HTML for testing the directive `data-wp-body`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div + data-wp-interactive + data-wp-context='{"text":"text-1"}' +> + <div data-testid="container"> + <aside data-wp-body data-testid="element with data-wp-body"> + <p data-wp-text="context.text" data-testid="text">initial</p> + </aside> + </div> + <button + data-wp-on--click="actions.toggleText" + data-testid="toggle text" + >toggle text</button> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js new file mode 100644 index 00000000000000..f3cbc521f4355b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js @@ -0,0 +1,11 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + actions: { + toggleText: ( { context } ) => { + context.text = context.text === 'text-1' ? 'text-2' : 'text-1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json new file mode 100644 index 00000000000000..af2764db986919 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-class", + "title": "E2E Interactivity tests - directive class", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-class-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php new file mode 100644 index 00000000000000..b229418de2f67d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -0,0 +1,86 @@ +<?php +/** + * HTML for testing the directive `data-wp-class`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <button + data-wp-on--click="actions.toggleTrueValue" + data-testid="toggle trueValue" + > + Toggle trueValue + </button> + + <button + data-wp-on--click="actions.toggleFalseValue" + data-testid="toggle falseValue" + > + Toggle falseValue + </button> + + <div + class="foo bar" + data-wp-class--foo="state.falseValue" + data-testid="remove class if callback returns falsy value" + ></div> + + <div + class="foo" + data-wp-class--bar="state.trueValue" + data-testid="add class if callback returns truthy value" + ></div> + + <div + class="foo bar" + data-wp-class--foo="state.falseValue" + data-wp-class--bar="state.trueValue" + data-wp-class--baz="state.trueValue" + data-testid="handles multiple classes and callbacks" + ></div> + + <div + class="foo foo-bar" + data-wp-class--foo="state.falseValue" + data-wp-class--foo-bar="state.trueValue" + data-testid="handles class names that are contained inside other class names" + ></div> + + <div + class="foo bar baz" + data-wp-class--bar="state.trueValue" + data-testid="can toggle class in the middle" + ></div> + + <div + data-wp-class--foo="state.falseValue" + data-testid="can toggle class when class attribute is missing" + ></div> + + <div data-wp-context='{ "falseValue": false }'> + <div + class="foo" + data-wp-class--foo="context.falseValue" + data-testid="can use context values" + ></div> + <button + data-wp-on--click="actions.toggleContextFalseValue" + data-testid="toggle context false value" + > + Toggle context falseValue + </button> + </div> + + <div + data-wp-class--block__element--modifier="state.trueValue" + data-testid="can use BEM notation classes" + ></div> + + <div + data-wp-class--main-bg----color="state.trueValue" + data-testid="can use classes with several dashes" + ></div> + +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js new file mode 100644 index 00000000000000..bb06cf38412811 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js @@ -0,0 +1,21 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json new file mode 100644 index 00000000000000..1b3c448cc62aac --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-context", + "title": "E2E Interactivity tests - directive context", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-context-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php new file mode 100644 index 00000000000000..e64686e02d5581 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -0,0 +1,134 @@ +<?php +/** + * HTML for testing the directive `data-wp-context`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <div + data-wp-context='{ "prop1":"parent","prop2":"parent","obj":{"prop4":"parent","prop5":"parent"},"array":[1,2,3] }' + > + <pre + data-testid="parent context" + data-wp-bind--children="derived.renderContext" + > + <!-- rendered during hydration --> + </pre> + <button + data-testid="parent prop1" + name="prop1" + value="modifiedFromParent" + data-wp-on--click="actions.updateContext" + > + prop1 + </button> + <button + data-testid="parent prop2" + name="prop2" + value="modifiedFromParent" + data-wp-on--click="actions.updateContext" + > + prop2 + </button> + <button + data-testid="parent obj.prop4" + name="obj.prop4" + value="modifiedFromParent" + data-wp-on--click="actions.updateContext" + > + obj.prop4 + </button> + <button + data-testid="parent obj.prop5" + name="obj.prop5" + value="modifiedFromParent" + data-wp-on--click="actions.updateContext" + > + obj.prop5 + </button> + <div + data-wp-context='{ "prop2":"child","prop3":"child","obj":{"prop5":"child","prop6":"child"},"array":[4,5,6] }' + > + <pre + data-testid="child context" + data-wp-bind--children="derived.renderContext" + > + <!-- rendered during hydration --> + </pre> + <button + data-testid="child prop1" + name="prop1" + value="modifiedFromChild" + data-wp-on--click="actions.updateContext" + > + prop1 + </button> + <button + data-testid="child prop2" + name="prop2" + value="modifiedFromChild" + data-wp-on--click="actions.updateContext" + > + prop2 + </button> + <button + data-testid="child prop3" + name="prop3" + value="modifiedFromChild" + data-wp-on--click="actions.updateContext" + > + prop3 + </button> + <button + data-testid="child obj.prop4" + name="obj.prop4" + value="modifiedFromChild" + data-wp-on--click="actions.updateContext" + > + obj.prop4 + </button> + <button + data-testid="child obj.prop5" + name="obj.prop5" + value="modifiedFromChild" + data-wp-on--click="actions.updateContext" + > + obj.prop5 + </button> + <button + data-testid="child obj.prop6" + name="obj.prop6" + value="modifiedFromChild" + data-wp-on--click="actions.updateContext" + > + obj.prop6 + </button> + </div> + <br /> + + <button + data-testid="context & other directives" + data-wp-context='{ "text": "Text 1" }' + data-wp-text="context.text" + data-wp-on--click="actions.toggleContextText" + data-wp-bind--value="context.text" + > + Toggle Context Text + </button> + </div> +</div> + +<div + data-wp-interactive + data-wp-navigation-id="navigation" + data-wp-context='{ "text": "first page" }' +> + <div data-testid="navigation text" data-wp-text="context.text"></div> + <div data-testid="navigation new text" data-wp-text="context.newText"></div> + <button data-testid="toggle text" data-wp-on--click="actions.toggleText">Toggle Text</button> + <button data-testid="add new text" data-wp-on--click="actions.addNewText">Add New Text</button> + <button data-testid="navigate" data-wp-on--click="actions.navigate">Navigate</button> + <button data-testid="async navigate" data-wp-on--click="actions.asyncNavigate">Async Navigate</button> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js new file mode 100644 index 00000000000000..1bab3946a3d4b5 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -0,0 +1,55 @@ +( ( { wp } ) => { + const { store, navigate } = wp.interactivity; + + const html = ` + <div + data-wp-interactive + data-wp-navigation-id="navigation" + data-wp-context='{ "text": "second page" }' + > + <div data-testid="navigation text" data-wp-text="context.text"></div> + <div data-testid="navigation new text" data-wp-text="context.newText"></div> + <button data-testid="toggle text" data-wp-on--click="actions.toggleText">Toggle Text</button> + <button data-testid="add new text" data-wp-on--click="actions.addNewText">Add new text</button> + <button data-testid="navigate" data-wp-on--click="actions.navigate">Navigate</button> + <button data-testid="async navigate" data-wp-on--click="actions.asyncNavigate">Async Navigate</button> + </div>`; + + store( { + derived: { + renderContext: ( { context } ) => { + return JSON.stringify( context, undefined, 2 ); + }, + }, + actions: { + updateContext: ( { context, event } ) => { + const { name, value } = event.target; + const [ key, ...path ] = name.split( '.' ).reverse(); + const obj = path.reduceRight( ( o, k ) => o[ k ], context ); + obj[ key ] = value; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + toggleText: ( { context } ) => { + context.text = "changed dynamically"; + }, + addNewText: ( { context } ) => { + context.newText = 'some new text'; + }, + navigate: () => { + navigate( window.location, { + force: true, + html, + } ); + }, + asyncNavigate: async ({ context }) => { + await navigate( window.location, { + force: true, + html, + } ); + context.newText = 'changed from async action'; + } + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json new file mode 100644 index 00000000000000..b9cb2f782b2e6f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-effect", + "title": "E2E Interactivity tests - directive effect", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-effect-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php new file mode 100644 index 00000000000000..3ae11975d329e5 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php @@ -0,0 +1,39 @@ +<?php +/** + * HTML for testing the directive `data-wp-effect`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <div data-wp-show-mock="state.isOpen"> + <input + data-testid="input" + data-wp-effect="effects.elementAddedToTheDOM" + /> + </div> + + <div + data-wp-text="selectors.elementInTheDOM" + data-testid="element in the DOM" + ></div> + + <div data-wp-effect="effects.changeFocus"></div> + + <div + data-testid="short-circuit infinite loops" + data-wp-effect="effects.infiniteLoop" + data-wp-text="state.counter" + > + 0 + </div> + + <button data-testid="toggle" data-wp-on--click="actions.toggle"> + Update + </button> + + <button data-testid="increment" data-wp-on--click="actions.increment"> + Increment + </button> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js new file mode 100644 index 00000000000000..bd982775648845 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js @@ -0,0 +1,59 @@ +( ( { wp } ) => { + const { store, directive } = wp.interactivity; + + // Fake `data-wp-show-mock` directive to test when things are removed from the + // DOM. Replace with `data-wp-show` when it's ready. + directive( + 'show-mock', + ( { + directives: { + "show-mock": { default: showMock }, + }, + element, + evaluate, + } ) => { + if ( ! evaluate( showMock ) ) return null; + return element; + } + ); + + store( { + state: { + isOpen: true, + isElementInTheDOM: false, + counter: 0, + }, + selectors: { + elementInTheDOM: ( { state } ) => + state.isElementInTheDOM + ? 'element is in the DOM' + : 'element is not in the DOM', + }, + actions: { + toggle( { state } ) { + state.isOpen = ! state.isOpen; + }, + increment( { state } ) { + state.counter = state.counter + 1; + }, + }, + effects: { + elementAddedToTheDOM: ( { state } ) => { + state.isElementInTheDOM = true; + + return () => { + state.isElementInTheDOM = false; + }; + }, + changeFocus: ( { state } ) => { + if ( state.isOpen ) { + document.querySelector( "[data-testid='input']" ).focus(); + } + }, + infiniteLoop: ({ state }) => { + state.counter = state.counter + 1; + } + }, + } ); + +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-init/block.json new file mode 100644 index 00000000000000..a7e195d2e4884a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-init", + "title": "E2E Interactivity tests - directive init", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-init-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php new file mode 100644 index 00000000000000..76d5b776a68bb3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php @@ -0,0 +1,42 @@ +<?php +/** + * HTML for testing the directive `data-wp-init`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <div + data-testid="single init" + data-wp-context='{"isReady":[false],"calls":[0]}' + data-wp-init="actions.initOne" + > + <p data-wp-text="selector.isReady" data-testid="isReady">false</p> + <p data-wp-text="selector.calls" data-testid="calls">0</p> + <button data-wp-on--click="actions.reset">reset</button> + </div> + <div + data-testid="multiple inits" + data-wp-context='{"isReady":[false,false],"calls":[0,0]}' + data-wp-init--one="actions.initOne" + data-wp-init--two="actions.initTwo" + > + <p data-wp-text="selector.isReady" data-testid="isReady">false,false</p> + <p data-wp-text="selector.calls" data-testid="calls">0,0</p> + </div> + <div + data-testid="init show" + data-wp-context='{"isVisible":true,"isMounted":false}' + > + <div data-wp-show-mock="context.isVisible" data-testid="show"> + <span data-wp-init="actions.initMount">Initially visible</span> + </div> + <button data-wp-on--click="actions.toggle" data-testid="toggle"> + toggle + </button> + <p data-wp-text="selector.isMounted" data-testid="isMounted"> + true + </p> + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js new file mode 100644 index 00000000000000..274809df3a9e5c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js @@ -0,0 +1,64 @@ +( ( { wp } ) => { + const { store, directive, useContext } = wp.interactivity; + + // Mock `data-wp-show` directive to test when things are removed from the + // DOM. Replace with `data-wp-show` when it's ready. + directive( + 'show-mock', + ( { + directives: { + 'show-mock': { default: showMock }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + if ( ! evaluate( showMock, { context: contextValue } ) ) { + return null; + } + return element; + } + ); + + + store( { + selector: { + isReady: ({ context: { isReady } }) => { + return isReady + .map(v => v ? 'true': 'false') + .join(','); + }, + calls: ({ context: { calls } }) => { + return calls.join(','); + }, + isMounted: ({ context }) => { + return context.isMounted ? 'true' : 'false'; + }, + }, + actions: { + initOne: ( { context: { isReady, calls } } ) => { + isReady[0] = true; + // Subscribe to changes in that prop. + isReady[0] = isReady[0]; + calls[0]++; + }, + initTwo: ( { context: { isReady, calls } } ) => { + isReady[1] = true; + calls[1]++; + }, + initMount: ( { context } ) => { + context.isMounted = true; + return () => { + context.isMounted = false; + } + }, + reset: ( { context: { isReady } } ) => { + isReady.fill(false); + }, + toggle: ( { context } ) => { + context.isVisible = ! context.isVisible; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json new file mode 100644 index 00000000000000..0cbdd065e63a1d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-key", + "title": "E2E Interactivity tests - directive key", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-key-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php new file mode 100644 index 00000000000000..07c6e4e3de161d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php @@ -0,0 +1,18 @@ +<?php +/** + * HTML for testing the directive `data-wp-key`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> + +<div data-wp-interactive data-wp-navigation-id="some-id"> + <ul> + <li data-wp-key="id-2" data-testid="first-item">2</li> + <li data-wp-key="id-3">3</li> + </ul> + <button data-testid="navigate" data-wp-on--click="actions.navigate"> + Navigate + </button> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js new file mode 100644 index 00000000000000..a155dec99e0aa9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js @@ -0,0 +1,23 @@ +( ( { wp } ) => { + const { store, navigate } = wp.interactivity; + + const html = ` + <div data-wp-interactive data-wp-navigation-id="some-id"> + <ul> + <li data-wp-key="id-1">1</li> + <li data-wp-key="id-2" data-testid="second-item">2</li> + <li data-wp-key="id-3">3</li> + </ul> + </div>`; + + store( { + actions: { + navigate: () => { + navigate( window.location, { + force: true, + html, + } ); + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-on/block.json new file mode 100644 index 00000000000000..b9d8aa5f9ce57d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-on", + "title": "E2E Interactivity tests - directive on", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-on-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php new file mode 100644 index 00000000000000..9d96c7768a4894 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php @@ -0,0 +1,52 @@ +<?php +/** + * HTML for testing the directive `data-wp-on`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <div> + <p data-wp-text="state.counter" data-testid="counter">0</p> + <button + data-testid="button" + data-wp-on--click="actions.clickHandler" + >Click me!</button> + </div> + <div> + <p data-wp-text="state.text" data-testid="text">initial</p> + <input + type="text" + value="initial" + data-testid="input" + data-wp-on--input="actions.inputHandler" + > + </div> + <div data-wp-context='{"option":"undefined"}'> + <p data-wp-text="context.option" data-testid="option">0</p> + <select + name="pets" + value="undefined" + data-testid="select" + data-wp-on--change="actions.selectHandler" + > + <option value="undefined">Choose an option...</option> + <option value="dog">Dog</option> + <option value="cat">Cat</option> + </select> + </div> + <div + data-wp-on--customevent="actions.customEventHandler" + data-wp-context='{"customEvents":0}' + > + <p + data-wp-text="context.customEvents" + data-testid="custom events counter" + >0</p> + <button + data-testid="custom events button" + data-wp-on--click="actions.clickHandler" + >Click me!</button> + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-on/view.js new file mode 100644 index 00000000000000..c93b4d5ed34e63 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on/view.js @@ -0,0 +1,27 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + counter: 0, + text: '' + }, + actions: { + clickHandler: ( { state, event } ) => { + state.counter += 1; + event.target.dispatchEvent( + new CustomEvent( 'customevent', { bubbles: true } ) + ); + }, + inputHandler: ( { state, event } ) => { + state.text = event.target.value; + }, + selectHandler: ( { context, event } ) => { + context.option = event.target.value; + }, + customEventHandler: ({ context }) => { + context.customEvents += 1; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json new file mode 100644 index 00000000000000..c7361c3d5f121a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-priorities", + "title": "E2E Interactivity tests - directive priorities", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-priorities-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php new file mode 100644 index 00000000000000..13e15b8e141714 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php @@ -0,0 +1,24 @@ +<?php +/** + * HTML for testing priorities between directives. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <pre data-testid="execution order"></pre> + + <!-- Element with test directives --> + <div + data-testid="test directives" + data-wp-test-attribute + data-wp-test-children + data-wp-test-text + data-wp-test-context + ></div> +</div> + +<div data-testid="non-existent-directives"> + <div data-wp-interactive ><div data-wp-non-existent-directive></div></div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js new file mode 100644 index 00000000000000..cedc0c7c1d3ad3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -0,0 +1,121 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { + store, + directive, + deepSignal, + useContext, + useEffect, + createElement: h + } = wp.interactivity; + + /** + * Util to check that render calls happen in order. + * + * @param {string} n Name passed from the directive being executed. + */ + const executionProof = ( n ) => { + const el = document.querySelector( '[data-testid="execution order"]' ); + if ( ! el.textContent ) el.textContent = n; + else el.textContent += `, ${ n }`; + }; + + /** + * Simple context directive, just for testing purposes. It provides a deep + * signal with these two properties: + * - attribute: 'from context' + * - text: 'from context' + */ + directive( + 'test-context', + ( { context: { Provider }, props: { children } } ) => { + executionProof( 'context' ); + const value = deepSignal( { + attribute: 'from context', + text: 'from context', + } ); + return h( Provider, { value }, children ); + }, + { priority: 8 } + ); + + /** + * Simple attribute directive, for testing purposes. It reads the value of + * `attribute` from context and populates `data-attribute` with it. + */ + directive( 'test-attribute', ( { context, evaluate, element } ) => { + executionProof( 'attribute' ); + const contextValue = useContext( context ); + const attributeValue = evaluate( 'context.attribute', { + context: contextValue, + } ); + useEffect( () => { + element.ref.current.setAttribute( + 'data-attribute', + attributeValue, + ); + }, [] ); + element.props[ 'data-attribute' ] = attributeValue; + } ); + + /** + * Simple text directive, for testing purposes. It reads the value of + * `text` from context and populates `children` with it. + */ + directive( + 'test-text', + ( { context, evaluate, element } ) => { + executionProof( 'text' ); + const contextValue = useContext( context ); + const textValue = evaluate( 'context.text', { + context: contextValue, + } ); + element.props.children = + h( 'p', { 'data-testid': 'text' }, textValue ); + }, + { priority: 12 } + ); + + /** + * Children directive, for testing purposes. It adds a wrapper around + * `children`, including two buttons to modify `text` and `attribute` values + * from the received context. + */ + directive( + 'test-children', + ( { context, evaluate, element } ) => { + executionProof( 'children' ); + const contextValue = useContext( context ); + const updateAttribute = () => { + evaluate( + 'actions.updateAttribute', + { context: contextValue } + ); + }; + const updateText = () => { + evaluate( 'actions.updateText', { context: contextValue } ); + }; + element.props.children = h( + 'div', + {}, + element.props.children, + h( 'button', { onClick: updateAttribute }, 'Update attribute' ), + h( 'button', { onClick: updateText }, 'Update text' ) + ); + }, + { priority: 14 } + ); + + store( { + actions: { + updateText( { context } ) { + context.text = 'updated'; + }, + updateAttribute( { context } ) { + context.attribute = 'updated'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-slots/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/block.json new file mode 100644 index 00000000000000..f79f89a6e81b8a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-slots", + "title": "E2E Interactivity tests - directive slots", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-slots-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-slots/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/render.php new file mode 100644 index 00000000000000..5c1558d35403d3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/render.php @@ -0,0 +1,67 @@ +<?php +/** + * HTML for testing the directive `data-wp-bind`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div + data-wp-interactive + data-wp-slot-provider + data-wp-context='{ "text": "fill" }' +> + <div data-testid="slots" data-wp-context='{ "text": "fill inside slots" }'> + <div + data-testid="slot-1" + data-wp-key="slot-1" + data-wp-slot="slot-1" + data-wp-context='{ "text": "fill inside slot 1" }' + >[1]</div> + <div + data-testid="slot-2" + data-wp-key="slot-2" + data-wp-slot='{ "name": "slot-2", "position": "before" }' + data-wp-context='{ "text": "[2]" }' + data-wp-text='context.text' + data-wp-on--click="actions.updateSlotText" + >[2]</div> + <div + data-testid="slot-3" + data-wp-key="slot-3" + data-wp-slot='{ "name": "slot-3", "position": "after" }' + data-wp-context='{ "text": "[3]" }' + data-wp-text='context.text' + data-wp-on--click="actions.updateSlotText" + >[3]</div> + <div + data-testid="slot-4" + data-wp-key="slot-4" + data-wp-slot='{ "name": "slot-4", "position": "children" }' + data-wp-context='{ "text": "fill inside slot 4" }' + >[4]</div> + <div + data-testid="slot-5" + data-wp-key="slot-5" + data-wp-slot='{ "name": "slot-5", "position": "replace" }' + data-wp-context='{ "text": "fill inside slot 5" }' + >[5]</div> + </div> + + <div data-testid="fill-container"> + <span + data-testid="fill" + data-wp-fill="state.slot" + data-wp-text="context.text" + >initial</span> + </div> + + <div data-wp-on--click="actions.changeSlot"> + <button data-testid="slot-1-button" data-slot="slot-1">slot-1</button> + <button data-testid="slot-2-button" data-slot="slot-2">slot-2</button> + <button data-testid="slot-3-button" data-slot="slot-3">slot-3</button> + <button data-testid="slot-4-button" data-slot="slot-4">slot-4</button> + <button data-testid="slot-5-button" data-slot="slot-5">slot-5</button> + <button data-testid="reset" data-slot="">reset</button> + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-slots/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/view.js new file mode 100644 index 00000000000000..ab5b39379f3a84 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/view.js @@ -0,0 +1,18 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + slot: '' + }, + actions: { + changeSlot: ( { state, event } ) => { + state.slot = event.target.dataset.slot; + }, + updateSlotText: ( { context } ) => { + const n = context.text[1]; + context.text = `[${n} updated]`; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-style/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-style/block.json new file mode 100644 index 00000000000000..6cbfa57b0784f1 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-style/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-style", + "title": "E2E Interactivity tests - directive style", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-style-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-style/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-style/render.php new file mode 100644 index 00000000000000..4272333c3ec277 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-style/render.php @@ -0,0 +1,93 @@ +<?php +/** + * HTML for testing the directive `data-wp-style`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> + +<div data-wp-interactive> + <button + data-wp-on--click="actions.toggleColor" + data-testid="toggle color" + > + Toggle Color + </button> + + <button + data-wp-on--click="actions.switchColorToFalse" + data-testid="switch color to false" + > + Switch Color to False + </button> + + <div + style="color: red; background: green;" + data-wp-style--color="state.color" + data-testid="dont change style if callback returns same value on hydration" + >Don't change style if callback returns same value on hydration</div> + + <div + style="color: blue; background: green;" + data-wp-style--color="state.falseValue" + data-testid="remove style if callback returns falsy value on hydration" + >Remove style if callback returns falsy value on hydration</div> + + <div + style="color: blue; background: green;" + data-wp-style--color="state.color" + data-testid="change style if callback returns a new value on hydration" + >Change style if callback returns a new value on hydration</div> + + <div + style="color: blue; background: green; border: 1px solid black" + data-wp-style--color="state.falseValue" + data-wp-style--background="state.color" + data-wp-style--border="state.border" + data-testid="handles multiple styles and callbacks on hydration" + >Handles multiple styles and callbacks on hydration</div> + + <div + data-wp-style--color="state.color" + data-testid="can add style when style attribute is missing on hydration" + >Can add style when style attribute is missing on hydration</div> + + <div + style="color: red;" + data-wp-style--color="state.color" + data-testid="can toggle style" + >Can toggle style</div> + + <div + style="color: red;" + data-wp-style--color="state.color" + data-testid="can remove style" + >Can remove style</div> + + <div + style="color: blue; background: green; border: 1px solid black;" + data-wp-style--background="state.color" + data-testid="can toggle style in the middle" + >Can toggle style in the middle</div> + + <div + style="background-color: green;" + data-wp-style--background-color="state.color" + data-testid="handles styles names with hyphens" + >Handles styles names with hyphens</div> + + <div data-wp-context='{ "color": "blue" }'> + <div + style="color: blue;" + data-wp-style--color="context.color" + data-testid="can use context values" + ></div> + <button + data-wp-on--click="actions.toggleContext" + data-testid="toggle context" + > + Toggle context + </button> + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-style/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-style/view.js new file mode 100644 index 00000000000000..04fbf1aab11403 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-style/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + falseValue: false, + color: "red", + border: "2px solid yellow" + }, + actions: { + toggleColor: ( { state } ) => { + state.color = state.color === "red" ? "blue" : "red"; + }, + switchColorToFalse: ({ state }) => { + state.color = false; + }, + toggleContext: ( { context } ) => { + context.color = context.color === "red" ? "blue" : "red"; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json new file mode 100644 index 00000000000000..7295849b9912d7 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-text", + "title": "E2E Interactivity tests - directive text", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-text-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php new file mode 100644 index 00000000000000..54ac9c09e7d863 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php @@ -0,0 +1,35 @@ +<?php +/** + * HTML for testing the directive `data-wp-text`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <div> + <span + data-wp-text="state.text" + data-testid="show state text" + ></span> + <button + data-wp-on--click="actions.toggleStateText" + data-testid="toggle state text" + > + Toggle State Text + </button> + </div> + + <div data-wp-context='{ "text": "Text 1" }'> + <span + data-wp-text="context.text" + data-testid="show context text" + ></span> + <button + data-wp-on--click="actions.toggleContextText" + data-testid="toggle context text" + > + Toggle Context Text + </button> + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js new file mode 100644 index 00000000000000..49121213f2b04e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js @@ -0,0 +1,17 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + text: 'Text 1', + }, + actions: { + toggleStateText: ( { state } ) => { + state.text = state.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json new file mode 100644 index 00000000000000..68da53367ad63a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/negation-operator", + "title": "E2E Interactivity tests - negation operator", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "negation-operator-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php new file mode 100644 index 00000000000000..e087a5eceb8369 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php @@ -0,0 +1,26 @@ +<?php +/** + * HTML for testing the negation operator in directives. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <button + data-wp-on--click="actions.toggle" + data-testid="toggle active value" + > + Toggle Active Value + </button> + + <div + data-wp-bind--hidden="!state.active" + data-testid="add hidden attribute if state is not active" + ></div> + + <div + data-wp-bind--hidden="!selectors.active" + data-testid="add hidden attribute if selector is not active" + ></div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js new file mode 100644 index 00000000000000..64a84269c356e3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + selectors: { + active: ( { state } ) => { + return state.active; + }, + }, + state: { + active: false, + }, + actions: { + toggle: ( { state } ) => { + state.active = ! state.active; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json new file mode 100644 index 00000000000000..44cc260d87d3f6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/router-regions", + "title": "E2E Interactivity tests - router regions", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "router-regions-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php new file mode 100644 index 00000000000000..db6e75709f9792 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -0,0 +1,89 @@ +<?php +/** + * HTML for testing the hydration of router regions. + * + * @package gutenberg-test-interactive-blocks + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable + */ + +?> + +<section> + <h2>Region 1</h2> + <div data-wp-interactive data-wp-navigation-id="region-1"> + <p + data-testid="region-1-text" + data-wp-text="state.region1.text" + >not hydrated</p> + <p + data-testid="region-1-ssr" + >content from page <?php echo $attributes['page']; ?></p> + + <button + data-testid="state-counter" + data-wp-text="state.counter.value" + data-wp-on--click="actions.counter.increment" + >NaN</button> + + <?php if ( isset( $attributes['next'] ) ) : ?> + <a + data-testid="next" + data-wp-on--click="actions.router.navigate" + href="<?php echo $attributes['next']; ?>" + >Next</a> + <?php else : ?> + <a + data-testid="back" + data-wp-on--click="actions.router.back" + href="#" + >Back</a> + <?php endif; ?> + </div> +</section> + +<div> + <p + data-testid="no-region-text-1" + data-wp-text="state.region1.text" + >not hydrated</p> +</div> + + +<section> + <h2>Region 2</h2> + <div data-wp-interactive data-wp-navigation-id="region-2"> + <p + data-testid="region-2-text" + data-wp-text="state.region2.text" + >not hydrated</p> + <p + data-testid="region-2-ssr" + >content from page <?php echo $attributes['page']; ?></p> + + <button + data-testid="context-counter" + data-wp-context='{ "counter": { "initialValue": 0 } }' + data-wp-init="actions.counter.init" + data-wp-text="context.counter.value" + data-wp-on--click="actions.counter.increment" + >NaN</button> + + <div data-wp-ignore> + <div> + <p + data-testid="no-region-text-2" + data-wp-text="state.region2.text" + >not hydrated</p> + </div> + + <section> + <h2>Nested region</h2> + <div data-wp-interactive data-wp-navigation-id="nested-region"> + <p + data-testid="nested-region-ssr" + >content from page <?php echo $attributes['page']; ?></p> + </div> + </section> + </div> + </div> +</section> diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js new file mode 100644 index 00000000000000..296c77d3ee7b38 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js @@ -0,0 +1,43 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store, navigate } = wp.interactivity; + + store( { + state: { + region1: { + text: 'hydrated' + }, + region2: { + text: 'hydrated' + }, + counter: { + value: 0, + }, + }, + actions: { + router: { + navigate: async ( { event: e } ) => { + e.preventDefault(); + await navigate( e.target.href ); + }, + back: () => history.back(), + }, + counter: { + increment: ( { state, context } ) => { + if ( context.counter ) { + context.counter.value += 1; + } else { + state.counter.value += 1; + } + }, + init: ( { context } ) => { + if ( context.counter ) { + context.counter.value = context.counter.initialValue; + } + } + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json new file mode 100644 index 00000000000000..0c00bbbb514be3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/store-afterload", + "title": "E2E Interactivity tests - store afterload", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "store-afterload-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php new file mode 100644 index 00000000000000..950ba923428bf1 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php @@ -0,0 +1,41 @@ +<?php +/** + * HTML for testing `afterLoad` callbacks added to the store. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div data-wp-interactive> + <h3>Store statuses</h3> + <p data-store-status data-wp-text="state.status1">waiting</p> + <p data-store-status data-wp-text="state.status2">waiting</p> + <p data-store-status data-wp-text="state.status3">waiting</p> + <p data-store-status data-wp-text="state.status4">waiting</p> + + <h3><code>afterLoad</code> executions</h3> + <p>All stores ready:&#20; + <span + data-testid="all-stores-ready" + data-wp-text="state.allStoresReady"> + >waiting</span> + </p> + <p>vDOM ready:&#20; + <span + data-testid="vdom-ready" + data-wp-text="state.vdomReady"> + >waiting</span> + </p> + <p><code>afterLoad</code> exec times:&#20; + <span + data-testid="after-load-exec-times" + data-wp-text="state.execTimes.afterLoad"> + >0</span> + </p> + <p><code>sharedAfterLoad</code> exec times:&#20; + <span + data-testid="shared-after-load-exec-times" + data-wp-text="state.execTimes.sharedAfterLoad"> + >0</span> + </p> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js new file mode 100644 index 00000000000000..361a56dc622830 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js @@ -0,0 +1,60 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + const afterLoad = ({ state }) => { + // Check the state is correctly initialized. + const { status1, status2, status3, status4 } = state; + state.allStoresReady = + [ status1, status2, status3, status4 ] + .every( ( t ) => t === 'ready' ) + .toString(); + + // Check the HTML has been processed as well. + const selector = '[data-store-status]'; + state.vdomReady = + document.querySelector( selector ) && + Array.from( + document.querySelectorAll( selector ) + ).every( ( el ) => el.textContent === 'ready' ).toString(); + + // Increment exec times everytime this function runs. + state.execTimes.afterLoad += 1; + } + + const sharedAfterLoad = ({ state }) => { + // Increment exec times everytime this function runs. + state.execTimes.sharedAfterLoad += 1; + } + + // Case 1: without afterload callback + store( { + state: { status1: 'ready' }, + } ); + + // Case 2: non-shared afterload callback + store( { + state: { + status2: 'ready', + allStoresReady: false, + vdomReady: false, + execTimes: { afterLoad: 0 }, + }, + }, { afterLoad } ); + + // Case 3: shared afterload callback + store( { + state: { + status3: 'ready', + execTimes: { sharedAfterLoad: 0 }, + }, + }, { afterLoad: sharedAfterLoad } ); + store( { + state: { + status4: 'ready', + execTimes: { sharedAfterLoad: 0 }, + }, + }, { afterLoad: sharedAfterLoad } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json new file mode 100644 index 00000000000000..4611288de796c9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/store-tag", + "title": "E2E Interactivity tests - store tag", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "store-tag-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php new file mode 100644 index 00000000000000..9bc8126720b9b9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -0,0 +1,64 @@ +<?php +/** + * HTML for testing the hydration of the serialized store. + * + * @package gutenberg-test-interactive-blocks + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable + */ + +// These variables simulates SSR. +$test_store_tag_counter = 'ok' === $attributes['condition'] ? 3 : 0; +$test_store_tag_double = $test_store_tag_counter * 2; +?> +<div data-wp-interactive> + <div> + Counter: + <span + data-wp-bind--children="state.counter.value" + data-testid="counter value" + ><?php echo $test_store_tag_counter; ?></span + > + <br /> + Double: + <span + data-wp-bind--children="state.counter.double" + data-testid="counter double" + ><?php echo $test_store_tag_double; ?></span + > + <br /> + <button + data-wp-on--click="actions.counter.increment" + data-testid="counter button" + > + +1 + </button> + <span + data-wp-bind--children="state.counter.clicks" + data-testid="counter clicks" + >0</span + > + clicks + </div> +</div> +<?php + +if ( 'missing' !== $attributes['condition'] ) { + + if ( 'ok' === $attributes['condition'] ) { + $test_store_tag_json = '{ "state": { "counter": { "value": 3 } } }'; + } + + if ( 'corrupted-json' === $attributes['condition'] ) { + $test_store_tag_json = 'this is not a JSON'; + } + + if ( 'invalid-state' === $attributes['condition'] ) { + $test_store_tag_json = '{ "state": null }'; + } + + echo <<<HTML + <script type="application/json" id="wp-interactivity-store-data"> + $test_store_tag_json + </script> +HTML; +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js new file mode 100644 index 00000000000000..140cab6463137f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + counter: { + // `value` is defined in the server. + double: ( { state } ) => state.counter.value * 2, + clicks: 0, + }, + }, + actions: { + counter: { + increment: ( { state } ) => { + state.counter.value += 1; + state.counter.clicks += 1; + }, + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json new file mode 100644 index 00000000000000..fb852acefb9116 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom-islands", + "title": "E2E Interactivity tests - tovdom islands", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-islands-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php new file mode 100644 index 00000000000000..a3ebb7a87424e4 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php @@ -0,0 +1,66 @@ +<?php +/** + * HTML for testing the directive `data-wp-interactive`. + * + * @package gutenberg-test-interactive-blocks + */ + +?> +<div> + <div data-wp-show-mock="state.falseValue"> + <span data-testid="not inside an island"> + This should be shown because it is inside an island. + </span> + </div> + + <div data-wp-interactive> + <div data-wp-show-mock="state.falseValue"> + <span data-testid="inside an island"> + This should not be shown because it is inside an island. + </span> + </div> + </div> + + <div data-wp-interactive> + <div data-wp-ignore> + <div data-wp-show-mock="state.falseValue"> + <span + data-testid="inside an inner block of an isolated island" + > + This should be shown because it is inside an inner + block of an isolated island. + </span> + </div> + </div> + </div> + + <div data-wp-interactive> + <div data-wp-interactive> + <div + data-wp-show-mock="state.falseValue" + data-testid="island inside another island" + > + <span> + This should not have two template wrappers because + that means we hydrated twice. + </span> + </div> + </div> + </div> + + <div data-wp-interactive> + <div> + <div data-wp-interactive data-wp-ignore> + <div data-wp-show-mock="state.falseValue"> + <span + data-testid="island inside inner block of isolated island" + > + This should not be shown because even though it + is inside an inner block of an isolated island, + it's inside an new island. + </span> + </div> + </div> + </div> + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js new file mode 100644 index 00000000000000..f897368193ea8b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js @@ -0,0 +1,26 @@ +( ( { wp } ) => { + const { store, directive, createElement } = wp.interactivity; + + // Fake `data-wp-show-mock` directive to test when things are removed from the + // DOM. Replace with `data-wp-show` when it's ready. + directive( + 'show-mock', + ( { + directives: { + "show-mock": { default: showMock }, + }, + element, + evaluate, + } ) => { + if ( ! evaluate( showMock ) ) + element.props.children = + createElement( "template", null, element.props.children ); + } + ); + + store( { + state: { + falseValue: false, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json new file mode 100644 index 00000000000000..b685919e164821 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom", + "title": "E2E Interactivity tests - tovdom", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js new file mode 100644 index 00000000000000..506e899e42850c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js @@ -0,0 +1,15 @@ +const cdata = ` + <div> + <![CDATA[##1##]]> + <div data-testid="it should keep this node between CDATA"> + <![CDATA[##2##]]> + </div> + </div> + `; + +const cdataElement = new DOMParser() + .parseFromString( cdata, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-cdata' ) + .replaceWith( cdataElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js new file mode 100644 index 00000000000000..b65095ef6cde4f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js @@ -0,0 +1,16 @@ +const processingInstructions = ` + <div> + <?xpacket ##1## ?> + <div data-testid="it should keep this node between processing instructions"> + Processing instructions inner node + <?xpacket ##2## ?> + </div> + </div> + `; + +const processingInstructionsElement = new DOMParser() + .parseFromString( processingInstructions, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-processing-instructions' ) + .replaceWith( processingInstructionsElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php new file mode 100644 index 00000000000000..952a4f6c0a455d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php @@ -0,0 +1,33 @@ +<?php +/** + * HTML for testing the vDOM generation. + * + * @package gutenberg-test-interactive-blocks + */ + +$plugin_url = plugin_dir_url( __DIR__ ); +$src_proc_ins = $plugin_url . 'tovdom/processing-instructions.js'; +$src_cdata = $plugin_url . 'tovdom/cdata.js'; +?> + +<div data-wp-interactive> + <div data-testid="it should delete comments"> + <!-- ##1## --> + <div data-testid="it should keep this node between comments"> + Comments inner node + <!-- ##2## --> + </div> + </div> + + <div data-testid="it should delete processing instructions"> + <div id="replace-with-processing-instructions"></div> + </div> + + <script src="<?php echo $src_proc_ins; ?>"></script> + + <div data-testid="it should replace CDATA with text nodes"> + <div id="replace-with-cdata"></div> + </div> + + <script src="<?php echo $src_cdata; ?>"></script> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js new file mode 100644 index 00000000000000..734ccbd801bb1e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js @@ -0,0 +1,5 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( {} ); +} )( window ); diff --git a/packages/e2e-tests/plugins/marquee-function-widget.php b/packages/e2e-tests/plugins/marquee-function-widget.php index dda0c3f9a6e273..85881bab14c495 100644 --- a/packages/e2e-tests/plugins/marquee-function-widget.php +++ b/packages/e2e-tests/plugins/marquee-function-widget.php @@ -14,7 +14,7 @@ function marquee_greeting_init() { wp_register_sidebar_widget( 'marquee_greeting', 'Marquee Greeting', - function() { + static function() { $greeting = get_option( 'marquee_greeting', 'Hello!' ); printf( '<marquee>%s</marquee>', esc_html( $greeting ) ); } @@ -23,7 +23,7 @@ function() { wp_register_widget_control( 'marquee_greeting', 'Marquee Greeting', - function() { + static function() { if ( isset( $_POST['marquee-greeting'] ) ) { update_option( 'marquee_greeting', diff --git a/packages/e2e-tests/specs/editor/blocks/post-title.test.js b/packages/e2e-tests/specs/editor/blocks/post-title.test.js index 621e6815cfba84..0f9fc610be3ee5 100644 --- a/packages/e2e-tests/specs/editor/blocks/post-title.test.js +++ b/packages/e2e-tests/specs/editor/blocks/post-title.test.js @@ -5,6 +5,7 @@ import { createNewPost, insertBlock, saveDraft, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'Post Title block', () => { @@ -14,11 +15,11 @@ describe( 'Post Title block', () => { it( 'Can edit the post title', async () => { // Create a block with some text that will trigger a list creation. - await insertBlock( 'Post Title' ); + await insertBlock( 'Title' ); const editablePostTitleSelector = '.wp-block-post-title[contenteditable="true"]'; - await page.waitForSelector( editablePostTitleSelector ); - await page.focus( editablePostTitleSelector ); + await canvas().waitForSelector( editablePostTitleSelector ); + await canvas().focus( editablePostTitleSelector ); // Create a second list item. await page.keyboard.type( 'Just tweaking the post title' ); @@ -26,7 +27,7 @@ describe( 'Post Title block', () => { await saveDraft(); await page.reload(); await page.waitForSelector( '.edit-post-layout' ); - const title = await page.$eval( + const title = await canvas().$eval( '.editor-post-title__input', ( element ) => element.textContent ); diff --git a/packages/e2e-tests/specs/editor/blocks/site-title.test.js b/packages/e2e-tests/specs/editor/blocks/site-title.test.js index 32428a73c8e206..d24b79b6bb3720 100644 --- a/packages/e2e-tests/specs/editor/blocks/site-title.test.js +++ b/packages/e2e-tests/specs/editor/blocks/site-title.test.js @@ -9,6 +9,7 @@ import { pressKeyWithModifier, setOption, openDocumentSettingsSidebar, + canvas, } from '@wordpress/e2e-test-utils'; const saveEntities = async () => { @@ -45,8 +46,8 @@ describe( 'Site Title block', () => { await insertBlock( 'Site Title' ); const editableSiteTitleSelector = '[aria-label="Block: Site Title"] a[contenteditable="true"]'; - await page.waitForSelector( editableSiteTitleSelector ); - await page.focus( editableSiteTitleSelector ); + await canvas().waitForSelector( editableSiteTitleSelector ); + await canvas().focus( editableSiteTitleSelector ); await pressKeyWithModifier( 'primary', 'a' ); await page.keyboard.type( 'New Site Title' ); diff --git a/packages/e2e-tests/specs/editor/plugins/__snapshots__/container-blocks.test.js.snap b/packages/e2e-tests/specs/editor/plugins/__snapshots__/container-blocks.test.js.snap index fb3f84595e5e2b..cbcbf0402f8c96 100644 --- a/packages/e2e-tests/specs/editor/plugins/__snapshots__/container-blocks.test.js.snap +++ b/packages/e2e-tests/specs/editor/plugins/__snapshots__/container-blocks.test.js.snap @@ -22,10 +22,11 @@ exports[`InnerBlocks Template Sync Ensures blocks without locking are kept intac <p class="has-large-font-size">Content…</p> <!-- /wp:paragraph --> -<!-- wp:paragraph --> -<p>added paragraph</p> -<!-- /wp:paragraph --> -<!-- /wp:test/test-inner-blocks-no-locking -->" + <!-- wp:paragraph --> + <p>added paragraph</p> + <!-- /wp:paragraph --> + <!-- /wp:test/test-inner-blocks-no-locking --> +" `; exports[`InnerBlocks Template Sync Removes blocks that are not expected by the template if a lock all exists 1`] = ` diff --git a/packages/e2e-tests/specs/editor/plugins/allowed-blocks.test.js b/packages/e2e-tests/specs/editor/plugins/allowed-blocks.test.js deleted file mode 100644 index 804e062fa725b8..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/allowed-blocks.test.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - clickOnMoreMenuItem, - createNewPost, - deactivatePlugin, - searchForBlock, -} from '@wordpress/e2e-test-utils'; - -describe( 'Allowed Blocks Filter', () => { - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-allowed-blocks' ); - } ); - - beforeEach( async () => { - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( 'gutenberg-test-allowed-blocks' ); - } ); - - it( 'should restrict the allowed blocks in the inserter', async () => { - // The paragraph block is available. - await searchForBlock( 'Paragraph' ); - const paragraphBlockButton = ( - await page.$x( `//button//span[contains(text(), 'Paragraph')]` ) - )[ 0 ]; - expect( paragraphBlockButton ).not.toBeNull(); - - // The gallery block is not available. - await searchForBlock( 'Gallery' ); - - const galleryBlockButton = ( - await page.$x( `//button//span[contains(text(), 'Gallery')]` ) - )[ 0 ]; - expect( galleryBlockButton ).toBeUndefined(); - } ); - - it( 'should remove not allowed blocks from the block manager', async () => { - await clickOnMoreMenuItem( 'Preferences' ); - const [ blocksTab ] = await page.$x( - `//button[contains(text(), "Blocks")]` - ); - await blocksTab.click(); - - const BLOCK_LABEL_SELECTOR = - '.edit-post-block-manager__checklist-item .components-checkbox-control__label'; - await page.waitForSelector( BLOCK_LABEL_SELECTOR ); - const blocks = await page.evaluate( ( selector ) => { - return Array.from( document.querySelectorAll( selector ) ) - .map( ( element ) => ( element.innerText || '' ).trim() ) - .sort(); - }, BLOCK_LABEL_SELECTOR ); - expect( blocks ).toEqual( [ 'Image', 'Paragraph' ] ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/plugins/annotations.test.js b/packages/e2e-tests/specs/editor/plugins/annotations.test.js index 85265bf424abc5..f0134812d4a7eb 100644 --- a/packages/e2e-tests/specs/editor/plugins/annotations.test.js +++ b/packages/e2e-tests/specs/editor/plugins/annotations.test.js @@ -8,6 +8,7 @@ import { clickOnMoreMenuItem, createNewPost, deactivatePlugin, + canvas, } from '@wordpress/e2e-test-utils'; const clickOnBlockSettingsMenuItem = async ( buttonLabel ) => { @@ -28,6 +29,13 @@ describe( 'Annotations', () => { beforeEach( async () => { await createNewPost(); + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); } ); /** @@ -51,7 +59,7 @@ describe( 'Annotations', () => { await page.$x( "//button[contains(text(), 'Add annotation')]" ) )[ 0 ]; await addAnnotationButton.click(); - await page.evaluate( () => + await canvas().evaluate( () => document.querySelector( '.wp-block-paragraph' ).focus() ); } @@ -67,7 +75,7 @@ describe( 'Annotations', () => { await page.$x( "//button[contains(text(), 'Remove annotations')]" ) )[ 0 ]; await addAnnotationButton.click(); - await page.evaluate( () => + await canvas().evaluate( () => document.querySelector( '[contenteditable]' ).focus() ); } @@ -78,11 +86,11 @@ describe( 'Annotations', () => { * @return {Promise<string>} The annotated text. */ async function getAnnotatedText() { - const annotations = await page.$$( ANNOTATIONS_SELECTOR ); + const annotations = await canvas().$$( ANNOTATIONS_SELECTOR ); const annotation = annotations[ 0 ]; - return await page.evaluate( ( el ) => el.innerText, annotation ); + return await canvas().evaluate( ( el ) => el.innerText, annotation ); } /** @@ -91,7 +99,7 @@ describe( 'Annotations', () => { * @return {Promise<string>} Inner HTML. */ async function getRichTextInnerHTML() { - const htmlContent = await page.$$( '.wp-block-paragraph' ); + const htmlContent = await canvas().$$( '.wp-block-paragraph' ); return await page.evaluate( ( el ) => { return el.innerHTML; }, htmlContent[ 0 ] ); @@ -102,12 +110,12 @@ describe( 'Annotations', () => { await clickOnMoreMenuItem( 'Annotations' ); - let annotations = await page.$$( ANNOTATIONS_SELECTOR ); + let annotations = await canvas().$$( ANNOTATIONS_SELECTOR ); expect( annotations ).toHaveLength( 0 ); await annotateFirstBlock( 9, 13 ); - annotations = await page.$$( ANNOTATIONS_SELECTOR ); + annotations = await canvas().$$( ANNOTATIONS_SELECTOR ); expect( annotations ).toHaveLength( 1 ); const text = await getAnnotatedText(); @@ -115,7 +123,7 @@ describe( 'Annotations', () => { await clickOnBlockSettingsMenuItem( 'Edit as HTML' ); - const htmlContent = await page.$$( + const htmlContent = await canvas().$$( '.block-editor-block-list__block-html-textarea' ); const html = await page.evaluate( ( el ) => { @@ -136,7 +144,7 @@ describe( 'Annotations', () => { await page.keyboard.type( 'D' ); await removeAnnotations(); - const htmlContent = await page.$$( '.wp-block-paragraph' ); + const htmlContent = await canvas().$$( '.wp-block-paragraph' ); const html = await page.evaluate( ( el ) => { return el.innerHTML; }, htmlContent[ 0 ] ); diff --git a/packages/e2e-tests/specs/editor/plugins/block-variations.test.js b/packages/e2e-tests/specs/editor/plugins/block-variations.test.js deleted file mode 100644 index 886382a4667b1e..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/block-variations.test.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - insertBlock, - searchForBlock, - pressKeyWithModifier, - openDocumentSettingsSidebar, - togglePreferencesOption, - toggleMoreMenu, -} from '@wordpress/e2e-test-utils'; - -describe( 'Block variations', () => { - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-block-variations' ); - } ); - - beforeEach( async () => { - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( 'gutenberg-test-block-variations' ); - } ); - - const expectInserterItem = async ( blockTitle ) => { - const inserterItem = await page.$x( - `//button[contains(@class, 'block-editor-block-types-list__item')]//span[text()="${ blockTitle }"]` - ); - expect( inserterItem ).toBeDefined(); - expect( inserterItem ).toHaveLength( 1 ); - }; - - test( 'Search for the overridden default Quote block', async () => { - await searchForBlock( 'Quote' ); - - expect( await page.$( '.editor-block-list-item-quote' ) ).toBeNull(); - expectInserterItem( 'Large Quote' ); - } ); - - test( 'Insert the overridden default Quote block variation', async () => { - await insertBlock( 'Large Quote' ); - - expect( - await page.$( - '.wp-block[data-type="core/quote"] blockquote.is-style-large' - ) - ).toBeDefined(); - } ); - - test( 'Insert the Large Quote block variation with slash command', async () => { - await insertBlock( 'Paragraph' ); - - await page.keyboard.type( '/large' ); - await page.keyboard.press( 'Enter' ); - - expect( - await page.$( - '.wp-block[data-type="core/quote"] blockquote.is-style-large' - ) - ).toBeDefined(); - } ); - - test( 'Search for the Paragraph block with 2 additional variations', async () => { - await searchForBlock( 'Paragraph' ); - - expectInserterItem( 'Paragraph' ); - expectInserterItem( 'Success Message' ); - expectInserterItem( 'Warning Message' ); - } ); - - test( 'Insert the Success Message block variation', async () => { - await insertBlock( 'Success Message' ); - - const successMessageBlock = await page.$( - '.wp-block[data-type="core/paragraph"]' - ); - expect( successMessageBlock ).toBeDefined(); - expect( - await successMessageBlock.evaluate( ( node ) => node.innerText ) - ).toBe( 'This is a success message!' ); - } ); - test( 'Pick the additional variation in the inserted Columns block', async () => { - await insertBlock( 'Columns' ); - - const fourColumnsVariation = await page.waitForSelector( - '.wp-block[data-type="core/columns"] .block-editor-block-variation-picker__variation[aria-label="Four columns"]' - ); - await fourColumnsVariation.click(); - expect( - await page.$$( - '.wp-block[data-type="core/columns"] .wp-block[data-type="core/column"]' - ) - ).toHaveLength( 4 ); - } ); - // @see @wordpres/block-editor/src/components/use-block-display-information (`useBlockDisplayInformation` hook). - describe( 'testing block display information with matching variations', () => { - beforeEach( async () => { - await togglePreferencesOption( - 'General', - 'Display block breadcrumbs', - true - ); - await toggleMoreMenu( 'close' ); - } ); - - afterEach( async () => { - await togglePreferencesOption( - 'General', - 'Display block breadcrumbs', - false - ); - await toggleMoreMenu( 'close' ); - } ); - - const getActiveBreadcrumb = async () => - page.evaluate( - () => - document.querySelector( - '.block-editor-block-breadcrumb__current' - ).textContent - ); - const getFirstNavigationItem = async () => { - await pressKeyWithModifier( 'access', 'o' ); - // This also returns the visually hidden text `(selected block)`. - // For example `Paragraph(selected block)`. In order to hide this - // implementation detail and search for childNodes, we choose to - // test with `String.prototype.startsWith()`. - return page.evaluate( - () => - document.querySelector( - '.block-editor-list-view-block-select-button' - ).textContent - ); - }; - const getBlockCardDescription = async () => { - await openDocumentSettingsSidebar(); - return page.evaluate( - () => - document.querySelector( - '.block-editor-block-card__description' - ).textContent - ); - }; - - it( 'should show block information when no matching variation is found', async () => { - await insertBlock( 'Large Quote' ); - // Select the quote block. - await page.keyboard.press( 'ArrowDown' ); - const breadcrumb = await getActiveBreadcrumb(); - expect( breadcrumb ).toEqual( 'Quote' ); - const navigationItem = await getFirstNavigationItem(); - expect( navigationItem.startsWith( 'Quote' ) ).toBeTruthy(); - const description = await getBlockCardDescription(); - expect( description ).toEqual( - 'Give quoted text visual emphasis. "In quoting others, we cite ourselves." — Julio Cortázar' - ); - } ); - it( 'should display variations info if all declared', async () => { - await insertBlock( 'Success Message' ); - const breadcrumb = await getActiveBreadcrumb(); - expect( breadcrumb ).toEqual( 'Success Message' ); - const navigationItem = await getFirstNavigationItem(); - expect( - navigationItem.startsWith( 'Success Message' ) - ).toBeTruthy(); - const description = await getBlockCardDescription(); - expect( description ).toEqual( - 'This block displays a success message. This description overrides the default one provided for the Paragraph block.' - ); - } ); - it( 'should display mixed block and variation match information', async () => { - // Warning Message variation is missing the `description`. - await insertBlock( 'Warning Message' ); - const breadcrumb = await getActiveBreadcrumb(); - expect( breadcrumb ).toEqual( 'Warning Message' ); - const navigationItem = await getFirstNavigationItem(); - expect( - navigationItem.startsWith( 'Warning Message' ) - ).toBeTruthy(); - const description = await getBlockCardDescription(); - expect( description ).toEqual( - 'Start with the basic building block of all narrative.' - ); - } ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/plugins/container-blocks.test.js b/packages/e2e-tests/specs/editor/plugins/container-blocks.test.js index 4c159571da9fd8..88c1a7fc8271ae 100644 --- a/packages/e2e-tests/specs/editor/plugins/container-blocks.test.js +++ b/packages/e2e-tests/specs/editor/plugins/container-blocks.test.js @@ -9,6 +9,7 @@ import { insertBlock, switchEditorModeTo, pressKeyWithModifier, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'InnerBlocks Template Sync', () => { @@ -75,7 +76,7 @@ describe( 'InnerBlocks Template Sync', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); // Trigger a template update and assert that a second block is now present. - const [ button ] = await page.$x( + const [ button ] = await canvas().$x( `//button[contains(text(), 'Update template')]` ); await button.click(); diff --git a/packages/e2e-tests/specs/editor/plugins/cpt-locking.test.js b/packages/e2e-tests/specs/editor/plugins/cpt-locking.test.js index 1a4d8e2b6d4332..447be0793fafbb 100644 --- a/packages/e2e-tests/specs/editor/plugins/cpt-locking.test.js +++ b/packages/e2e-tests/specs/editor/plugins/cpt-locking.test.js @@ -12,6 +12,7 @@ import { pressKeyTimes, pressKeyWithModifier, setPostContent, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'cpt locking', () => { @@ -35,7 +36,7 @@ describe( 'cpt locking', () => { }; const shouldNotAllowBlocksToBeRemoved = async () => { - await page.type( + await canvas().type( '.block-editor-rich-text__editable[data-type="core/paragraph"]', 'p1' ); @@ -46,12 +47,12 @@ describe( 'cpt locking', () => { }; const shouldAllowBlocksToBeMoved = async () => { - await page.click( + await canvas().click( 'div > .block-editor-rich-text__editable[data-type="core/paragraph"]' ); expect( await page.$( 'button[aria-label="Move up"]' ) ).not.toBeNull(); await page.click( 'button[aria-label="Move up"]' ); - await page.type( + await canvas().type( 'div > .block-editor-rich-text__editable[data-type="core/paragraph"]', 'p1' ); @@ -71,14 +72,14 @@ describe( 'cpt locking', () => { ); it( 'should not allow blocks to be moved', async () => { - await page.click( + await canvas().click( '.block-editor-rich-text__editable[data-type="core/paragraph"]' ); expect( await page.$( 'button[aria-label="Move up"]' ) ).toBeNull(); } ); it( 'should not error when deleting the cotents of a paragraph', async () => { - await page.click( + await canvas().click( '.block-editor-block-list__block[data-type="core/paragraph"]' ); const textToType = 'Paragraph'; @@ -88,7 +89,7 @@ describe( 'cpt locking', () => { } ); it( 'should insert line breaks when using enter and shift-enter', async () => { - await page.click( + await canvas().click( '.block-editor-block-list__block[data-type="core/paragraph"]' ); await page.keyboard.type( 'First line' ); @@ -118,12 +119,14 @@ describe( 'cpt locking', () => { } ); it( 'should not allow blocks to be inserted in inner blocks', async () => { - await page.click( 'button[aria-label="Two columns; equal split"]' ); + await canvas().click( + 'button[aria-label="Two columns; equal split"]' + ); await page.evaluate( () => new Promise( window.requestIdleCallback ) ); expect( - await page.$( + await canvas().$( '.wp-block-column .block-editor-button-block-appender' ) ).toBeNull(); @@ -173,7 +176,7 @@ describe( 'cpt locking', () => { } ); it( 'should allow blocks to be removed', async () => { - await page.type( + await canvas().type( '.block-editor-rich-text__editable[data-type="core/paragraph"]', 'p1' ); @@ -193,7 +196,7 @@ describe( 'cpt locking', () => { } ); it( 'should allow blocks to be removed', async () => { - await page.type( + await canvas().type( 'div > .block-editor-rich-text__editable[data-type="core/paragraph"]', 'p1' ); @@ -219,7 +222,7 @@ describe( 'cpt locking', () => { ); it( 'should not allow blocks to be moved', async () => { - await page.click( + await canvas().click( '.block-editor-rich-text__editable[data-type="core/paragraph"]' ); expect( await page.$( 'button[aria-label="Move up"]' ) ).toBeNull(); @@ -239,7 +242,7 @@ describe( 'cpt locking', () => { ); it( 'should not allow blocks to be moved', async () => { - await page.click( + await canvas().click( '.block-editor-rich-text__editable[data-type="core/paragraph"]' ); expect( await page.$( 'button[aria-label="Move up"]' ) ).toBeNull(); diff --git a/packages/e2e-tests/specs/editor/plugins/iframed-enqueue-block-editor-settings.test.js b/packages/e2e-tests/specs/editor/plugins/iframed-enqueue-block-editor-settings.test.js new file mode 100644 index 00000000000000..df6eb4bf840328 --- /dev/null +++ b/packages/e2e-tests/specs/editor/plugins/iframed-enqueue-block-editor-settings.test.js @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + canvas, + activateTheme, +} from '@wordpress/e2e-test-utils'; + +async function getComputedStyle( context, selector, property ) { + return await context.evaluate( + ( sel, prop ) => + window.getComputedStyle( document.querySelector( sel ) )[ prop ], + selector, + property + ); +} + +describe( 'iframed block editor settings styles', () => { + beforeEach( async () => { + // Activate the empty theme (block based theme), which is iframed. + await activateTheme( 'emptytheme' ); + await activatePlugin( + 'gutenberg-test-iframed-enqueue-block-editor-settings' + ); + await createNewPost(); + } ); + + afterEach( async () => { + await deactivatePlugin( + 'gutenberg-test-iframed-enqueue-block-editor-settings' + ); + await activateTheme( 'twentytwentyone' ); + } ); + + it( 'should load styles added through block editor settings', async () => { + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); + // Expect a red border (added in PHP). + expect( await getComputedStyle( canvas(), 'p', 'border-color' ) ).toBe( + 'rgb(255, 0, 0)' + ); + + await page.evaluate( () => { + const settings = window.wp.data + .select( 'core/editor' ) + .getEditorSettings(); + wp.data.dispatch( 'core/editor' ).updateEditorSettings( { + ...settings, + styles: [ + ...settings.styles, + { + css: 'p { border-width: 2px; }', + __unstableType: 'plugin', + }, + ], + } ); + } ); + + // Expect a 2px border (added in JS). + expect( await getComputedStyle( canvas(), 'p', 'border-width' ) ).toBe( + '2px' + ); + } ); + + it( 'should load theme styles added through block editor settings', async () => { + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); + + await page.evaluate( () => { + // Make sure that theme styles are added even if the theme styles + // preference is off. + window.wp.data + .dispatch( 'core/edit-post' ) + .toggleFeature( 'themeStyles' ); + const settings = window.wp.data + .select( 'core/editor' ) + .getEditorSettings(); + wp.data.dispatch( 'core/editor' ).updateEditorSettings( { + ...settings, + styles: [ + ...settings.styles, + { + css: 'p { border-width: 2px; }', + __unstableType: 'theme', + }, + ], + } ); + } ); + + // Expect a 1px border because theme styles are disabled. + expect( await getComputedStyle( canvas(), 'p', 'border-width' ) ).toBe( + '1px' + ); + + await page.evaluate( () => { + // Now enable theme styles. + window.wp.data + .dispatch( 'core/edit-post' ) + .toggleFeature( 'themeStyles' ); + } ); + + // Expect a 2px border because theme styles are enabled. + expect( await getComputedStyle( canvas(), 'p', 'border-width' ) ).toBe( + '2px' + ); + } ); +} ); diff --git a/packages/e2e-tests/specs/editor/plugins/iframed-equeue-block-assets.test.js b/packages/e2e-tests/specs/editor/plugins/iframed-equeue-block-assets.test.js deleted file mode 100644 index c1bd26fe1c7610..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/iframed-equeue-block-assets.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - canvas, - activateTheme, -} from '@wordpress/e2e-test-utils'; - -async function getComputedStyle( context, selector, property ) { - return await context.evaluate( - ( sel, prop ) => - window.getComputedStyle( document.querySelector( sel ) )[ prop ], - selector, - property - ); -} - -describe( 'iframed inline styles', () => { - beforeEach( async () => { - // Activate the empty theme (block based theme), which is iframed. - await activateTheme( 'emptytheme' ); - await activatePlugin( 'gutenberg-test-iframed-enqueue_block_assets' ); - await createNewPost(); - } ); - - afterEach( async () => { - await deactivatePlugin( 'gutenberg-test-iframed-enqueue_block_assets' ); - await activateTheme( 'twentytwentyone' ); - } ); - - it( 'should load styles added through enqueue_block_assets', async () => { - // Check stylesheet. - expect( - await getComputedStyle( canvas(), 'body', 'background-color' ) - ).toBe( 'rgb(33, 117, 155)' ); - // Check inline style. - expect( await getComputedStyle( canvas(), 'body', 'padding' ) ).toBe( - '20px' - ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js b/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js index 2a9af38c49209c..eafc0b1f48b614 100644 --- a/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js +++ b/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js @@ -35,8 +35,10 @@ describe( 'iframed inline styles', () => { await insertBlock( 'Iframed Inline Styles' ); expect( await getEditedPostContent() ).toMatchSnapshot(); - expect( await getComputedStyle( page, 'padding' ) ).toBe( '20px' ); - expect( await getComputedStyle( page, 'border-width' ) ).toBe( '2px' ); + expect( await getComputedStyle( canvas(), 'padding' ) ).toBe( '20px' ); + expect( await getComputedStyle( canvas(), 'border-width' ) ).toBe( + '2px' + ); await createNewTemplate( 'Iframed Test' ); @@ -48,5 +50,7 @@ describe( 'iframed inline styles', () => { expect( await getComputedStyle( canvas(), 'border-width' ) ).toBe( '2px' ); + + expect( console ).toHaveWarned(); } ); } ); diff --git a/packages/e2e-tests/specs/editor/plugins/iframed-masonry-block.test.js b/packages/e2e-tests/specs/editor/plugins/iframed-masonry-block.test.js index 47767edc8e5d29..503beeb92e1642 100644 --- a/packages/e2e-tests/specs/editor/plugins/iframed-masonry-block.test.js +++ b/packages/e2e-tests/specs/editor/plugins/iframed-masonry-block.test.js @@ -39,7 +39,7 @@ describe( 'iframed masonry block', () => { await insertBlock( 'Iframed Masonry Block' ); expect( await getEditedPostContent() ).toMatchSnapshot(); - expect( await didMasonryLoadCorrectly( page ) ).toBe( true ); + expect( await didMasonryLoadCorrectly( canvas() ) ).toBe( true ); await createNewTemplate( 'Iframed Test' ); await canvas().waitForSelector( '.grid-item[style]' ); diff --git a/packages/e2e-tests/specs/editor/plugins/iframed-multiple-block-stylesheets.test.js b/packages/e2e-tests/specs/editor/plugins/iframed-multiple-block-stylesheets.test.js index 9d8e5975d3f2ec..23058b48b8da43 100644 --- a/packages/e2e-tests/specs/editor/plugins/iframed-multiple-block-stylesheets.test.js +++ b/packages/e2e-tests/specs/editor/plugins/iframed-multiple-block-stylesheets.test.js @@ -35,7 +35,7 @@ describe( 'iframed multiple block stylesheets', () => { it( 'should load multiple block stylesheets in iframe', async () => { await insertBlock( 'Iframed Multiple Stylesheets' ); - await page.waitForSelector( + await canvas().waitForSelector( '.wp-block-test-iframed-multiple-stylesheets' ); await createNewTemplate( 'Iframed Test' ); diff --git a/packages/e2e-tests/specs/editor/plugins/inner-blocks-allowed-blocks.test.js b/packages/e2e-tests/specs/editor/plugins/inner-blocks-allowed-blocks.test.js deleted file mode 100644 index 4431d3bd5802f0..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/inner-blocks-allowed-blocks.test.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - getAllBlockInserterItemTitles, - insertBlock, - openGlobalBlockInserter, - closeGlobalBlockInserter, - clickBlockToolbarButton, -} from '@wordpress/e2e-test-utils'; - -describe( 'Allowed Blocks Setting on InnerBlocks', () => { - const paragraphSelector = - '.block-editor-rich-text__editable[data-type="core/paragraph"]'; - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-innerblocks-allowed-blocks' ); - } ); - - beforeEach( async () => { - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( 'gutenberg-test-innerblocks-allowed-blocks' ); - } ); - - it( 'allows all blocks if the allowed blocks setting was not set', async () => { - const parentBlockSelector = '[data-type="test/allowed-blocks-unset"]'; - const childParagraphSelector = `${ parentBlockSelector } ${ paragraphSelector }`; - await insertBlock( 'Allowed Blocks Unset' ); - await closeGlobalBlockInserter(); - await page.waitForSelector( childParagraphSelector ); - await page.click( childParagraphSelector ); - await openGlobalBlockInserter(); - await expect( - ( - await getAllBlockInserterItemTitles() - ).length - ).toBeGreaterThan( 20 ); - } ); - - it( 'allows the blocks if the allowed blocks setting was set', async () => { - const parentBlockSelector = '[data-type="test/allowed-blocks-set"]'; - const childParagraphSelector = `${ parentBlockSelector } ${ paragraphSelector }`; - await insertBlock( 'Allowed Blocks Set' ); - await closeGlobalBlockInserter(); - await page.waitForSelector( childParagraphSelector ); - await page.click( childParagraphSelector ); - await openGlobalBlockInserter(); - const allowedBlocks = await getAllBlockInserterItemTitles(); - expect( allowedBlocks.sort() ).toEqual( [ - 'Button', - 'Gallery', - 'List', - 'Media & Text', - 'Quote', - ] ); - } ); - - it( 'correctly applies dynamic allowed blocks restrictions', async () => { - await insertBlock( 'Allowed Blocks Dynamic' ); - await closeGlobalBlockInserter(); - const parentBlockSelector = '[data-type="test/allowed-blocks-dynamic"]'; - const blockAppender = '.block-list-appender button'; - const appenderSelector = `${ parentBlockSelector } ${ blockAppender }`; - await page.waitForSelector( appenderSelector ); - await page.click( appenderSelector ); - expect( await getAllBlockInserterItemTitles() ).toEqual( [ - 'Image', - 'List', - ] ); - const insertButton = ( - await page.$x( `//button//span[contains(text(), 'List')]` ) - )[ 0 ]; - await insertButton.click(); - // Select the list wrapper so the image is insertable. - await page.keyboard.press( 'ArrowUp' ); - await insertBlock( 'Image' ); - await closeGlobalBlockInserter(); - await page.waitForSelector( '.product[data-number-of-children="2"]' ); - await clickBlockToolbarButton( 'Select Allowed Blocks Dynamic' ); - // This focus shouldn't be neessary but there's a bug in trunk right now - // Where if you open the inserter, don't do anything and click the "appender" on the canvas - // the appender is not opened right away. - // It needs to be investigated on its own. - await page.focus( appenderSelector ); - await page.click( appenderSelector ); - expect( await getAllBlockInserterItemTitles() ).toEqual( [ - 'Gallery', - 'Video', - ] ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/plugins/inner-blocks-prioritized-inserter-blocks.test.js b/packages/e2e-tests/specs/editor/plugins/inner-blocks-prioritized-inserter-blocks.test.js index 77ae8d38274325..7621fbea12140a 100644 --- a/packages/e2e-tests/specs/editor/plugins/inner-blocks-prioritized-inserter-blocks.test.js +++ b/packages/e2e-tests/specs/editor/plugins/inner-blocks-prioritized-inserter-blocks.test.js @@ -8,6 +8,7 @@ import { getAllBlockInserterItemTitles, insertBlock, closeGlobalBlockInserter, + canvas, } from '@wordpress/e2e-test-utils'; const QUICK_INSERTER_RESULTS_SELECTOR = @@ -108,7 +109,7 @@ describe( 'Prioritized Inserter Blocks Setting on InnerBlocks', () => { describe( 'Slash inserter', () => { it( 'uses the priority ordering if prioritzed blocks setting is set', async () => { await insertBlock( 'Prioritized Inserter Blocks Set' ); - await page.click( '[data-type="core/image"]' ); + await canvas().click( '[data-type="core/image"]' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '/' ); // Wait for the results to display. diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/block-hierarchy-navigation.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/block-hierarchy-navigation.test.js.snap deleted file mode 100644 index b4d171f170d918..00000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/block-hierarchy-navigation.test.js.snap +++ /dev/null @@ -1,63 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Navigating the block hierarchy should appear and function even without nested blocks 1`] = ` -"<!-- wp:paragraph --> -<p>and I say hello</p> -<!-- /wp:paragraph --> - -<!-- wp:image --> -<figure class="wp-block-image"><img alt=""/></figure> -<!-- /wp:image -->" -`; - -exports[`Navigating the block hierarchy should navigate block hierarchy using only the keyboard 1`] = ` -"<!-- wp:columns --> -<div class="wp-block-columns"><!-- wp:column --> -<div class="wp-block-column"><!-- wp:paragraph --> -<p>First column</p> -<!-- /wp:paragraph --></div> -<!-- /wp:column --> - -<!-- wp:column --> -<div class="wp-block-column"></div> -<!-- /wp:column --> - -<!-- wp:column --> -<div class="wp-block-column"><!-- wp:paragraph --> -<p>Third column</p> -<!-- /wp:paragraph --></div> -<!-- /wp:column --></div> -<!-- /wp:columns -->" -`; - -exports[`Navigating the block hierarchy should navigate using the list view sidebar 1`] = ` -"<!-- wp:columns --> -<div class="wp-block-columns"><!-- wp:column --> -<div class="wp-block-column"><!-- wp:paragraph --> -<p>First column</p> -<!-- /wp:paragraph --></div> -<!-- /wp:column --> - -<!-- wp:column --> -<div class="wp-block-column"></div> -<!-- /wp:column --> - -<!-- wp:column --> -<div class="wp-block-column"><!-- wp:paragraph --> -<p>Third column</p> -<!-- /wp:paragraph --></div> -<!-- /wp:column --></div> -<!-- /wp:columns -->" -`; - -exports[`Navigating the block hierarchy should select the wrapper div for a group 1`] = ` -"<!-- wp:group {"layout":{"type":"constrained"}} --> -<div class="wp-block-group"><!-- wp:paragraph --> -<p>just a paragraph</p> -<!-- /wp:paragraph --> - -<!-- wp:separator --> -<hr class="wp-block-separator has-alpha-channel-opacity"/> -<!-- /wp:separator --></div> -<!-- /wp:group -->" -`; diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap index 5187e574f2c7aa..fa3400670a602d 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap @@ -88,8 +88,8 @@ exports[`Inserting blocks inserts a block in proper place after having clicked \ <p>First paragraph</p> <!-- /wp:paragraph --> -<!-- wp:cover {"isDark":false,"layout":{"type":"constrained"}} --> -<div class="wp-block-cover is-light"><span aria-hidden="true" class="wp-block-cover__background has-background-dim-100 has-background-dim"></span><div class="wp-block-cover__inner-container"></div></div> +<!-- wp:cover {"layout":{"type":"constrained"}} --> +<div class="wp-block-cover"><span aria-hidden="true" class="wp-block-cover__background has-background-dim-100 has-background-dim"></span><div class="wp-block-cover__inner-container"></div></div> <!-- /wp:cover --> <!-- wp:heading --> diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap index 7b54b5bd2f598f..541a5456fd4d53 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap @@ -12,18 +12,6 @@ exports[`Links can be created by selecting text and clicking Link 1`] = ` <!-- /wp:paragraph -->" `; -exports[`Links can be created by selecting text and using keyboard shortcuts 1`] = ` -"<!-- wp:paragraph --> -<p>This is Gutenberg</p> -<!-- /wp:paragraph -->" -`; - -exports[`Links can be created by selecting text and using keyboard shortcuts 2`] = ` -"<!-- wp:paragraph --> -<p>This is <a href="https://wordpress.org/gutenberg" target="_blank" rel="noreferrer noopener">Gutenberg</a></p> -<!-- /wp:paragraph -->" -`; - exports[`Links can be created instantly when a URL is selected 1`] = ` "<!-- wp:paragraph --> <p>This is Gutenberg: <a href="https://wordpress.org/gutenberg">https://wordpress.org/gutenberg</a></p> @@ -59,15 +47,3 @@ exports[`Links can be removed 1`] = ` <p>This is Gutenberg</p> <!-- /wp:paragraph -->" `; - -exports[`Links should contain a label when it should open in a new tab 1`] = ` -"<!-- wp:paragraph --> -<p>This is <a href="http://w.org" target="_blank" rel="noreferrer noopener">WordPress</a></p> -<!-- /wp:paragraph -->" -`; - -exports[`Links should contain a label when it should open in a new tab 2`] = ` -"<!-- wp:paragraph --> -<p>This is <a href="http://wordpress.org" target="_blank" rel="noreferrer noopener">WordPress</a></p> -<!-- /wp:paragraph -->" -`; diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap index 5d9c235e1c6a2e..7705ff11cbff9d 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap @@ -24,6 +24,16 @@ exports[`RichText should apply multiple formats when selection is collapsed 1`] <!-- /wp:paragraph -->" `; +exports[`RichText should copy/paste heading 1`] = ` +"<!-- wp:heading --> +<h2 class="wp-block-heading">Heading</h2> +<!-- /wp:heading --> + +<!-- wp:heading --> +<h2 class="wp-block-heading">Heading</h2> +<!-- /wp:heading -->" +`; + exports[`RichText should handle Home and End keys 1`] = ` "<!-- wp:paragraph --> <p>-<strong>12</strong>+</p> @@ -129,7 +139,11 @@ exports[`RichText should paste paragraph contents into list 1`] = ` <!-- wp:list --> <ul><!-- wp:list-item --> -<li>1<br>2</li> +<li>1</li> +<!-- /wp:list-item --> + +<!-- wp:list-item --> +<li>2</li> <!-- /wp:list-item --></ul> <!-- /wp:list -->" `; diff --git a/packages/e2e-tests/specs/editor/various/adding-inline-tokens.test.js b/packages/e2e-tests/specs/editor/various/adding-inline-tokens.test.js deleted file mode 100644 index 513a9fa7af27bc..00000000000000 --- a/packages/e2e-tests/specs/editor/various/adding-inline-tokens.test.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * External dependencies - */ -import path from 'path'; -import fs from 'fs'; -import os from 'os'; -import { v4 as uuid } from 'uuid'; - -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - getEditedPostContent, - createNewPost, - clickBlockToolbarButton, - clickButton, - pressKeyWithModifier, -} from '@wordpress/e2e-test-utils'; - -describe( 'adding inline tokens', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'should insert inline image', async () => { - // Create a paragraph. - await clickBlockAppender(); - await page.keyboard.type( 'a ' ); - - await clickBlockToolbarButton( 'More' ); - await clickButton( 'Inline image' ); - - // Wait for media modal to appear and upload image. - // Wait for media modal to appear and upload image. - const inputElement = await page.waitForSelector( - '.media-modal .moxie-shim input[type=file]' - ); - const testImagePath = path.join( - __dirname, - '..', - '..', - '..', - 'assets', - '10x10_e2e_test_image_z9T8jK.png' - ); - const filename = uuid(); - const tmpFileName = path.join( os.tmpdir(), filename + '.png' ); - fs.copyFileSync( testImagePath, tmpFileName ); - await inputElement.uploadFile( tmpFileName ); - - // Wait for upload. - await page.waitForSelector( - `.media-modal li[aria-label="${ filename }"]` - ); - - // Insert the uploaded image. - await page.click( '.media-modal button.media-button-select' ); - - // Check the content. - const regex = new RegExp( - `<!-- wp:paragraph -->\\s*<p>a <img class="wp-image-\\d+" style="width:\\s*10px;?" src="[^"]+\\/${ filename }\\.png" alt=""\\/?><\\/p>\\s*<!-- \\/wp:paragraph -->` - ); - expect( await getEditedPostContent() ).toMatch( regex ); - - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - await page.waitForSelector( - '.block-editor-format-toolbar__image-popover' - ); - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - await page.keyboard.type( '20' ); - await page.keyboard.press( 'Enter' ); - - // Check the content. - const regex2 = new RegExp( - `<!-- wp:paragraph -->\\s*<p>a <img class="wp-image-\\d+" style="width:\\s*20px;?" src="[^"]+\\/${ filename }\\.png" alt=""\\/?><\\/p>\\s*<!-- \\/wp:paragraph -->` - ); - expect( await getEditedPostContent() ).toMatch( regex2 ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/autosave.test.js b/packages/e2e-tests/specs/editor/various/autosave.test.js index abf4ad8b83e68a..528efc2d463167 100644 --- a/packages/e2e-tests/specs/editor/various/autosave.test.js +++ b/packages/e2e-tests/specs/editor/various/autosave.test.js @@ -9,6 +9,7 @@ import { publishPost, saveDraft, toggleOfflineMode, + canvas, } from '@wordpress/e2e-test-utils'; // Constant to override editor preference @@ -258,7 +259,7 @@ describe( 'autosave', () => { await page.keyboard.type( 'before publish' ); await publishPost(); - await page.click( '[data-type="core/paragraph"]' ); + await canvas().click( '[data-type="core/paragraph"]' ); await page.keyboard.type( ' after publish' ); // Trigger remote autosave. diff --git a/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js b/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js index e6e156ca8ee15d..b8f43f31cd77bb 100644 --- a/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js +++ b/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js @@ -9,6 +9,7 @@ import { clickBlockToolbarButton, clickMenuItem, clickOnCloseModalButton, + canvas, } from '@wordpress/e2e-test-utils'; const createTestParagraphBlocks = async () => { @@ -51,7 +52,7 @@ describe( 'block editor keyboard shortcuts', () => { await createTestParagraphBlocks(); expect( await getEditedPostContent() ).toMatchSnapshot(); await pressKeyWithModifier( 'shift', 'ArrowUp' ); - await page.waitForSelector( + await canvas().waitForSelector( '[aria-label="Multiple selected blocks"]' ); await moveUp(); @@ -63,7 +64,7 @@ describe( 'block editor keyboard shortcuts', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); await page.keyboard.press( 'ArrowUp' ); await pressKeyWithModifier( 'shift', 'ArrowUp' ); - await page.waitForSelector( + await canvas().waitForSelector( '[aria-label="Multiple selected blocks"]' ); await moveDown(); @@ -89,9 +90,9 @@ describe( 'block editor keyboard shortcuts', () => { } ); it( 'should prevent deleting multiple selected blocks from inputs', async () => { await clickBlockToolbarButton( 'Options' ); - await clickMenuItem( 'Create Reusable block' ); + await clickMenuItem( 'Create pattern' ); const reusableBlockNameInputSelector = - '.reusable-blocks-menu-items__convert-modal .components-text-control__input'; + '.patterns-menu-items__convert-modal .components-text-control__input'; const nameInput = await page.waitForSelector( reusableBlockNameInputSelector ); @@ -101,7 +102,7 @@ describe( 'block editor keyboard shortcuts', () => { await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.press( 'Delete' ); await clickOnCloseModalButton( - '.reusable-blocks-menu-items__convert-modal' + '.patterns-menu-items__convert-modal' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); diff --git a/packages/e2e-tests/specs/editor/various/block-grouping.test.js b/packages/e2e-tests/specs/editor/various/block-grouping.test.js index 07dcabcbf0526d..f67273a550d1c2 100644 --- a/packages/e2e-tests/specs/editor/various/block-grouping.test.js +++ b/packages/e2e-tests/specs/editor/various/block-grouping.test.js @@ -14,6 +14,7 @@ import { activatePlugin, deactivatePlugin, createReusableBlock, + canvas, } from '@wordpress/e2e-test-utils'; async function insertBlocksOfSameType() { @@ -115,8 +116,8 @@ describe( 'Block Grouping', () => { const getParagraphText = async () => { const paragraphInReusableSelector = '.block-editor-block-list__block[data-type="core/block"] p'; - await page.waitForSelector( paragraphInReusableSelector ); - return page.$eval( + await canvas().waitForSelector( paragraphInReusableSelector ); + return canvas().$eval( paragraphInReusableSelector, ( element ) => element.innerText ); @@ -128,14 +129,14 @@ describe( 'Block Grouping', () => { await clickBlockToolbarButton( 'Options' ); await clickMenuItem( 'Group' ); - let group = await page.$$( '[data-type="core/group"]' ); + let group = await canvas().$$( '[data-type="core/group"]' ); expect( group ).toHaveLength( 1 ); // Make sure the paragraph in reusable block exists. expect( await getParagraphText() ).toMatch( paragraphText ); await clickBlockToolbarButton( 'Options' ); await clickMenuItem( 'Ungroup' ); - group = await page.$$( '[data-type="core/group"]' ); + group = await canvas().$$( '[data-type="core/group"]' ); expect( group ).toHaveLength( 0 ); // Make sure the paragraph in reusable block exists. expect( await getParagraphText() ).toEqual( paragraphText ); diff --git a/packages/e2e-tests/specs/editor/various/block-hierarchy-navigation.test.js b/packages/e2e-tests/specs/editor/various/block-hierarchy-navigation.test.js deleted file mode 100644 index b1c5a7278efda8..00000000000000 --- a/packages/e2e-tests/specs/editor/various/block-hierarchy-navigation.test.js +++ /dev/null @@ -1,227 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - insertBlock, - getEditedPostContent, - pressKeyTimes, - pressKeyWithModifier, - openDocumentSettingsSidebar, - getListViewBlocks, - switchBlockInspectorTab, -} from '@wordpress/e2e-test-utils'; - -async function openListViewSidebar() { - await pressKeyWithModifier( 'access', 'o' ); - await page.waitForSelector( '.block-editor-list-view-leaf.is-selected' ); -} - -async function tabToColumnsControl() { - let isColumnsControl = false; - do { - await page.keyboard.press( 'Tab' ); - - const isBlockInspectorTab = await page.evaluate( () => { - const activeElement = document.activeElement; - return ( - activeElement.getAttribute( 'role' ) === 'tab' && - activeElement.attributes.getNamedItem( 'aria-label' ).value === - 'Styles' - ); - } ); - - if ( isBlockInspectorTab ) { - await page.keyboard.press( 'ArrowRight' ); - } - - isColumnsControl = await page.evaluate( () => { - const activeElement = document.activeElement; - return ( - activeElement.tagName === 'INPUT' && - activeElement.attributes.getNamedItem( 'aria-label' ).value === - 'Columns' - ); - } ); - } while ( ! isColumnsControl ); -} - -describe( 'Navigating the block hierarchy', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'should navigate using the list view sidebar', async () => { - await insertBlock( 'Columns' ); - await page.click( '[aria-label="Two columns; equal split"]' ); - - // Add a paragraph in the first column. - await page.keyboard.press( 'ArrowDown' ); // Navigate to inserter. - await page.keyboard.press( 'Enter' ); // Activate inserter. - // Wait for inserter results to appear and then insert a paragraph. - await page.waitForSelector( - '.block-editor-inserter__quick-inserter-results .editor-block-list-item-paragraph' - ); - await page.click( '.editor-block-list-item-paragraph' ); - await page.keyboard.type( 'First column' ); - - // Navigate to the columns blocks. - await page.click( - '.edit-post-header-toolbar__document-overview-toggle' - ); - - const firstColumnsBlockMenuItem = ( - await getListViewBlocks( 'Columns' ) - )[ 0 ]; - await firstColumnsBlockMenuItem.click(); - - // Tweak the columns count. - await openDocumentSettingsSidebar(); - await switchBlockInspectorTab( 'Settings' ); - await page.focus( - '.block-editor-block-inspector [aria-label="Columns"][type="number"]' - ); - await page.keyboard.down( 'Shift' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.up( 'Shift' ); - await page.keyboard.type( '3' ); - - // Wait for the new column block to appear in the list view - // 5 = Columns, Column, Paragraph, Column, *Column* - await page.waitForSelector( - 'tr.block-editor-list-view-leaf:nth-of-type(5)' - ); - - // Navigate to the last column block. - const lastColumnBlockMenuItem = ( - await getListViewBlocks( 'Column' ) - )[ 2 ]; - await lastColumnBlockMenuItem.click(); - - // Insert text in the last column block. - await page.keyboard.press( 'ArrowDown' ); // Navigate to inserter. - await page.keyboard.press( 'Enter' ); // Activate inserter. - // Wait for inserter results to appear and then insert a paragraph. - await page.waitForSelector( - '.block-editor-inserter__quick-inserter-results .editor-block-list-item-paragraph' - ); - await page.click( '.editor-block-list-item-paragraph' ); - await page.keyboard.type( 'Third column' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should navigate block hierarchy using only the keyboard', async () => { - await insertBlock( 'Columns' ); - await openDocumentSettingsSidebar(); - await page.click( '[aria-label="Two columns; equal split"]' ); - - // Add a paragraph in the first column. - await page.keyboard.press( 'ArrowDown' ); // Navigate to inserter. - await page.keyboard.press( 'Enter' ); // Activate inserter. - // Wait for inserter results to appear and then insert a paragraph. - await page.waitForSelector( - '.block-editor-inserter__quick-inserter-results .editor-block-list-item-paragraph' - ); - await page.click( '.editor-block-list-item-paragraph' ); - await page.keyboard.type( 'First column' ); - - // Navigate to the columns blocks using the keyboard. - await openListViewSidebar(); - await pressKeyTimes( 'ArrowUp', 2 ); - await page.keyboard.press( 'Enter' ); - - // Move focus to the sidebar area. - await pressKeyWithModifier( 'ctrl', '`' ); - await tabToColumnsControl(); - - // Tweak the columns count by increasing it by one. - await page.keyboard.press( 'ArrowRight' ); - - // Navigate to the third column in the columns block. - await pressKeyWithModifier( 'ctrlShift', '`' ); - await pressKeyWithModifier( 'ctrlShift', '`' ); - await pressKeyTimes( 'Tab', 4 ); - await pressKeyTimes( 'ArrowDown', 4 ); - await page.waitForSelector( - '.is-highlighted[aria-label="Block: Column (3 of 3)"]' - ); - await page.keyboard.press( 'Enter' ); - await page.waitForSelector( '.is-selected[data-type="core/column"]' ); - - // Insert text in the last column block. - await page.keyboard.press( 'ArrowDown' ); // Navigate to inserter. - await page.keyboard.press( 'Enter' ); // Activate inserter. - // Wait for inserter results to appear and then insert a paragraph. - await page.waitForSelector( - '.block-editor-inserter__quick-inserter-results .editor-block-list-item-paragraph' - ); - await page.click( '.editor-block-list-item-paragraph' ); - await page.keyboard.type( 'Third column' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should appear and function even without nested blocks', async () => { - const textString = 'You say goodbye'; - - await insertBlock( 'Paragraph' ); - - // Add content so there is a block in the hierarchy. - await page.keyboard.type( textString ); - - // Create an image block too. - await page.keyboard.press( 'Enter' ); - await insertBlock( 'Image' ); - - // Return to first block. - await openListViewSidebar(); - await page.keyboard.press( 'ArrowUp' ); - await page.keyboard.press( 'Space' ); - - // Replace its content. - await pressKeyWithModifier( 'primary', 'a' ); - await page.keyboard.type( 'and I say hello' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should select the wrapper div for a group', async () => { - // Insert a group block. - await insertBlock( 'Group' ); - // Select the default, selected Group layout from the variation picker. - await page.click( - 'button[aria-label="Group: Gather blocks in a container."]' - ); - // Insert some random blocks. - // The last block shouldn't be a textual block. - await page.click( '.block-list-appender .block-editor-inserter' ); - const paragraphMenuItem = ( - await page.$x( `//button//span[contains(text(), 'Paragraph')]` ) - )[ 0 ]; - await paragraphMenuItem.click(); - await page.keyboard.type( 'just a paragraph' ); - await insertBlock( 'Separator' ); - - // Check the Group block content. - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Unselect the blocks. - await page.click( '.editor-post-title' ); - - // Try selecting the group block using the Outline. - await page.click( - '.edit-post-header-toolbar__document-overview-toggle' - ); - const groupMenuItem = ( await getListViewBlocks( 'Group' ) )[ 0 ]; - await groupMenuItem.click(); - - // The group block's wrapper should be selected. - const isGroupBlockSelected = await page.evaluate( - () => - document.activeElement.getAttribute( 'data-type' ) === - 'core/group' - ); - expect( isGroupBlockSelected ).toBe( true ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/change-detection.test.js b/packages/e2e-tests/specs/editor/various/change-detection.test.js index dab3765ef0d148..97157060d36245 100644 --- a/packages/e2e-tests/specs/editor/various/change-detection.test.js +++ b/packages/e2e-tests/specs/editor/various/change-detection.test.js @@ -11,6 +11,7 @@ import { openDocumentSettingsSidebar, isCurrentURL, openTypographyToolsPanelMenu, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'Change detection', () => { @@ -78,7 +79,7 @@ describe( 'Change detection', () => { } ); it( 'Should autosave post', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Force autosave to occur immediately. await Promise.all( [ @@ -94,7 +95,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt to confirm unsaved changes for autosaved draft for non-content fields', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Toggle post as needing review (not persisted for autosave). await ensureSidebarOpened(); @@ -117,7 +118,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt to confirm unsaved changes for autosaved published post', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); await publishPost(); @@ -130,7 +131,7 @@ describe( 'Change detection', () => { ] ); // Should be dirty after autosave change of published post. - await page.type( '.editor-post-title__input', '!' ); + await canvas().type( '.editor-post-title__input', '!' ); await Promise.all( [ page.waitForSelector( @@ -162,7 +163,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt if property changed without save', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); await assertIsDirty( true ); } ); @@ -175,7 +176,7 @@ describe( 'Change detection', () => { } ); it( 'Should not prompt if changes saved', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); await saveDraft(); @@ -192,7 +193,7 @@ describe( 'Change detection', () => { } ); it( 'Should not save if all changes saved', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); await saveDraft(); @@ -205,7 +206,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt if save failed', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); await page.setOfflineMode( true ); @@ -231,7 +232,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt if changes and save is in-flight', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Hold the posts request so we don't deal with race conditions of the // save completing early. Other requests should be allowed to continue, @@ -247,7 +248,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt if changes made while save is in-flight', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Hold the posts request so we don't deal with race conditions of the // save completing early. Other requests should be allowed to continue, @@ -257,7 +258,7 @@ describe( 'Change detection', () => { // Keyboard shortcut Ctrl+S save. await pressKeyWithModifier( 'primary', 'S' ); - await page.type( '.editor-post-title__input', '!' ); + await canvas().type( '.editor-post-title__input', '!' ); await page.waitForSelector( '.editor-post-save-draft' ); await releaseSaveIntercept(); @@ -266,7 +267,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt if property changes made while save is in-flight, and save completes', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Hold the posts request so we don't deal with race conditions of the // save completing early. @@ -282,7 +283,7 @@ describe( 'Change detection', () => { ); // Dirty post while save is in-flight. - await page.type( '.editor-post-title__input', '!' ); + await canvas().type( '.editor-post-title__input', '!' ); // Allow save to complete. Disabling interception flushes pending. await Promise.all( [ savedPromise, releaseSaveIntercept() ] ); @@ -291,7 +292,7 @@ describe( 'Change detection', () => { } ); it( 'Should prompt if block revision is made while save is in-flight, and save completes', async () => { - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Hold the posts request so we don't deal with race conditions of the // save completing early. @@ -324,7 +325,7 @@ describe( 'Change detection', () => { await saveDraft(); // Verify that the title is empty. - const title = await page.$eval( + const title = await canvas().$eval( '.editor-post-title__input', // Trim padding non-breaking space. ( element ) => element.textContent.trim() @@ -337,7 +338,7 @@ describe( 'Change detection', () => { it( 'should not prompt to confirm unsaved changes when trashing an existing post', async () => { // Enter title. - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Save. await saveDraft(); @@ -381,7 +382,7 @@ describe( 'Change detection', () => { ] ); // Change the paragraph's `drop cap`. - await page.click( '[data-type="core/paragraph"]' ); + await canvas().click( '[data-type="core/paragraph"]' ); await openTypographyToolsPanelMenu(); await page.click( 'button[aria-label="Show Drop cap"]' ); @@ -390,7 +391,7 @@ describe( 'Change detection', () => { "//label[contains(text(), 'Drop cap')]" ); await dropCapToggle.click(); - await page.click( '[data-type="core/paragraph"]' ); + await canvas().click( '[data-type="core/paragraph"]' ); // Check that the post is dirty. await page.waitForSelector( '.editor-post-save-draft' ); @@ -402,7 +403,7 @@ describe( 'Change detection', () => { ] ); // Change the paragraph's `drop cap` again. - await page.click( '[data-type="core/paragraph"]' ); + await canvas().click( '[data-type="core/paragraph"]' ); await dropCapToggle.click(); // Check that the post is dirty. diff --git a/packages/e2e-tests/specs/editor/various/editor-modes.test.js b/packages/e2e-tests/specs/editor/various/editor-modes.test.js index de328b87f736d0..81878ebf7208e3 100644 --- a/packages/e2e-tests/specs/editor/various/editor-modes.test.js +++ b/packages/e2e-tests/specs/editor/various/editor-modes.test.js @@ -11,6 +11,7 @@ import { pressKeyTimes, pressKeyWithModifier, openTypographyToolsPanelMenu, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'Editing modes (visual/HTML)', () => { @@ -22,7 +23,7 @@ describe( 'Editing modes (visual/HTML)', () => { it( 'should switch between visual and HTML modes', async () => { // This block should be in "visual" mode by default. - let visualBlock = await page.$$( '[data-block].rich-text' ); + let visualBlock = await canvas().$$( '[data-block].rich-text' ); expect( visualBlock ).toHaveLength( 1 ); // Change editing mode from "Visual" to "HTML". @@ -30,7 +31,7 @@ describe( 'Editing modes (visual/HTML)', () => { await clickMenuItem( 'Edit as HTML' ); // Wait for the block to be converted to HTML editing mode. - const htmlBlock = await page.$$( + const htmlBlock = await canvas().$$( '[data-block] .block-editor-block-list__block-html-textarea' ); expect( htmlBlock ).toHaveLength( 1 ); @@ -40,7 +41,7 @@ describe( 'Editing modes (visual/HTML)', () => { await clickMenuItem( 'Edit visually' ); // This block should be in "visual" mode by default. - visualBlock = await page.$$( '[data-block].rich-text' ); + visualBlock = await canvas().$$( '[data-block].rich-text' ); expect( visualBlock ).toHaveLength( 1 ); } ); @@ -67,7 +68,7 @@ describe( 'Editing modes (visual/HTML)', () => { await clickMenuItem( 'Edit as HTML' ); // Make sure the paragraph content is rendered as expected. - let htmlBlockContent = await page.$eval( + let htmlBlockContent = await canvas().$eval( '.block-editor-block-list__layout .block-editor-block-list__block .block-editor-block-list__block-html-textarea', ( node ) => node.textContent ); @@ -83,7 +84,7 @@ describe( 'Editing modes (visual/HTML)', () => { await dropCapToggle.click(); // Make sure the HTML content updated. - htmlBlockContent = await page.$eval( + htmlBlockContent = await canvas().$eval( '.block-editor-block-list__layout .block-editor-block-list__block .block-editor-block-list__block-html-textarea', ( node ) => node.textContent ); @@ -138,7 +139,7 @@ describe( 'Editing modes (visual/HTML)', () => { const editPosition = textContent.indexOf( 'Hello' ); // Replace the word 'Hello' with 'Hi'. - await page.click( '.editor-post-title__input' ); + await canvas().click( '.editor-post-title__input' ); await page.keyboard.press( 'Tab' ); await pressKeyTimes( 'ArrowRight', editPosition ); await pressKeyTimes( 'Delete', 5 ); diff --git a/packages/e2e-tests/specs/editor/various/embedding.test.js b/packages/e2e-tests/specs/editor/various/embedding.test.js index a7522b88e7729f..4461fc62330530 100644 --- a/packages/e2e-tests/specs/editor/various/embedding.test.js +++ b/packages/e2e-tests/specs/editor/various/embedding.test.js @@ -3,7 +3,6 @@ */ import { clickBlockAppender, - clickButton, createEmbeddingMatcher, createJSONResponse, createNewPost, @@ -12,6 +11,7 @@ import { insertBlock, publishPost, setUpResponseMocking, + canvas, } from '@wordpress/e2e-test-utils'; const MOCK_EMBED_WORDPRESS_SUCCESS_RESPONSE = { @@ -178,24 +178,24 @@ describe( 'Embedding content', () => { it( 'should render embeds in the correct state', async () => { // Valid embed. Should render valid figure element. await insertEmbed( 'https://twitter.com/notnownikki' ); - await page.waitForSelector( 'figure.wp-block-embed' ); + await canvas().waitForSelector( 'figure.wp-block-embed' ); // Valid provider; invalid content. Should render failed, edit state. await insertEmbed( 'https://twitter.com/wooyaygutenberg123454312' ); - await page.waitForSelector( + await canvas().waitForSelector( 'input[value="https://twitter.com/wooyaygutenberg123454312"]' ); // WordPress invalid content. Should render failed, edit state. await insertEmbed( 'https://wordpress.org/gutenberg/handbook/' ); - await page.waitForSelector( + await canvas().waitForSelector( 'input[value="https://wordpress.org/gutenberg/handbook/"]' ); // Provider whose oembed API has gone wrong. Should render failed, edit // state. await insertEmbed( 'https://twitter.com/thatbunty' ); - await page.waitForSelector( + await canvas().waitForSelector( 'input[value="https://twitter.com/thatbunty"]' ); @@ -204,18 +204,18 @@ describe( 'Embedding content', () => { await insertEmbed( 'https://wordpress.org/gutenberg/handbook/block-api/attributes/' ); - await page.waitForSelector( 'figure.wp-block-embed' ); + await canvas().waitForSelector( 'figure.wp-block-embed' ); // Video content. Should render valid figure element, and include the // aspect ratio class. await insertEmbed( 'https://www.youtube.com/watch?v=lXMskKTw3Bc' ); - await page.waitForSelector( + await canvas().waitForSelector( 'figure.wp-block-embed.is-type-video.wp-embed-aspect-16-9' ); // Photo content. Should render valid figure element. await insertEmbed( 'https://cloudup.com/cQFlxqtY4ob' ); - await page.waitForSelector( + await canvas().waitForSelector( 'iframe[title="Embedded content from cloudup"' ); @@ -230,18 +230,21 @@ describe( 'Embedding content', () => { // has styles applied which depend on resize observer, wait for the // expected size class to settle before clicking, since otherwise a race // condition could occur on the placeholder layout vs. click intent. - await page.waitForSelector( + await canvas().waitForSelector( '.components-placeholder.is-large .components-placeholder__error' ); - await clickButton( 'Convert to link' ); + const button = await canvas().waitForXPath( + `//button[contains(text(), 'Convert to link')]` + ); + await button.click(); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); it( 'should retry embeds that could not be embedded with trailing slashes, without the trailing slashes', async () => { await insertEmbed( 'https://twitter.com/notnownikki/' ); // The twitter block should appear correctly. - await page.waitForSelector( 'figure.wp-block-embed' ); + await canvas().waitForSelector( 'figure.wp-block-embed' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -253,7 +256,7 @@ describe( 'Embedding content', () => { // has styles applied which depend on resize observer, wait for the // expected size class to settle before clicking, since otherwise a race // condition could occur on the placeholder layout vs. click intent. - await page.waitForSelector( + await canvas().waitForSelector( '.components-placeholder.is-large .components-placeholder__error' ); @@ -268,8 +271,11 @@ describe( 'Embedding content', () => { ), }, ] ); - await clickButton( 'Try again' ); - await page.waitForSelector( 'figure.wp-block-embed' ); + const button = await canvas().waitForXPath( + `//button[contains(text(), 'Try again')]` + ); + await button.click(); + await canvas().waitForSelector( 'figure.wp-block-embed' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -292,6 +298,6 @@ describe( 'Embedding content', () => { await insertEmbed( postUrl ); // Check the block has become a WordPress block. - await page.waitForSelector( 'figure.wp-block-embed' ); + await canvas().waitForSelector( 'figure.wp-block-embed' ); } ); } ); diff --git a/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js b/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js index 918737e2b38997..84c251d6534685 100644 --- a/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js +++ b/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js @@ -11,6 +11,7 @@ import { searchForBlock, setBrowserViewport, pressKeyWithModifier, + canvas, } from '@wordpress/e2e-test-utils'; /** @typedef {import('puppeteer-core').ElementHandle} ElementHandle */ @@ -165,7 +166,7 @@ describe( 'Inserting blocks', () => { await page.keyboard.press( 'Enter' ); expect( - await page.waitForSelector( '[data-type="core/tag-cloud"]' ) + await canvas().waitForSelector( '[data-type="core/tag-cloud"]' ) ).not.toBeNull(); } ); @@ -175,7 +176,7 @@ describe( 'Inserting blocks', () => { await page.keyboard.type( '1.1' ); // After inserting the Buttons block the inner button block should be selected. - const selectedButtonBlocks = await page.$$( + const selectedButtonBlocks = await canvas().$$( '.wp-block-button.is-selected' ); expect( selectedButtonBlocks.length ).toBe( 1 ); @@ -185,7 +186,7 @@ describe( 'Inserting blocks', () => { window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock() ); // Specifically click the root container appender. - await page.click( + await canvas().click( '.block-editor-block-list__layout.is-root-container > .block-list-appender .block-editor-inserter__toggle' ); @@ -222,7 +223,7 @@ describe( 'Inserting blocks', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); // Using the between inserter. - const insertionPoint = await page.$( '[data-type="core/heading"]' ); + const insertionPoint = await canvas().$( '[data-type="core/heading"]' ); const rect = await insertionPoint.boundingBox(); await page.mouse.move( rect.x + rect.width / 2, rect.y - 10, { steps: 10, @@ -247,7 +248,7 @@ describe( 'Inserting blocks', () => { await insertBlock( 'Paragraph' ); await page.keyboard.type( 'First paragraph' ); await insertBlock( 'Image' ); - const paragraphBlock = await page.$( + const paragraphBlock = await canvas().$( 'p[aria-label="Paragraph block"]' ); paragraphBlock.click(); @@ -278,14 +279,16 @@ describe( 'Inserting blocks', () => { it( 'inserts a block in proper place after having clicked `Browse All` from block appender', async () => { await insertBlock( 'Group' ); // Select the default, selected Group layout from the variation picker. - await page.click( + await canvas().click( 'button[aria-label="Group: Gather blocks in a container."]' ); await insertBlock( 'Paragraph' ); await page.keyboard.type( 'Paragraph after group' ); // Click the Group first to make the appender inside it clickable. - await page.click( '[data-type="core/group"]' ); - await page.click( '[data-type="core/group"] [aria-label="Add block"]' ); + await canvas().click( '[data-type="core/group"]' ); + await canvas().click( + '[data-type="core/group"] [aria-label="Add block"]' + ); const browseAll = await page.waitForXPath( '//button[text()="Browse all"]' ); @@ -300,14 +303,16 @@ describe( 'Inserting blocks', () => { '.block-editor-inserter__search input,.block-editor-inserter__search-input,input.block-editor-inserter__search'; await insertBlock( 'Group' ); // Select the default, selected Group layout from the variation picker. - await page.click( + await canvas().click( 'button[aria-label="Group: Gather blocks in a container."]' ); await insertBlock( 'Paragraph' ); await page.keyboard.type( 'Text' ); // Click the Group first to make the appender inside it clickable. - await page.click( '[data-type="core/group"]' ); - await page.click( '[data-type="core/group"] [aria-label="Add block"]' ); + await canvas().click( '[data-type="core/group"]' ); + await canvas().click( + '[data-type="core/group"] [aria-label="Add block"]' + ); await page.waitForSelector( INSERTER_SEARCH_SELECTOR ); await page.focus( INSERTER_SEARCH_SELECTOR ); await pressKeyWithModifier( 'primary', 'a' ); @@ -337,7 +342,7 @@ describe( 'Inserting blocks', () => { expect( inserterPanels.length ).toBe( 0 ); // The editable 'Read More' text should be focused. - const isFocusInBlock = await page.evaluate( () => + const isFocusInBlock = await canvas().evaluate( () => document .querySelector( '[data-type="core/more"]' ) .contains( document.activeElement ) @@ -366,14 +371,14 @@ describe( 'Inserting blocks', () => { async ( viewport ) => { await setBrowserViewport( viewport ); - await page.type( + await canvas().type( '.block-editor-default-block-appender__content', 'Testing inserted block focus' ); await insertBlock( 'Image' ); - await page.waitForSelector( 'figure[data-type="core/image"]' ); + await canvas().waitForSelector( 'figure[data-type="core/image"]' ); const selectedBlock = await page.evaluate( () => { return wp.data.select( 'core/block-editor' ).getSelectedBlock(); diff --git a/packages/e2e-tests/specs/editor/various/invalid-block.test.js b/packages/e2e-tests/specs/editor/various/invalid-block.test.js index ad08ac2f4c6b44..354c370434be92 100644 --- a/packages/e2e-tests/specs/editor/various/invalid-block.test.js +++ b/packages/e2e-tests/specs/editor/various/invalid-block.test.js @@ -7,6 +7,7 @@ import { clickBlockAppender, clickBlockToolbarButton, setPostContent, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'invalid blocks', () => { @@ -25,7 +26,7 @@ describe( 'invalid blocks', () => { await clickMenuItem( 'Edit as HTML' ); // Focus on the textarea and enter an invalid paragraph - await page.click( + await canvas().click( '.block-editor-block-list__layout .block-editor-block-list__block .block-editor-block-list__block-html-textarea' ); await page.keyboard.type( '<p>invalid paragraph' ); @@ -34,7 +35,7 @@ describe( 'invalid blocks', () => { await page.click( '.editor-post-save-draft' ); // Click on the 'three-dots' menu toggle. - await page.click( + await canvas().click( '.block-editor-warning__actions button[aria-label="More options"]' ); @@ -75,7 +76,7 @@ describe( 'invalid blocks', () => { expect( hasAlert ).toBe( false ); } ); - it( 'should strip potentially malicious script tags', async () => { + it( 'should not trigger malicious script tags when using a shortcode block', async () => { let hasAlert = false; page.on( 'dialog', () => { @@ -94,9 +95,6 @@ describe( 'invalid blocks', () => { // Give the browser time to show the alert. await page.evaluate( () => new Promise( window.requestIdleCallback ) ); - - expect( console ).toHaveWarned(); - expect( console ).toHaveErrored(); expect( hasAlert ).toBe( false ); } ); } ); diff --git a/packages/e2e-tests/specs/editor/various/keyboard-navigable-blocks.test.js b/packages/e2e-tests/specs/editor/various/keyboard-navigable-blocks.test.js index b19c4cb93f3e73..b3dccdf8bf20ad 100644 --- a/packages/e2e-tests/specs/editor/various/keyboard-navigable-blocks.test.js +++ b/packages/e2e-tests/specs/editor/various/keyboard-navigable-blocks.test.js @@ -8,13 +8,16 @@ import { clickBlockAppender, getEditedPostContent, showBlockToolbar, + canvas, } from '@wordpress/e2e-test-utils'; async function getActiveLabel() { return await page.evaluate( () => { + const { activeElement } = + document.activeElement.contentDocument ?? document; return ( - document.activeElement.getAttribute( 'aria-label' ) || - document.activeElement.innerHTML + activeElement.getAttribute( 'aria-label' ) || + activeElement.innerHTML ); } ); } @@ -34,7 +37,11 @@ const tabThroughParagraphBlock = async ( paragraphText ) => { await page.keyboard.press( 'Tab' ); await expect( await getActiveLabel() ).toBe( 'Paragraph block' ); await expect( - await page.evaluate( () => document.activeElement.innerHTML ) + await page.evaluate( () => { + const { activeElement } = + document.activeElement.contentDocument ?? document; + return activeElement.innerHTML; + } ) ).toBe( paragraphText ); await page.keyboard.press( 'Tab' ); @@ -113,16 +120,12 @@ describe( 'Order of block keyboard navigation', () => { } // Clear the selected block. - const paragraph = await page.$( '[data-type="core/paragraph"]' ); + const paragraph = await canvas().$( '[data-type="core/paragraph"]' ); const box = await paragraph.boundingBox(); await page.mouse.click( box.x - 1, box.y ); await page.keyboard.press( 'Tab' ); - await expect( - await page.evaluate( () => { - return document.activeElement.getAttribute( 'aria-label' ); - } ) - ).toBe( 'Add title' ); + await expect( await getActiveLabel() ).toBe( 'Add title' ); await page.keyboard.press( 'Tab' ); await expect( await getActiveLabel() ).toBe( @@ -148,7 +151,7 @@ describe( 'Order of block keyboard navigation', () => { } // Clear the selected block. - const paragraph = await page.$( '[data-type="core/paragraph"]' ); + const paragraph = await canvas().$( '[data-type="core/paragraph"]' ); const box = await paragraph.boundingBox(); await page.mouse.click( box.x - 1, box.y ); @@ -176,11 +179,7 @@ describe( 'Order of block keyboard navigation', () => { ); await pressKeyWithModifier( 'shift', 'Tab' ); - await expect( - await page.evaluate( () => { - return document.activeElement.getAttribute( 'aria-label' ); - } ) - ).toBe( 'Add title' ); + await expect( await getActiveLabel() ).toBe( 'Add title' ); } ); it( 'should navigate correctly with multi selection', async () => { @@ -217,7 +216,7 @@ describe( 'Order of block keyboard navigation', () => { await insertBlock( 'Image' ); // Make sure the upload button has focus. - const uploadButton = await page.waitForXPath( + const uploadButton = await canvas().waitForXPath( '//button[contains( text(), "Upload" ) ]' ); await expect( uploadButton ).toHaveFocus(); @@ -231,7 +230,7 @@ describe( 'Order of block keyboard navigation', () => { // Insert a group block. await insertBlock( 'Group' ); // Select the default, selected Group layout from the variation picker. - await page.click( + await canvas().click( 'button[aria-label="Group: Gather blocks in a container."]' ); // If active label matches, that means focus did not change from group block wrapper. diff --git a/packages/e2e-tests/specs/editor/various/links.test.js b/packages/e2e-tests/specs/editor/various/links.test.js index 9c3e8a722a7f0e..719d00afe076bb 100644 --- a/packages/e2e-tests/specs/editor/various/links.test.js +++ b/packages/e2e-tests/specs/editor/various/links.test.js @@ -9,6 +9,7 @@ import { pressKeyWithModifier, showBlockToolbar, pressKeyTimes, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'Links', () => { @@ -17,9 +18,16 @@ describe( 'Links', () => { } ); const waitForURLFieldAutoFocus = async () => { - await page.waitForFunction( - () => !! document.activeElement.closest( '.block-editor-url-input' ) - ); + await page.waitForFunction( () => { + const input = document.querySelector( + '.block-editor-url-input__input' + ); + if ( input ) { + input.focus(); + return true; + } + return false; + } ); }; it( 'will use Post title as link text if link to existing post is created without any text selected', async () => { @@ -50,7 +58,7 @@ describe( 'Links', () => { await page.keyboard.press( 'Enter' ); - const actualText = await page.evaluate( + const actualText = await canvas().evaluate( () => document.querySelector( '.block-editor-rich-text__editable a' ) .textContent @@ -97,54 +105,13 @@ describe( 'Links', () => { await waitForURLFieldAutoFocus(); const urlInputValue = await page.evaluate( - () => document.querySelector( '[aria-label="URL"]' ).value + () => + document.querySelector( '.block-editor-url-input__input' ).value ); expect( urlInputValue ).toBe( '' ); } ); - it( 'can be created by selecting text and using keyboard shortcuts', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - - // Select some text. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Open settings. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Navigate to and toggle the "Open in new tab" checkbox. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Toggle should still have focus and be checked. - await page.waitForSelector( - ':focus:checked.components-form-toggle__input' - ); - - // Ensure that the contents of the post have not been changed, since at - // this point the link is still not inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Tab back to the Submit and apply the link. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); - - // The link should have been inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - it( 'can be created without any text selected', async () => { // Create a block with some text. await clickBlockAppender(); @@ -207,7 +174,7 @@ describe( 'Links', () => { await page.keyboard.type( 'https://wordpress.org/gutenberg' ); // Click somewhere else - it doesn't really matter where. - await page.click( '.editor-post-title' ); + await canvas().click( '.editor-post-title' ); } ); const createAndReselectLink = async () => { @@ -346,7 +313,7 @@ describe( 'Links', () => { const createPostWithTitle = async ( titleText ) => { await createNewPost(); - await page.type( '.editor-post-title__input', titleText ); + await canvas().type( '.editor-post-title__input', titleText ); await page.click( '.editor-post-publish-panel__toggle' ); // Disable reason: Wait for the animation to complete, since otherwise the @@ -520,81 +487,6 @@ describe( 'Links', () => { ); } ); - it( 'should contain a label when it should open in a new tab', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'This is WordPress' ); - // Select "WordPress". - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'k' ); - await waitForURLFieldAutoFocus(); - await page.keyboard.type( 'w.org' ); - - // Link settings open - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Navigate to and toggle the "Open in new tab" checkbox. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Confirm that focus was not prematurely returned to the paragraph on - // a changing value of the setting. - await page.waitForSelector( ':focus.components-form-toggle__input' ); - - // Submit link. Expect that "Open in new tab" would have been applied - // immediately. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); - - // Wait for Gutenberg to finish the job. - await page.waitForXPath( - '//a[contains(@href,"w.org") and @target="_blank"]' - ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Regression Test: This verifies that the UI is updated according to - // the expected changed values, where previously the value could have - // fallen out of sync with how the UI is displayed (specifically for - // collapsed selections). - // - // See: https://github.com/WordPress/gutenberg/pull/15573 - - // Move caret back into the link. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - - // Edit link. - await pressKeyWithModifier( 'primary', 'k' ); - await waitForURLFieldAutoFocus(); - await pressKeyWithModifier( 'primary', 'a' ); - await page.keyboard.type( 'wordpress.org' ); - - // Update the link. - await page.keyboard.press( 'Enter' ); - - // Navigate back to the popover. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - - // Navigate back to inputs to verify appears as changed. - await pressKeyWithModifier( 'primary', 'k' ); - await waitForURLFieldAutoFocus(); - - // Navigate to the "Open in new tab" checkbox. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - // Uncheck the checkbox. - await page.keyboard.press( 'Space' ); - - // Wait for Gutenberg to finish the job. - await page.waitForXPath( - '//a[contains(@href,"wordpress.org") and not(@target)]' - ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - describe( 'Editing link text', () => { it( 'should not display text input when initially creating the link', async () => { // Create a block with some text. @@ -605,7 +497,7 @@ describe( 'Links', () => { await pressKeyWithModifier( 'primary', 'K' ); const [ settingsToggle ] = await page.$x( - '//button[contains(@aria-label, "Link Settings")]' + '//button[contains(text(), "Advanced")]' ); await settingsToggle.click(); @@ -637,12 +529,7 @@ describe( 'Links', () => { await waitForURLFieldAutoFocus(); - const [ settingsToggle ] = await page.$x( - '//button[contains(@aria-label, "Link Settings")]' - ); - await settingsToggle.click(); - - await page.keyboard.press( 'Tab' ); + await pressKeyWithModifier( 'shift', 'Tab' ); // Tabbing should land us in the text input. const { isTextInput, textValue } = await page.evaluate( () => { @@ -701,14 +588,9 @@ describe( 'Links', () => { await waitForURLFieldAutoFocus(); - const [ settingsToggle ] = await page.$x( - '//button[contains(@aria-label, "Link Settings")]' - ); - await settingsToggle.click(); - - await page.keyboard.press( 'Tab' ); + // Tabbing backward should land us in the "Text" input. + await pressKeyWithModifier( 'shift', 'Tab' ); - // Tabbing back should land us in the text input. const textInputValue = await page.evaluate( () => document.activeElement.value ); @@ -734,16 +616,11 @@ describe( 'Links', () => { '//button[contains(@aria-label, "Edit")]' ); await editButton.click(); - await waitForURLFieldAutoFocus(); - const [ settingsToggle ] = await page.$x( - '//button[contains(@aria-label, "Link Settings")]' - ); - await settingsToggle.click(); + await waitForURLFieldAutoFocus(); - await page.keyboard.press( 'Tab' ); + await pressKeyWithModifier( 'shift', 'Tab' ); - // Tabbing should land us in the text input. const textInputValue = await page.evaluate( () => document.activeElement.value ); @@ -762,7 +639,7 @@ describe( 'Links', () => { await page.keyboard.press( 'Enter' ); // Check the created link reflects the link text. - const actualLinkText = await page.evaluate( + const actualLinkText = await canvas().evaluate( () => document.querySelector( '.block-editor-rich-text__editable a' @@ -788,12 +665,17 @@ describe( 'Links', () => { await waitForURLFieldAutoFocus(); const [ settingsToggle ] = await page.$x( - '//button[contains(@aria-label, "Link Settings")]' + '//button[contains(text(), "Advanced")]' ); await settingsToggle.click(); + // Wait for settings to open. + await page.waitForXPath( `//label[text()='Open in new tab']` ); + // Move focus back to RichText for the underlying link. - await pressKeyTimes( 'Tab', 5 ); + await pressKeyWithModifier( 'shift', 'Tab' ); + await pressKeyWithModifier( 'shift', 'Tab' ); + await pressKeyWithModifier( 'shift', 'Tab' ); // Make a selection within the RichText. await pressKeyWithModifier( 'shift', 'ArrowRight' ); @@ -801,7 +683,7 @@ describe( 'Links', () => { await pressKeyWithModifier( 'shift', 'ArrowRight' ); // Move back to the text input. - await pressKeyTimes( 'Tab', 3 ); + await pressKeyTimes( 'Tab', 1 ); // Tabbing back should land us in the text input. const textInputValue = await page.evaluate( @@ -1001,19 +883,18 @@ describe( 'Links', () => { await waitForURLFieldAutoFocus(); - // Link settings open - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); + // Move to "Text" field. + await pressKeyWithModifier( 'shift', 'Tab' ); - // Move to Link Text field. - await page.keyboard.press( 'Tab' ); + // Delete existing value from "Text" field + await page.keyboard.press( 'Delete' ); // Change text to "z" await page.keyboard.type( 'z' ); await page.keyboard.press( 'Enter' ); - const richTextText = await page.evaluate( + const richTextText = await canvas().evaluate( () => document.querySelector( '.block-editor-rich-text__editable' @@ -1022,7 +903,7 @@ describe( 'Links', () => { // Check that the correct (i.e. last) instance of "a" was replaced with "z". expect( richTextText ).toBe( 'a b c z' ); - const richTextLink = await page.evaluate( + const richTextLink = await canvas().evaluate( () => document.querySelector( '.block-editor-rich-text__editable a' diff --git a/packages/e2e-tests/specs/editor/various/navigable-toolbar.test.js b/packages/e2e-tests/specs/editor/various/navigable-toolbar.test.js deleted file mode 100644 index 8fb2edf4f6768d..00000000000000 --- a/packages/e2e-tests/specs/editor/various/navigable-toolbar.test.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * WordPress dependencies - */ -import { createNewPost, pressKeyWithModifier } from '@wordpress/e2e-test-utils'; - -async function isInBlockToolbar() { - return await page.evaluate( () => { - return !! document.activeElement.closest( - '.block-editor-block-toolbar' - ); - } ); -} - -describe( 'Block Toolbar', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - describe( 'Contextual Toolbar', () => { - it( 'should not scroll page', async () => { - while ( - await page.evaluate( () => { - const scrollable = wp.dom.getScrollContainer( - document.activeElement - ); - return ! scrollable || scrollable.scrollTop === 0; - } ) - ) { - await page.keyboard.press( 'Enter' ); - } - - await page.keyboard.type( 'a' ); - - const scrollTopBefore = await page.evaluate( - () => - wp.dom.getScrollContainer( document.activeElement ) - .scrollTop - ); - - await pressKeyWithModifier( 'alt', 'F10' ); - expect( await isInBlockToolbar() ).toBe( true ); - - const scrollTopAfter = await page.evaluate( - () => - wp.dom.getScrollContainer( document.activeElement ) - .scrollTop - ); - - expect( scrollTopBefore ).toBe( scrollTopAfter ); - } ); - - it( 'navigates into the toolbar by keyboard (Alt+F10)', async () => { - // Assumes new post focus starts in title. Create first new - // block by Enter. - await page.keyboard.press( 'Enter' ); - - // [TEMPORARY]: A new paragraph is not technically a block yet - // until starting to type within it. - await page.keyboard.type( 'Example' ); - - // Upward. - await pressKeyWithModifier( 'alt', 'F10' ); - - expect( await isInBlockToolbar() ).toBe( true ); - } ); - } ); - - describe( 'Unified Toolbar', () => { - beforeEach( async () => { - // Enable unified toolbar - await page.evaluate( () => { - const { select, dispatch } = wp.data; - const isCurrentlyUnified = - select( 'core/edit-post' ).isFeatureActive( - 'fixedToolbar' - ); - if ( ! isCurrentlyUnified ) { - dispatch( 'core/edit-post' ).toggleFeature( - 'fixedToolbar' - ); - } - } ); - } ); - - it( 'navigates into the toolbar by keyboard (Alt+F10)', async () => { - // Assumes new post focus starts in title. Create first new - // block by Enter. - await page.keyboard.press( 'Enter' ); - - // [TEMPORARY]: A new paragraph is not technically a block yet - // until starting to type within it. - await page.keyboard.type( 'Example' ); - - // Upward. - await pressKeyWithModifier( 'alt', 'F10' ); - - expect( - await page.evaluate( () => { - return document.activeElement.getAttribute( 'aria-label' ); - } ) - ).toBe( 'Show document tools' ); - } ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/nux.test.js b/packages/e2e-tests/specs/editor/various/nux.test.js index 1edc92e9e85752..8ea151686d2eb0 100644 --- a/packages/e2e-tests/specs/editor/various/nux.test.js +++ b/packages/e2e-tests/specs/editor/various/nux.test.js @@ -1,7 +1,11 @@ /** * WordPress dependencies */ -import { createNewPost, clickOnMoreMenuItem } from '@wordpress/e2e-test-utils'; +import { + createNewPost, + clickOnMoreMenuItem, + canvas, +} from '@wordpress/e2e-test-utils'; describe( 'New User Experience (NUX)', () => { it( 'should show the guide to first-time users', async () => { @@ -128,7 +132,7 @@ describe( 'New User Experience (NUX)', () => { await page.click( '[role="dialog"] button[aria-label="Close"]' ); // Focus should be in post title field. - const postTitle = await page.waitForSelector( + const postTitle = await canvas().waitForSelector( 'h1[aria-label="Add title"' ); await expect( postTitle ).toHaveFocus(); diff --git a/packages/e2e-tests/specs/editor/various/publish-button.test.js b/packages/e2e-tests/specs/editor/various/publish-button.test.js index 2db6608331cb33..90ef0950e535bb 100644 --- a/packages/e2e-tests/specs/editor/various/publish-button.test.js +++ b/packages/e2e-tests/specs/editor/various/publish-button.test.js @@ -6,6 +6,7 @@ import { disablePrePublishChecks, enablePrePublishChecks, createNewPost, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'PostPublishButton', () => { @@ -32,7 +33,7 @@ describe( 'PostPublishButton', () => { } ); it( 'should be disabled when post is being saved', async () => { - await page.type( '.editor-post-title__input', 'E2E Test Post' ); // Make it saveable. + await canvas().type( '.editor-post-title__input', 'E2E Test Post' ); // Make it saveable. expect( await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) ).toBeNull(); @@ -42,19 +43,4 @@ describe( 'PostPublishButton', () => { await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) ).not.toBeNull(); } ); - - it( 'should be disabled when metabox is being saved', async () => { - await page.type( '.editor-post-title__input', 'E2E Test Post' ); // Make it saveable. - expect( - await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) - ).toBeNull(); - - await page.evaluate( () => { - window.wp.data.dispatch( 'core/edit-post' ).requestMetaBoxUpdates(); - return true; - } ); - expect( - await page.$( '.editor-post-publish-button[aria-disabled="true"]' ) - ).not.toBeNull(); - } ); } ); diff --git a/packages/e2e-tests/specs/editor/various/publish-panel.test.js b/packages/e2e-tests/specs/editor/various/publish-panel.test.js index 3a6aefd8f66870..333f2f1c2a8b3b 100644 --- a/packages/e2e-tests/specs/editor/various/publish-panel.test.js +++ b/packages/e2e-tests/specs/editor/various/publish-panel.test.js @@ -9,6 +9,7 @@ import { openPublishPanel, pressKeyWithModifier, publishPost, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'PostPublishPanel', () => { @@ -28,7 +29,7 @@ describe( 'PostPublishPanel', () => { } ); it( 'PrePublish: publish button should have the focus', async () => { - await page.type( '.editor-post-title__input', 'E2E Test Post' ); + await canvas().type( '.editor-post-title__input', 'E2E Test Post' ); await openPublishPanel(); const focusedElementClassList = await page.$eval( @@ -44,7 +45,7 @@ describe( 'PostPublishPanel', () => { it( 'PostPublish: post link should have the focus', async () => { const postTitle = 'E2E Test Post'; - await page.type( '.editor-post-title__input', postTitle ); + await canvas().type( '.editor-post-title__input', postTitle ); await publishPost(); const focusedElementTag = await page.$eval( @@ -64,7 +65,7 @@ describe( 'PostPublishPanel', () => { } ); it( 'should retain focus within the panel', async () => { - await page.type( '.editor-post-title__input', 'E2E Test Post' ); + await canvas().type( '.editor-post-title__input', 'E2E Test Post' ); await openPublishPanel(); await pressKeyWithModifier( 'shift', 'Tab' ); diff --git a/packages/e2e-tests/specs/editor/various/publishing.test.js b/packages/e2e-tests/specs/editor/various/publishing.test.js index 88c0d2b993db3f..fbac8cf98638bb 100644 --- a/packages/e2e-tests/specs/editor/various/publishing.test.js +++ b/packages/e2e-tests/specs/editor/various/publishing.test.js @@ -11,6 +11,7 @@ import { setBrowserViewport, openPublishPanel, pressKeyWithModifier, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'Publishing', () => { @@ -22,7 +23,7 @@ describe( 'Publishing', () => { } ); it( `disables the publish button when a ${ postType } is locked`, async () => { - await page.type( + await canvas().type( '.editor-post-title__input', 'E2E Test Post lock check publish button' ); @@ -42,7 +43,7 @@ describe( 'Publishing', () => { } ); it( `disables the save shortcut when a ${ postType } is locked`, async () => { - await page.type( + await canvas().type( '.editor-post-title__input', 'E2E Test Post check save shortcut' ); @@ -79,7 +80,7 @@ describe( 'Publishing', () => { } ); it( `should publish the ${ postType } and close the panel once we start editing again.`, async () => { - await page.type( '.editor-post-title__input', 'E2E Test Post' ); + await canvas().type( '.editor-post-title__input', 'E2E Test Post' ); await publishPost(); @@ -89,7 +90,7 @@ describe( 'Publishing', () => { ).not.toBeNull(); // Start editing again. - await page.type( '.editor-post-title__input', ' (Updated)' ); + await canvas().type( '.editor-post-title__input', ' (Updated)' ); // The post-publishing panel is not visible anymore. expect( await page.$( '.editor-post-publish-panel' ) ).toBeNull(); @@ -117,7 +118,10 @@ describe( 'Publishing', () => { } ); it( `should publish the ${ postType } without opening the post-publish sidebar.`, async () => { - await page.type( '.editor-post-title__input', 'E2E Test Post' ); + await canvas().type( + '.editor-post-title__input', + 'E2E Test Post' + ); // The "Publish" button should be shown instead of the "Publish..." toggle. expect( diff --git a/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js b/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js index a94cb1f7214267..a647b61c2b4d3f 100644 --- a/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js +++ b/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js @@ -16,12 +16,15 @@ import { saveDraft, createReusableBlock, publishPost, + canvas, } from '@wordpress/e2e-test-utils'; const reusableBlockNameInputSelector = - '.reusable-blocks-menu-items__convert-modal .components-text-control__input'; + '.patterns-menu-items__convert-modal .components-text-control__input'; const reusableBlockInspectorNameInputSelector = '.block-editor-block-inspector .components-text-control__input'; +const syncToggleSelectorChecked = + '.patterns-menu-items__convert-modal .components-form-toggle.is-checked'; const saveAll = async () => { const publishButtonSelector = @@ -83,7 +86,7 @@ describe( 'Reusable blocks', () => { await page.keyboard.type( 'Surprised greeting block' ); // Quickly focus the paragraph block. - await page.click( + await canvas().click( '.block-editor-block-list__block[data-type="core/block"] p' ); await page.keyboard.press( 'Escape' ); // Enter navigation mode. @@ -96,7 +99,7 @@ describe( 'Reusable blocks', () => { await saveAllButDontPublish(); // Check that its content is up to date. - const text = await page.$eval( + const text = await canvas().$eval( '.block-editor-block-list__block[data-type="core/block"] p', ( element ) => element.innerText ); @@ -108,16 +111,17 @@ describe( 'Reusable blocks', () => { await insertReusableBlock( 'Surprised greeting block' ); // Convert block to a regular block. - await clickBlockToolbarButton( 'Convert to regular block' ); + await clickBlockToolbarButton( 'Options' ); + await clickMenuItem( 'Detach pattern' ); // Check that we have a paragraph block on the page. - const paragraphBlock = await page.$( + const paragraphBlock = await canvas().$( '.block-editor-block-list__block[data-type="core/paragraph"]' ); expect( paragraphBlock ).not.toBeNull(); // Check that its content is up to date. - const paragraphContent = await page.$eval( + const paragraphContent = await canvas().$eval( '.block-editor-block-list__block[data-type="core/paragraph"]', ( element ) => element.innerText ); @@ -132,7 +136,7 @@ describe( 'Reusable blocks', () => { ); // Make sure the reusable block has loaded properly before attempting to publish the post. - await page.waitForSelector( 'p[aria-label="Paragraph block"]' ); + await canvas().waitForSelector( 'p[aria-label="Paragraph block"]' ); await publishPost(); @@ -142,8 +146,8 @@ describe( 'Reusable blocks', () => { await page.waitForSelector( closePublishPanelSelector ); await page.click( closePublishPanelSelector ); - await page.waitForSelector( 'p[aria-label="Paragraph block"]' ); - await page.focus( 'p[aria-label="Paragraph block"]' ); + await canvas().waitForSelector( 'p[aria-label="Paragraph block"]' ); + await canvas().focus( 'p[aria-label="Paragraph block"]' ); // Change the block's content. await page.keyboard.type( 'Einen ' ); @@ -152,7 +156,7 @@ describe( 'Reusable blocks', () => { await saveAll(); // Check that its content is up to date. - const paragraphContent = await page.$eval( + const paragraphContent = await canvas().$eval( 'p[aria-label="Paragraph block"]', ( element ) => element.innerText ); @@ -192,7 +196,7 @@ describe( 'Reusable blocks', () => { // Convert block to a reusable block. await clickBlockToolbarButton( 'Options' ); - await clickMenuItem( 'Create Reusable block' ); + await clickMenuItem( 'Create pattern' ); // Set title. const nameInput = await page.waitForSelector( @@ -200,11 +204,12 @@ describe( 'Reusable blocks', () => { ); await nameInput.click(); await page.keyboard.type( 'Multi-selection reusable block' ); + await page.waitForSelector( syncToggleSelectorChecked ); await page.keyboard.press( 'Enter' ); // Wait for creation to finish. await page.waitForXPath( - '//*[contains(@class, "components-snackbar")]/*[text()="Reusable block created."]' + '//*[contains(@class, "components-snackbar")]/*[contains(text(),"Pattern created:")]' ); await clearAllBlocks(); @@ -213,7 +218,8 @@ describe( 'Reusable blocks', () => { await insertReusableBlock( 'Multi-selection reusable block' ); // Convert block to a regular block. - await clickBlockToolbarButton( 'Convert to regular blocks' ); + await clickBlockToolbarButton( 'Options' ); + await clickMenuItem( 'Detach patterns' ); // Check that we have two paragraph blocks on the page. expect( await getEditedPostContent() ).toMatchSnapshot(); @@ -235,11 +241,12 @@ describe( 'Reusable blocks', () => { await editButton.click(); await page.waitForNavigation(); + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); // Click the block to give it focus. const blockSelector = 'p[data-title="Paragraph"]'; - await page.waitForSelector( blockSelector ); - await page.click( blockSelector ); + await canvas().waitForSelector( blockSelector ); + await canvas().click( blockSelector ); // Delete the block, leaving the reusable block empty. await clickBlockToolbarButton( 'Options' ); @@ -257,7 +264,7 @@ describe( 'Reusable blocks', () => { // Save the reusable block. await page.click( publishButtonSelector ); await page.waitForXPath( - '//*[contains(@class, "components-snackbar")]/*[text()="Reusable block updated."]' + '//*[contains(@class, "components-snackbar")]/*[text()="Pattern updated."]' ); await createNewPost(); @@ -277,7 +284,7 @@ describe( 'Reusable blocks', () => { ] ); } ); - await page.waitForXPath( + await canvas().waitForXPath( '//*[contains(@class, "block-editor-warning")]/*[text()="Block has been deleted or is unavailable."]' ); @@ -295,15 +302,16 @@ describe( 'Reusable blocks', () => { await insertReusableBlock( 'Duplicated reusable block' ); await saveDraft(); await page.reload(); + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); // Wait for the paragraph to be loaded. - await page.waitForSelector( + await canvas().waitForSelector( '.block-editor-block-list__block[data-type="core/paragraph"]' ); // The first click selects the reusable block wrapper. // The second click selects the actual paragraph block. - await page.click( '.wp-block-block' ); - await page.focus( + await canvas().click( '.wp-block-block' ); + await canvas().focus( '.block-editor-block-list__block[data-type="core/paragraph"]' ); await pressKeyWithModifier( 'primary', 'a' ); @@ -333,17 +341,18 @@ describe( 'Reusable blocks', () => { // Make an edit to the reusable block and assert that there's only a // paragraph in a reusable block. - await page.waitForSelector( 'p[aria-label="Paragraph block"]' ); - await page.click( 'p[aria-label="Paragraph block"]' ); + await canvas().waitForSelector( 'p[aria-label="Paragraph block"]' ); + await canvas().click( 'p[aria-label="Paragraph block"]' ); await page.keyboard.type( '2' ); const selector = - '//div[@aria-label="Block: Reusable block"]//p[@aria-label="Paragraph block"][.="12"]'; + '//div[@aria-label="Block: Pattern"]//p[@aria-label="Paragraph block"][.="12"]'; const reusableBlockWithParagraph = await page.$x( selector ); expect( reusableBlockWithParagraph ).toBeTruthy(); // Convert back to regular blocks. - await clickBlockToolbarButton( 'Select Reusable block' ); - await clickBlockToolbarButton( 'Convert to regular block' ); + await clickBlockToolbarButton( 'Select Edited block' ); + await clickBlockToolbarButton( 'Options' ); + await clickMenuItem( 'Detach pattern' ); await page.waitForXPath( selector, { hidden: true, } ); @@ -358,9 +367,10 @@ describe( 'Reusable blocks', () => { insertBlock( 'Quote' ); await saveDraft(); await page.reload(); + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); // The quote block should have a visible preview in the sidebar for this test to be valid. - const quoteBlock = await page.waitForSelector( + const quoteBlock = await canvas().waitForSelector( '.block-editor-block-list__block[aria-label="Block: Quote"]' ); // Select the quote block. @@ -372,15 +382,16 @@ describe( 'Reusable blocks', () => { // Convert to reusable. await clickBlockToolbarButton( 'Options' ); - await clickMenuItem( 'Create Reusable block' ); + await clickMenuItem( 'Create pattern' ); const nameInput = await page.waitForSelector( reusableBlockNameInputSelector ); await nameInput.click(); await page.keyboard.type( 'Block with styles' ); + await page.waitForSelector( syncToggleSelectorChecked ); await page.keyboard.press( 'Enter' ); - const reusableBlock = await page.waitForSelector( - '.block-editor-block-list__block[aria-label="Block: Reusable block"]' + const reusableBlock = await canvas().waitForSelector( + '.block-editor-block-list__block[aria-label="Block: Pattern"]' ); expect( reusableBlock ).toBeTruthy(); } ); diff --git a/packages/e2e-tests/specs/editor/various/rich-text.test.js b/packages/e2e-tests/specs/editor/various/rich-text.test.js index ca7eac55471b1a..ff651e61d52ea9 100644 --- a/packages/e2e-tests/specs/editor/various/rich-text.test.js +++ b/packages/e2e-tests/specs/editor/various/rich-text.test.js @@ -9,6 +9,7 @@ import { pressKeyWithModifier, showBlockToolbar, clickBlockToolbarButton, + canvas, } from '@wordpress/e2e-test-utils'; describe( 'RichText', () => { @@ -24,8 +25,8 @@ describe( 'RichText', () => { // // See: https://github.com/WordPress/gutenberg/issues/3091 await insertBlock( 'Heading' ); - await page.waitForSelector( '[aria-label="Change heading level"]' ); - await page.click( '[aria-label="Change heading level"]' ); + await page.waitForSelector( '[aria-label="Change level"]' ); + await page.click( '[aria-label="Change level"]' ); await page.click( '[aria-label="Heading 3"]' ); expect( await getEditedPostContent() ).toMatchSnapshot(); @@ -74,7 +75,7 @@ describe( 'RichText', () => { await pressKeyWithModifier( 'shift', 'ArrowLeft' ); await pressKeyWithModifier( 'primary', 'b' ); - const count = await page.evaluate( + const count = await canvas().evaluate( () => document.querySelectorAll( '*[data-rich-text-format-boundary]' ) .length @@ -173,7 +174,7 @@ describe( 'RichText', () => { await pressKeyWithModifier( 'primary', 'b' ); await page.keyboard.type( '3' ); - await page.evaluate( () => { + await canvas().evaluate( () => { let called; const { body } = document; const config = { @@ -233,7 +234,7 @@ describe( 'RichText', () => { await page.keyboard.type( '4' ); - await page.evaluate( () => { + await canvas().evaluate( () => { // The selection change event should be called once. If there's only // one item in `window.unsubscribes`, it means that only one // function is present to disconnect the `mutationObserver`. @@ -274,7 +275,7 @@ describe( 'RichText', () => { await page.keyboard.press( 'Enter' ); // Wait for rich text editor to load. - await page.waitForSelector( '.block-editor-rich-text__editable' ); + await canvas().waitForSelector( '.block-editor-rich-text__editable' ); await pressKeyWithModifier( 'primary', 'b' ); await page.keyboard.type( '12' ); @@ -305,7 +306,7 @@ describe( 'RichText', () => { await page.keyboard.type( '1' ); // Simulate moving focus to a different app, then moving focus back, // without selection being changed. - await page.evaluate( () => { + await canvas().evaluate( () => { const activeElement = document.activeElement; activeElement.blur(); activeElement.focus(); @@ -515,7 +516,7 @@ describe( 'RichText', () => { // text in the DOM directly, setting selection in the right place, and // firing `compositionend`. // See https://github.com/puppeteer/puppeteer/issues/4981. - await page.evaluate( async () => { + await canvas().evaluate( async () => { document.activeElement.textContent = '`a`'; const selection = window.getSelection(); // The `selectionchange` and `compositionend` events should run in separate event @@ -555,4 +556,15 @@ describe( 'RichText', () => { // Expect: <strong>1</strong>-<em>2</em> expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + test( 'should copy/paste heading', async () => { + await insertBlock( 'Heading' ); + await page.keyboard.type( 'Heading' ); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'c' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + await pressKeyWithModifier( 'primary', 'v' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/e2e-tests/specs/editor/various/shortcut-help.test.js b/packages/e2e-tests/specs/editor/various/shortcut-help.test.js deleted file mode 100644 index 838a3edac2a2a4..00000000000000 --- a/packages/e2e-tests/specs/editor/various/shortcut-help.test.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - clickOnMoreMenuItem, - clickOnCloseModalButton, - pressKeyWithModifier, -} from '@wordpress/e2e-test-utils'; - -describe( 'keyboard shortcut help modal', () => { - beforeAll( async () => { - await createNewPost(); - } ); - - it( 'displays the shortcut help modal when opened using the menu item in the more menu', async () => { - await clickOnMoreMenuItem( 'Keyboard shortcuts' ); - const shortcutHelpModalElements = await page.$$( - '.edit-post-keyboard-shortcut-help-modal' - ); - expect( shortcutHelpModalElements ).toHaveLength( 1 ); - } ); - - it( 'closes the shortcut help modal when the close icon is clicked', async () => { - await clickOnCloseModalButton(); - const shortcutHelpModalElements = await page.$$( - '.edit-post-keyboard-shortcut-help-modal' - ); - expect( shortcutHelpModalElements ).toHaveLength( 0 ); - } ); - - it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => { - await pressKeyWithModifier( 'access', 'h' ); - const shortcutHelpModalElements = await page.$$( - '.edit-post-keyboard-shortcut-help-modal' - ); - expect( shortcutHelpModalElements ).toHaveLength( 1 ); - } ); - - it( 'closes the shortcut help modal when the shortcut key (access+h) is pressed again', async () => { - await pressKeyWithModifier( 'access', 'h' ); - const shortcutHelpModalElements = await page.$$( - '.edit-post-keyboard-shortcut-help-modal' - ); - expect( shortcutHelpModalElements ).toHaveLength( 0 ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/sidebar-permalink.test.js b/packages/e2e-tests/specs/editor/various/sidebar-permalink.test.js index a89ac0469f27ae..e23bd830cee4fe 100644 --- a/packages/e2e-tests/specs/editor/various/sidebar-permalink.test.js +++ b/packages/e2e-tests/specs/editor/various/sidebar-permalink.test.js @@ -6,6 +6,7 @@ import { createNewPost, deactivatePlugin, publishPost, + canvas, } from '@wordpress/e2e-test-utils'; const urlButtonSelector = '*[aria-label^="Change URL"]'; @@ -28,7 +29,7 @@ describe( 'Sidebar Permalink', () => { await page.keyboard.type( 'aaaaa' ); await publishPost(); // Start editing again. - await page.type( '.editor-post-title__input', ' (Updated)' ); + await canvas().type( '.editor-post-title__input', ' (Updated)' ); expect( await page.$( urlButtonSelector ) ).toBeNull(); } ); @@ -37,7 +38,7 @@ describe( 'Sidebar Permalink', () => { await page.keyboard.type( 'aaaaa' ); await publishPost(); // Start editing again. - await page.type( '.editor-post-title__input', ' (Updated)' ); + await canvas().type( '.editor-post-title__input', ' (Updated)' ); expect( await page.$( urlButtonSelector ) ).toBeNull(); } ); @@ -46,7 +47,7 @@ describe( 'Sidebar Permalink', () => { await page.keyboard.type( 'aaaaa' ); await publishPost(); // Start editing again. - await page.type( '.editor-post-title__input', ' (Updated)' ); + await canvas( 0 ).type( '.editor-post-title__input', ' (Updated)' ); expect( await page.$( urlButtonSelector ) ).not.toBeNull(); } ); } ); diff --git a/packages/e2e-tests/specs/editor/various/taxonomies.test.js b/packages/e2e-tests/specs/editor/various/taxonomies.test.js index 551187d654dd93..a1804307fe74e5 100644 --- a/packages/e2e-tests/specs/editor/various/taxonomies.test.js +++ b/packages/e2e-tests/specs/editor/various/taxonomies.test.js @@ -6,6 +6,7 @@ import { findSidebarPanelWithTitle, openDocumentSettingsSidebar, publishPost, + canvas, } from '@wordpress/e2e-test-utils'; /** @@ -113,7 +114,7 @@ describe( 'Taxonomies', () => { expect( selectedCategories[ 0 ] ).toEqual( 'z rand category 1' ); // Type something in the title so we can publish the post. - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Publish the post. await publishPost(); @@ -171,7 +172,7 @@ describe( 'Taxonomies', () => { expect( tags[ 0 ] ).toEqual( tagName ); // Type something in the title so we can publish the post. - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Publish the post. await publishPost(); @@ -230,7 +231,7 @@ describe( 'Taxonomies', () => { expect( tags[ 0 ] ).toEqual( tagName ); // Type something in the title so we can publish the post. - await page.type( '.editor-post-title__input', 'Hello World' ); + await canvas().type( '.editor-post-title__input', 'Hello World' ); // Publish the post. await publishPost(); diff --git a/packages/e2e-tests/specs/editor/various/typewriter.test.js b/packages/e2e-tests/specs/editor/various/typewriter.test.js index f1eb0c7b689345..d935197b14f87f 100644 --- a/packages/e2e-tests/specs/editor/various/typewriter.test.js +++ b/packages/e2e-tests/specs/editor/various/typewriter.test.js @@ -9,7 +9,12 @@ describe( 'TypeWriter', () => { } ); const getCaretPosition = async () => - await page.evaluate( () => wp.dom.computeCaretRect( window ).y ); + await page.evaluate( + () => + wp.dom.computeCaretRect( + document.activeElement?.contentWindow ?? window + ).y + ); // Allow the scroll position to be 1px off. const BUFFER = 1; @@ -33,11 +38,13 @@ describe( 'TypeWriter', () => { // Create blocks until the typewriter effect kicks in. while ( - await page.evaluate( - () => - wp.dom.getScrollContainer( document.activeElement ) - .scrollTop === 0 - ) + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + return ( + wp.dom.getScrollContainer( activeElement ).scrollTop === 0 + ); + } ) ) { await page.keyboard.press( 'Enter' ); } @@ -51,14 +58,14 @@ describe( 'TypeWriter', () => { // Type until the text wraps. while ( - await page.evaluate( - () => - document.activeElement.clientHeight <= - parseInt( - getComputedStyle( document.activeElement ).lineHeight, - 10 - ) - ) + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + return ( + activeElement.clientHeight <= + parseInt( getComputedStyle( activeElement ).lineHeight, 10 ) + ); + } ) ) { await page.keyboard.type( 'a' ); } @@ -93,32 +100,35 @@ describe( 'TypeWriter', () => { // Create zero or more blocks until there is a scrollable container. // No blocks should be created if there's already a scrollbar. while ( - await page.evaluate( - () => ! wp.dom.getScrollContainer( document.activeElement ) - ) + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + const scrollContainer = + wp.dom.getScrollContainer( activeElement ); + return ( + scrollContainer.scrollHeight === + scrollContainer.clientHeight + ); + } ) ) { await page.keyboard.press( 'Enter' ); } - const scrollPosition = await page.evaluate( - () => wp.dom.getScrollContainer( document.activeElement ).scrollTop - ); + const scrollPosition = await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + return wp.dom.getScrollContainer( activeElement ).scrollTop; + } ); // Expect scrollbar to be at the top. expect( scrollPosition ).toBe( 0 ); // Move the mouse to the scroll container, and scroll down // a small amount to trigger the typewriter mode. - const mouseMovePosition = await page.evaluate( () => { - const caretRect = wp.dom.computeCaretRect( window ); - return [ Math.floor( caretRect.x ), Math.floor( caretRect.y ) ]; + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + wp.dom.getScrollContainer( activeElement ).scrollTop += 2; } ); - await page.mouse.move( ...mouseMovePosition ); - await page.mouse.wheel( { deltaY: 2 } ); - await page.waitForFunction( - () => - wp.dom.getScrollContainer( document.activeElement ) - .scrollTop === 2 - ); // Wait for the caret rectangle to be recalculated. await page.evaluate( () => new Promise( window.requestAnimationFrame ) @@ -128,12 +138,12 @@ describe( 'TypeWriter', () => { // coordinates should be the same. const initialPosition = await getCaretPosition(); await page.keyboard.press( 'Enter' ); - await page.waitForFunction( - () => - // Wait for the Typewriter to scroll down past the initial position. - wp.dom.getScrollContainer( document.activeElement ).scrollTop > - 2 - ); + await page.waitForFunction( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + // Wait for the Typewriter to scroll down past the initial position. + return wp.dom.getScrollContainer( activeElement ).scrollTop > 2; + } ); expect( await getDiff( initialPosition ) ).toBe( 0 ); } ); @@ -164,9 +174,11 @@ describe( 'TypeWriter', () => { // Create blocks until there is a scrollable container. while ( - await page.evaluate( - () => ! wp.dom.getScrollContainer( document.activeElement ) - ) + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + return ! wp.dom.getScrollContainer( activeElement ); + } ) ) { await page.keyboard.press( 'Enter' ); } @@ -176,11 +188,13 @@ describe( 'TypeWriter', () => { // Create blocks until the typewriter effect kicks in, create at // least 10 blocks to properly test the . while ( - ( await page.evaluate( - () => - wp.dom.getScrollContainer( document.activeElement ) - .scrollTop === 0 - ) ) || + ( await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + return ( + wp.dom.getScrollContainer( activeElement ).scrollTop === 0 + ); + } ) ) || count < 10 ) { await page.keyboard.press( 'Enter' ); @@ -190,9 +204,11 @@ describe( 'TypeWriter', () => { // Scroll the active element to the very bottom of the scroll container, // then scroll up, so the caret is partially hidden. await page.evaluate( () => { - document.activeElement.scrollIntoView( false ); - wp.dom.getScrollContainer( document.activeElement ).scrollTop -= - document.activeElement.offsetHeight + 10; + const { activeElement } = + document.activeElement?.contentDocument ?? document; + activeElement.scrollIntoView( false ); + wp.dom.getScrollContainer( activeElement ).scrollTop -= + activeElement.offsetHeight + 10; } ); const bottomPostition = await getCaretPosition(); @@ -220,9 +236,11 @@ describe( 'TypeWriter', () => { // Scroll the active element to the very top of the scroll container, // then scroll down, so the caret is partially hidden. await page.evaluate( () => { - document.activeElement.scrollIntoView(); - wp.dom.getScrollContainer( document.activeElement ).scrollTop += - document.activeElement.offsetHeight + 10; + const { activeElement } = + document.activeElement?.contentDocument ?? document; + activeElement.scrollIntoView(); + wp.dom.getScrollContainer( activeElement ).scrollTop += + activeElement.offsetHeight + 10; } ); const topPostition = await getCaretPosition(); diff --git a/packages/e2e-tests/specs/experiments/blocks/post-comments-form.test.js b/packages/e2e-tests/specs/experiments/blocks/post-comments-form.test.js index 9d395707e382f3..0a26788b2d442d 100644 --- a/packages/e2e-tests/specs/experiments/blocks/post-comments-form.test.js +++ b/packages/e2e-tests/specs/experiments/blocks/post-comments-form.test.js @@ -11,7 +11,7 @@ import { canvas, } from '@wordpress/e2e-test-utils'; -describe( 'Post Comments Form', () => { +describe( 'Comments Form', () => { let previousCommentStatus; beforeAll( async () => { @@ -37,12 +37,12 @@ describe( 'Post Comments Form', () => { ); await expect( page ).toClick( '.edit-site-sidebar-navigation-item', - { text: /singular/i } + { text: /single entries/i } ); await enterEditMode(); // Insert post comments form - await insertBlock( 'Post Comments Form' ); + await insertBlock( 'Comments Form' ); // Ensure the placeholder is there await expect( canvas() ).toMatchElement( diff --git a/packages/e2e-tests/specs/performance/.gitignore b/packages/e2e-tests/specs/performance/.gitignore deleted file mode 100644 index a6c57f5fb2ffba..00000000000000 --- a/packages/e2e-tests/specs/performance/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.json diff --git a/packages/e2e-tests/specs/performance/front-end-block-theme.test.js b/packages/e2e-tests/specs/performance/front-end-block-theme.test.js deleted file mode 100644 index 260a20ce64c4df..00000000000000 --- a/packages/e2e-tests/specs/performance/front-end-block-theme.test.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * WordPress dependencies - */ -import { activateTheme, createURL, logout } from '@wordpress/e2e-test-utils'; - -/** - * Internal dependencies - */ -import { saveResultsFile } from './utils'; - -describe( 'Front End Performance', () => { - const results = { - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], - }; - - beforeAll( async () => { - await activateTheme( 'twentytwentythree' ); - await logout(); - } ); - - afterAll( async () => { - saveResultsFile( __filename, results ); - await activateTheme( 'twentytwentyone' ); - } ); - - it( 'Report TTFB, LCP, and LCP-TTFB', async () => { - // Based on https://addyosmani.com/blog/puppeteer-recipes/#performance-observer-lcp - function calcLCP() { - // By using -1 we know when it didn't record any event. - window.largestContentfulPaint = -1; - - const observer = new PerformanceObserver( ( entryList ) => { - const entries = entryList.getEntries(); - const lastEntry = entries[ entries.length - 1 ]; - // According to the spec, we can use startTime - // as it'll report renderTime || loadTime: - // https://www.w3.org/TR/largest-contentful-paint/#largestcontentfulpaint - window.largestContentfulPaint = lastEntry.startTime; - } ); - - observer.observe( { - type: 'largest-contentful-paint', - buffered: true, - } ); - - document.addEventListener( 'visibilitychange', () => { - if ( document.visibilityState === 'hidden' ) { - observer.takeRecords(); - observer.disconnect(); - } - } ); - } - - let i = 16; - while ( i-- ) { - await page.evaluateOnNewDocument( calcLCP ); - // By waiting for networkidle we make sure navigation won't be considered finished on load, - // hence, it'll paint the page and largest-contentful-paint events will be dispatched. - // https://pptr.dev/api/puppeteer.page.goto#remarks - await page.goto( createURL( '/' ), { waitUntil: 'networkidle0' } ); - - const { lcp, ttfb } = await page.evaluate( () => { - const [ { responseStart, startTime } ] = - performance.getEntriesByType( 'navigation' ); - return { - lcp: window.largestContentfulPaint, - ttfb: responseStart - startTime, - }; - } ); - - results.largestContentfulPaint.push( lcp ); - results.timeToFirstByte.push( ttfb ); - results.lcpMinusTtfb.push( lcp - ttfb ); - } - } ); -} ); diff --git a/packages/e2e-tests/specs/performance/front-end-classic-theme.test.js b/packages/e2e-tests/specs/performance/front-end-classic-theme.test.js deleted file mode 100644 index ffe6906a529d8c..00000000000000 --- a/packages/e2e-tests/specs/performance/front-end-classic-theme.test.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * WordPress dependencies - */ -import { activateTheme, createURL, logout } from '@wordpress/e2e-test-utils'; - -/** - * Internal dependencies - */ -import { saveResultsFile } from './utils'; - -describe( 'Front End Performance', () => { - const results = { - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], - }; - - beforeAll( async () => { - await activateTheme( 'twentytwentyone' ); - await logout(); - } ); - - afterAll( async () => { - saveResultsFile( __filename, results ); - } ); - - it( 'Report TTFB, LCP, and LCP-TTFB', async () => { - // Based on https://addyosmani.com/blog/puppeteer-recipes/#performance-observer-lcp - function calcLCP() { - // By using -1 we know when it didn't record any event. - window.largestContentfulPaint = -1; - - const observer = new PerformanceObserver( ( entryList ) => { - const entries = entryList.getEntries(); - const lastEntry = entries[ entries.length - 1 ]; - // According to the spec, we can use startTime - // as it'll report renderTime || loadTime: - // https://www.w3.org/TR/largest-contentful-paint/#largestcontentfulpaint - window.largestContentfulPaint = lastEntry.startTime; - } ); - - observer.observe( { - type: 'largest-contentful-paint', - buffered: true, - } ); - - document.addEventListener( 'visibilitychange', () => { - if ( document.visibilityState === 'hidden' ) { - observer.takeRecords(); - observer.disconnect(); - } - } ); - } - - let i = 16; - while ( i-- ) { - await page.evaluateOnNewDocument( calcLCP ); - // By waiting for networkidle we make sure navigation won't be considered finished on load, - // hence, it'll paint the page and largest-contentful-paint events will be dispatched. - // https://pptr.dev/api/puppeteer.page.goto#remarks - await page.goto( createURL( '/' ), { waitUntil: 'networkidle0' } ); - - const { lcp, ttfb } = await page.evaluate( () => { - const [ { responseStart, startTime } ] = - performance.getEntriesByType( 'navigation' ); - return { - lcp: window.largestContentfulPaint, - ttfb: responseStart - startTime, - }; - } ); - - results.largestContentfulPaint.push( lcp ); - results.timeToFirstByte.push( ttfb ); - results.lcpMinusTtfb.push( lcp - ttfb ); - } - } ); -} ); diff --git a/packages/e2e-tests/specs/performance/post-editor.test.js b/packages/e2e-tests/specs/performance/post-editor.test.js deleted file mode 100644 index ee77f122a42bd4..00000000000000 --- a/packages/e2e-tests/specs/performance/post-editor.test.js +++ /dev/null @@ -1,363 +0,0 @@ -/** - * External dependencies - */ -import path from 'path'; - -/** - * WordPress dependencies - */ -import { - createNewPost, - saveDraft, - insertBlock, - openGlobalBlockInserter, - closeGlobalBlockInserter, - openListView, - closeListView, - canvas, -} from '@wordpress/e2e-test-utils'; - -/** - * Internal dependencies - */ -import { - readFile, - deleteFile, - saveResultsFile, - getTraceFilePath, - getTypingEventDurations, - getClickEventDurations, - getHoverEventDurations, - getSelectionEventDurations, - getLoadingDurations, - sum, -} from './utils'; - -jest.setTimeout( 1000000 ); - -async function loadHtmlIntoTheBlockEditor( html ) { - await page.evaluate( ( _html ) => { - const { parse } = window.wp.blocks; - const { dispatch } = window.wp.data; - const blocks = parse( _html ); - - blocks.forEach( ( block ) => { - if ( block.name === 'core/image' ) { - delete block.attributes.id; - delete block.attributes.url; - } - } ); - - dispatch( 'core/block-editor' ).resetBlocks( blocks ); - }, html ); -} - -async function load1000Paragraphs() { - await page.evaluate( () => { - const { createBlock } = window.wp.blocks; - const { dispatch } = window.wp.data; - const blocks = Array.from( { length: 1000 } ).map( () => - createBlock( 'core/paragraph' ) - ); - dispatch( 'core/block-editor' ).resetBlocks( blocks ); - } ); -} - -describe( 'Post Editor Performance', () => { - const results = { - serverResponse: [], - firstPaint: [], - domContentLoaded: [], - loaded: [], - firstContentfulPaint: [], - firstBlock: [], - type: [], - typeContainer: [], - focus: [], - listViewOpen: [], - inserterOpen: [], - inserterHover: [], - inserterSearch: [], - }; - const traceFilePath = getTraceFilePath(); - - let traceResults; - - afterAll( async () => { - saveResultsFile( __filename, results ); - deleteFile( traceFilePath ); - } ); - - beforeEach( async () => { - await createNewPost(); - // Disable auto-save to avoid impacting the metrics. - await page.evaluate( () => { - window.wp.data.dispatch( 'core/editor' ).updateEditorSettings( { - autosaveInterval: 100000000000, - localAutosaveInterval: 100000000000, - } ); - } ); - } ); - - it( 'Loading', async () => { - await loadHtmlIntoTheBlockEditor( - readFile( path.join( __dirname, '../../assets/large-post.html' ) ) - ); - await saveDraft(); - const draftURL = await page.url(); - - // Number of sample measurements to take. - const samples = 5; - // Number of throwaway measurements to perform before recording samples. - // Having at least one helps ensure that caching quirks don't manifest in - // the results. - const throwaway = 1; - - let i = throwaway + samples; - while ( i-- ) { - await page.close(); - page = await browser.newPage(); - - await page.goto( draftURL ); - await page.waitForSelector( '.edit-post-layout', { - timeout: 120000, - } ); - await canvas().waitForSelector( '.wp-block', { timeout: 120000 } ); - - if ( i < samples ) { - const { - serverResponse, - firstPaint, - domContentLoaded, - loaded, - firstContentfulPaint, - firstBlock, - } = await getLoadingDurations(); - - results.serverResponse.push( serverResponse ); - results.firstPaint.push( firstPaint ); - results.domContentLoaded.push( domContentLoaded ); - results.loaded.push( loaded ); - results.firstContentfulPaint.push( firstContentfulPaint ); - results.firstBlock.push( firstBlock ); - } - } - } ); - - it( 'Typing', async () => { - await loadHtmlIntoTheBlockEditor( - readFile( path.join( __dirname, '../../assets/large-post.html' ) ) - ); - await insertBlock( 'Paragraph' ); - let i = 20; - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - while ( i-- ) { - // Wait for the browser to be idle before starting the monitoring. - // The timeout should be big enough to allow all async tasks tor run. - // And also to allow Rich Text to mark the change as persistent. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 2000 ); - await page.keyboard.type( 'x' ); - } - await page.tracing.stop(); - traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ keyDownEvents, keyPressEvents, keyUpEvents ] = - getTypingEventDurations( traceResults ); - if ( - keyDownEvents.length === keyPressEvents.length && - keyPressEvents.length === keyUpEvents.length - ) { - // The first character typed triggers a longer time (isTyping change) - // It can impact the stability of the metric, so we exclude it. - for ( let j = 1; j < keyDownEvents.length; j++ ) { - results.type.push( - keyDownEvents[ j ] + keyPressEvents[ j ] + keyUpEvents[ j ] - ); - } - } - } ); - - it( 'Typing within containers', async () => { - await loadHtmlIntoTheBlockEditor( - readFile( - path.join( - __dirname, - '../../assets/small-post-with-containers.html' - ) - ) - ); - // Select the block where we type in - await canvas().waitForSelector( 'p[aria-label="Paragraph block"]' ); - await canvas().click( 'p[aria-label="Paragraph block"]' ); - // Ignore firsted typed character because it's different - // It probably deserves a dedicated metric. - // (isTyping triggers so it's slower) - await page.keyboard.type( 'x' ); - - let i = 10; - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - - while ( i-- ) { - // Wait for the browser to be idle before starting the monitoring. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 500 ); - await page.keyboard.type( 'x' ); - } - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 500 ); - await page.tracing.stop(); - traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ keyDownEvents, keyPressEvents, keyUpEvents ] = - getTypingEventDurations( traceResults ); - if ( - keyDownEvents.length === keyPressEvents.length && - keyPressEvents.length === keyUpEvents.length - ) { - // The first character typed triggers a longer time (isTyping change) - // It can impact the stability of the metric, so we exclude it. - for ( let j = 1; j < keyDownEvents.length; j++ ) { - results.typeContainer.push( - keyDownEvents[ j ] + keyPressEvents[ j ] + keyUpEvents[ j ] - ); - } - } - } ); - - it( 'Selecting blocks', async () => { - await load1000Paragraphs(); - const paragraphs = await canvas().$$( '.wp-block' ); - await paragraphs[ 0 ].click(); - for ( let j = 1; j <= 10; j++ ) { - // Wait for the browser to be idle before starting the monitoring. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 1000 ); - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - await paragraphs[ j ].click(); - await page.tracing.stop(); - traceResults = JSON.parse( readFile( traceFilePath ) ); - const allDurations = getSelectionEventDurations( traceResults ); - results.focus.push( - allDurations.reduce( ( acc, eventDurations ) => { - return acc + sum( eventDurations ); - }, 0 ) - ); - } - } ); - - it( 'Opening persistent list view', async () => { - await load1000Paragraphs(); - for ( let j = 0; j < 10; j++ ) { - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - await openListView(); - await page.tracing.stop(); - traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ mouseClickEvents ] = getClickEventDurations( traceResults ); - for ( let k = 0; k < mouseClickEvents.length; k++ ) { - results.listViewOpen.push( mouseClickEvents[ k ] ); - } - await closeListView(); - } - } ); - - it( 'Opening the inserter', async () => { - await load1000Paragraphs(); - for ( let j = 0; j < 10; j++ ) { - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - await openGlobalBlockInserter(); - await page.tracing.stop(); - traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ mouseClickEvents ] = getClickEventDurations( traceResults ); - for ( let k = 0; k < mouseClickEvents.length; k++ ) { - results.inserterOpen.push( mouseClickEvents[ k ] ); - } - await closeGlobalBlockInserter(); - } - } ); - - it( 'Searching the inserter', async () => { - await load1000Paragraphs(); - await openGlobalBlockInserter(); - for ( let j = 0; j < 10; j++ ) { - // Wait for the browser to be idle before starting the monitoring. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 500 ); - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - await page.keyboard.type( 'p' ); - await page.tracing.stop(); - traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ keyDownEvents, keyPressEvents, keyUpEvents ] = - getTypingEventDurations( traceResults ); - if ( - keyDownEvents.length === keyPressEvents.length && - keyPressEvents.length === keyUpEvents.length - ) { - results.inserterSearch.push( - sum( keyDownEvents ) + - sum( keyPressEvents ) + - sum( keyUpEvents ) - ); - } - await page.keyboard.press( 'Backspace' ); - } - await closeGlobalBlockInserter(); - } ); - - it( 'Hovering Inserter Items', async () => { - await load1000Paragraphs(); - const paragraphBlockItem = - '.block-editor-inserter__menu .editor-block-list-item-paragraph'; - const headingBlockItem = - '.block-editor-inserter__menu .editor-block-list-item-heading'; - await openGlobalBlockInserter(); - await page.waitForSelector( paragraphBlockItem ); - await page.hover( paragraphBlockItem ); - await page.hover( headingBlockItem ); - for ( let j = 0; j < 10; j++ ) { - // Wait for the browser to be idle before starting the monitoring. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 200 ); - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - await page.hover( paragraphBlockItem ); - await page.hover( headingBlockItem ); - await page.tracing.stop(); - - traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ mouseOverEvents, mouseOutEvents ] = - getHoverEventDurations( traceResults ); - for ( let k = 0; k < mouseOverEvents.length; k++ ) { - results.inserterHover.push( - mouseOverEvents[ k ] + mouseOutEvents[ k ] - ); - } - } - await closeGlobalBlockInserter(); - } ); -} ); diff --git a/packages/e2e-tests/specs/performance/site-editor.test.js b/packages/e2e-tests/specs/performance/site-editor.test.js deleted file mode 100644 index 22c1af35ff7161..00000000000000 --- a/packages/e2e-tests/specs/performance/site-editor.test.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * External dependencies - */ -import path from 'path'; - -/** - * WordPress dependencies - */ -import { - activateTheme, - canvas, - createNewPost, - visitSiteEditor, - saveDraft, - insertBlock, - deleteAllTemplates, - enterEditMode, -} from '@wordpress/e2e-test-utils'; - -/** - * Internal dependencies - */ -import { - readFile, - deleteFile, - saveResultsFile, - getTraceFilePath, - getTypingEventDurations, - getLoadingDurations, - sequence, -} from './utils'; - -jest.setTimeout( 1000000 ); - -const results = { - serverResponse: [], - firstPaint: [], - domContentLoaded: [], - loaded: [], - firstContentfulPaint: [], - firstBlock: [], - type: [], - typeContainer: [], - focus: [], - inserterOpen: [], - inserterHover: [], - inserterSearch: [], - listViewOpen: [], -}; - -let postId; - -describe( 'Site Editor Performance', () => { - beforeAll( async () => { - await activateTheme( 'emptytheme' ); - await deleteAllTemplates( 'wp_template' ); - await deleteAllTemplates( 'wp_template_part' ); - - const html = readFile( - path.join( __dirname, '../../assets/large-post.html' ) - ); - - await createNewPost( { postType: 'page' } ); - - await page.evaluate( ( _html ) => { - const { parse } = window.wp.blocks; - const { dispatch } = window.wp.data; - const blocks = parse( _html ); - - blocks.forEach( ( block ) => { - if ( block.name === 'core/image' ) { - delete block.attributes.id; - delete block.attributes.url; - } - } ); - - dispatch( 'core/block-editor' ).resetBlocks( blocks ); - }, html ); - await saveDraft(); - - postId = await page.evaluate( () => - new URL( document.location ).searchParams.get( 'post' ) - ); - } ); - - afterAll( async () => { - saveResultsFile( __filename, results ); - await deleteAllTemplates( 'wp_template' ); - await deleteAllTemplates( 'wp_template_part' ); - await activateTheme( 'twentytwentyone' ); - } ); - - // Number of loading measurements to take. - const loadingSamples = 3; - // Number of throwaway measurements to perform before recording samples. - // Having at least one helps ensure that caching quirks don't manifest - // in the results. - const loadingSamplesThrowaway = 1; - const loadingIterations = sequence( - 1, - loadingSamples + loadingSamplesThrowaway - ); - it.each( loadingIterations )( - `Loading (%i of ${ loadingIterations.length })`, - async ( i ) => { - // Open the test page in Site Editor. - await visitSiteEditor( { - postId, - postType: 'page', - } ); - - // Wait for the first block. - await canvas().waitForSelector( '.wp-block' ); - - // Save results. - if ( i > loadingSamplesThrowaway ) { - const { - serverResponse, - firstPaint, - domContentLoaded, - loaded, - firstContentfulPaint, - firstBlock, - } = await getLoadingDurations(); - - results.serverResponse.push( serverResponse ); - results.firstPaint.push( firstPaint ); - results.domContentLoaded.push( domContentLoaded ); - results.loaded.push( loaded ); - results.firstContentfulPaint.push( firstContentfulPaint ); - results.firstBlock.push( firstBlock ); - } - - expect( true ).toBe( true ); - } - ); - - it( 'Typing', async () => { - // Open the test page in Site Editor. - await visitSiteEditor( { - postId, - postType: 'page', - } ); - - // Wait for the first paragraph to be ready. - const firstParagraph = await canvas().waitForXPath( - '//p[contains(text(), "Lorem ipsum dolor sit amet")]' - ); - - // Get inside the post content. - await enterEditMode(); - - // Insert a new paragraph right under the first one. - await firstParagraph.focus(); - await insertBlock( 'Paragraph' ); - - // Start tracing. - const traceFilePath = getTraceFilePath(); - await page.tracing.start( { - path: traceFilePath, - screenshots: false, - categories: [ 'devtools.timeline' ], - } ); - - // Type "x" 200 times. - await page.keyboard.type( new Array( 200 ).fill( 'x' ).join( '' ) ); - - // Stop tracing and save results. - await page.tracing.stop(); - const traceResults = JSON.parse( readFile( traceFilePath ) ); - const [ keyDownEvents, keyPressEvents, keyUpEvents ] = - getTypingEventDurations( traceResults ); - for ( let i = 0; i < keyDownEvents.length; i++ ) { - results.type.push( - keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] - ); - } - - // Delete the original trace file. - deleteFile( traceFilePath ); - - expect( true ).toBe( true ); - } ); -} ); diff --git a/packages/e2e-tests/specs/performance/utils.js b/packages/e2e-tests/specs/performance/utils.js deleted file mode 100644 index a71e58edefebf2..00000000000000 --- a/packages/e2e-tests/specs/performance/utils.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * External dependencies - */ -import path from 'path'; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; - -export function readFile( filePath ) { - return existsSync( filePath ) - ? readFileSync( filePath, 'utf8' ).trim() - : ''; -} - -export function deleteFile( filePath ) { - if ( existsSync( filePath ) ) { - unlinkSync( filePath ); - } -} - -export function getTraceFilePath() { - return path.join( process.env.WP_ARTIFACTS_PATH, '/trace.json' ); -} - -export function saveResultsFile( testFilename, results ) { - const resultsFilename = - process.env.RESULTS_FILENAME || - path.basename( testFilename, '.js' ) + '.performance-results.json'; - - return writeFileSync( - path.join( process.env.WP_ARTIFACTS_PATH, resultsFilename ), - JSON.stringify( results, null, 2 ) - ); -} - -function isEvent( item ) { - return ( - item.cat === 'devtools.timeline' && - item.name === 'EventDispatch' && - item.dur && - item.args && - item.args.data - ); -} - -function isKeyDownEvent( item ) { - return isEvent( item ) && item.args.data.type === 'keydown'; -} - -function isKeyPressEvent( item ) { - return isEvent( item ) && item.args.data.type === 'keypress'; -} - -function isKeyUpEvent( item ) { - return isEvent( item ) && item.args.data.type === 'keyup'; -} - -function isFocusEvent( item ) { - return isEvent( item ) && item.args.data.type === 'focus'; -} - -function isFocusInEvent( item ) { - return isEvent( item ) && item.args.data.type === 'focusin'; -} - -function isClickEvent( item ) { - return isEvent( item ) && item.args.data.type === 'click'; -} - -function isMouseOverEvent( item ) { - return isEvent( item ) && item.args.data.type === 'mouseover'; -} - -function isMouseOutEvent( item ) { - return isEvent( item ) && item.args.data.type === 'mouseout'; -} - -function getEventDurationsForType( trace, filterFunction ) { - return trace.traceEvents - .filter( filterFunction ) - .map( ( item ) => item.dur / 1000 ); -} - -export function getTypingEventDurations( trace ) { - return [ - getEventDurationsForType( trace, isKeyDownEvent ), - getEventDurationsForType( trace, isKeyPressEvent ), - getEventDurationsForType( trace, isKeyUpEvent ), - ]; -} - -export function getSelectionEventDurations( trace ) { - return [ - getEventDurationsForType( trace, isFocusEvent ), - getEventDurationsForType( trace, isFocusInEvent ), - ]; -} - -export function getClickEventDurations( trace ) { - return [ getEventDurationsForType( trace, isClickEvent ) ]; -} - -export function getHoverEventDurations( trace ) { - return [ - getEventDurationsForType( trace, isMouseOverEvent ), - getEventDurationsForType( trace, isMouseOutEvent ), - ]; -} - -export async function getLoadingDurations() { - return await page.evaluate( () => { - const [ - { - requestStart, - responseStart, - responseEnd, - domContentLoadedEventEnd, - loadEventEnd, - }, - ] = performance.getEntriesByType( 'navigation' ); - const paintTimings = performance.getEntriesByType( 'paint' ); - return { - // Server side metric. - serverResponse: responseStart - requestStart, - // For client side metrics, consider the end of the response (the - // browser receives the HTML) as the start time (0). - firstPaint: - paintTimings.find( ( { name } ) => name === 'first-paint' ) - .startTime - responseEnd, - domContentLoaded: domContentLoadedEventEnd - responseEnd, - loaded: loadEventEnd - responseEnd, - firstContentfulPaint: - paintTimings.find( - ( { name } ) => name === 'first-contentful-paint' - ).startTime - responseEnd, - // This is evaluated right after Puppeteer found the block selector. - firstBlock: performance.now() - responseEnd, - }; - } ); -} - -export function sum( arr ) { - return arr.reduce( ( a, b ) => a + b, 0 ); -} - -export function sequence( start, length ) { - return Array.from( { length }, ( _, i ) => i + start ); -} diff --git a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js index d54f9c78dd8b89..c2bd3cb6d75076 100644 --- a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js +++ b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js @@ -12,8 +12,6 @@ import { activateTheme, clickButton, createReusableBlock, - visitSiteEditor, - enterEditMode, deleteAllTemplates, canvas, } from '@wordpress/e2e-test-utils'; @@ -239,104 +237,4 @@ describe( 'Multi-entity save flow', () => { expect( checkboxInputs ).toHaveLength( 1 ); } ); } ); - - describe( 'Site Editor', () => { - // Selectors - Site editor specific. - const saveSiteSelector = '.edit-site-save-button__button'; - const activeSaveSiteSelector = `${ saveSiteSelector }[aria-disabled=false]`; - const disabledSaveSiteSelector = `${ saveSiteSelector }[aria-disabled=true]`; - const saveA11ySelector = '.edit-site-editor__toggle-save-panel-button'; - - const saveAllChanges = async () => { - // Clicking button should open panel with boxes checked. - await page.click( activeSaveSiteSelector ); - await page.waitForSelector( savePanelSelector ); - await assertAllBoxesChecked(); - - // Save a11y button should not be present with save panel open. - await assertExistence( saveA11ySelector, false ); - - // Saving should result in items being saved. - await page.click( entitiesSaveSelector ); - }; - - it( 'Save flow should work as expected', async () => { - // Navigate to site editor. - await visitSiteEditor( { - postId: 'emptytheme//index', - postType: 'wp_template', - } ); - - await enterEditMode(); - - // Select the header template part via list view. - await page.click( '.edit-site-header-edit-mode__list-view-toggle' ); - const headerTemplatePartListViewButton = await page.waitForXPath( - '//a[contains(@class, "block-editor-list-view-block-select-button")][contains(., "header")]' - ); - headerTemplatePartListViewButton.click(); - await page.click( 'button[aria-label="Close"]' ); - - // Insert something to dirty the editor. - await insertBlock( 'Paragraph' ); - - const enabledButton = await page.waitForSelector( - activeSaveSiteSelector - ); - - // Should be enabled after edits. - expect( enabledButton ).not.toBeNull(); - - // Save a11y button should be present. - await assertExistence( saveA11ySelector, true ); - - // Save all changes. - await saveAllChanges(); - - const disabledButton = await page.waitForSelector( - disabledSaveSiteSelector - ); - expect( disabledButton ).not.toBeNull(); - } ); - - it( 'Save flow should allow re-saving after changing the same block attribute', async () => { - // Navigate to site editor. - await visitSiteEditor( { - postId: 'emptytheme//index', - postType: 'wp_template', - } ); - - await enterEditMode(); - - // Insert a paragraph at the bottom. - await insertBlock( 'Paragraph' ); - - // Open the block settings. - await page.click( 'button[aria-label="Settings"]' ); - - // Wait for the font size picker controls. - await page.waitForSelector( - '.components-font-size-picker__controls' - ); - - // Change the font size. - await page.click( - '.components-font-size-picker__controls button[aria-label="Small"]' - ); - - // Save all changes. - await saveAllChanges(); - - // Change the font size. - await page.click( - '.components-font-size-picker__controls button[aria-label="Medium"]' - ); - - // Assert that the save button has been re-enabled. - const saveButton = await page.waitForSelector( - activeSaveSiteSelector - ); - expect( saveButton ).not.toBeNull(); - } ); - } ); } ); diff --git a/packages/e2e-tests/specs/site-editor/settings-sidebar.test.js b/packages/e2e-tests/specs/site-editor/settings-sidebar.test.js index 88bf954e86ce2c..6b589ec7ea33d4 100644 --- a/packages/e2e-tests/specs/site-editor/settings-sidebar.test.js +++ b/packages/e2e-tests/specs/site-editor/settings-sidebar.test.js @@ -27,11 +27,11 @@ async function getActiveTabLabel() { async function getTemplateCard() { return { title: await page.$eval( - '.edit-site-template-card__title', + '.edit-site-sidebar-card__title', ( element ) => element.innerText ), description: await page.$eval( - '.edit-site-template-card__description', + '.edit-site-sidebar-card__description', ( element ) => element.innerText ), }; @@ -79,9 +79,9 @@ describe( 'Settings sidebar', () => { 'Used as a fallback template for all pages when a more specific template is not defined.', } ); expect( templateCardAfterNavigation ).toMatchObject( { - title: 'Singular', + title: 'Single Entries', description: - 'Displays any single entry, such as a post or a page. This template will serve as a fallback when a more specific template (e.g., Single Post, Page, or Attachment) cannot be found.', + 'Displays any single entry, such as a post or a page. This template will serve as a fallback when a more specific template (e.g. Single Post, Page, or Attachment) cannot be found.', } ); } ); } ); diff --git a/packages/edit-post/CHANGELOG.md b/packages/edit-post/CHANGELOG.md index ec7a8fd6fa8d99..dc5f6267945b72 100644 --- a/packages/edit-post/CHANGELOG.md +++ b/packages/edit-post/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 7.17.0 (2023-08-16) + +## 7.16.0 (2023-08-10) + +## 7.15.0 (2023-07-20) + +## 7.14.0 (2023-07-05) + +## 7.13.0 (2023-06-23) + +## 7.12.0 (2023-06-07) + ## 7.11.0 (2023-05-24) ## 7.10.0 (2023-05-10) diff --git a/packages/edit-post/README.md b/packages/edit-post/README.md index c9686fb8610ee2..bf592bb5e22052 100644 --- a/packages/edit-post/README.md +++ b/packages/edit-post/README.md @@ -116,6 +116,7 @@ function MyDocumentSettingPlugin() { { className: 'my-document-setting-plugin', title: 'My Panel', + name: 'my-panel', }, __( 'My Document Setting Panel' ) ); @@ -135,6 +136,7 @@ const MyDocumentSettingTest = () => ( <PluginDocumentSettingPanel className="my-document-setting-plugin" title="My Panel" + name="my-panel" > <p>My Document Setting Panel</p> </PluginDocumentSettingPanel> @@ -146,10 +148,11 @@ registerPlugin( 'document-setting-test', { render: MyDocumentSettingTest } ); _Parameters_ - _props_ `Object`: Component properties. -- _props.name_ `[string]`: The machine-friendly name for the panel. +- _props.name_ `string`: Required. A machine-friendly name for the panel. - _props.className_ `[string]`: An optional class name added to the row. - _props.title_ `[string]`: The title of the panel - _props.icon_ `[WPBlockTypeIconRender]`: The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. +- _props.children_ `WPElement`: Children to be rendered _Returns_ @@ -259,6 +262,7 @@ _Parameters_ - _props.title_ `[string]`: Title displayed at the top of the panel. - _props.initialOpen_ `[boolean]`: Whether to have the panel initially opened. When no title is provided it is always opened. - _props.icon_ `[WPBlockTypeIconRender]`: The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. +- _props.children_ `WPElement`: Children to be rendered _Returns_ @@ -355,6 +359,7 @@ _Parameters_ - _props.title_ `[string]`: Title displayed at the top of the panel. - _props.initialOpen_ `[boolean]`: Whether to have the panel initially opened. When no title is provided it is always opened. - _props.icon_ `[WPBlockTypeIconRender]`: The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. +- _props.children_ `WPElement`: Children to be rendered _Returns_ diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 6dfa1522875178..d3ac35d5012d94 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-post", - "version": "7.11.0", + "version": "7.17.0", "description": "Edit Post module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -39,7 +39,7 @@ "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", - "@wordpress/dom": "^3.20.0", + "@wordpress/dom": "file:../dom", "@wordpress/editor": "file:../editor", "@wordpress/element": "file:../element", "@wordpress/hooks": "file:../hooks", diff --git a/packages/edit-post/src/components/block-manager/index.js b/packages/edit-post/src/components/block-manager/index.js index 3adc2e3e91848f..685c274f03a846 100644 --- a/packages/edit-post/src/components/block-manager/index.js +++ b/packages/edit-post/src/components/block-manager/index.js @@ -2,11 +2,11 @@ * WordPress dependencies */ import { store as blocksStore } from '@wordpress/blocks'; -import { withSelect } from '@wordpress/data'; -import { SearchControl } from '@wordpress/components'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { SearchControl, Button } from '@wordpress/components'; import { __, _n, sprintf } from '@wordpress/i18n'; import { useEffect, useState } from '@wordpress/element'; -import { useDebounce } from '@wordpress/compose'; +import { useDebounce, compose } from '@wordpress/compose'; import { speak } from '@wordpress/a11y'; /** @@ -21,6 +21,7 @@ function BlockManager( { hasBlockSupport, isMatchingSearchTerm, numberOfHiddenBlocks, + enableAllBlockTypes, } ) { const debouncedSpeak = useDebounce( speak, 500 ); const [ search, setSearch ] = useState( '' ); @@ -63,6 +64,12 @@ function BlockManager( { ), numberOfHiddenBlocks ) } + <Button + variant="link" + onClick={ () => enableAllBlockTypes( blockTypes ) } + > + { __( 'Reset' ) } + </Button> </div> ) } <SearchControl @@ -105,34 +112,47 @@ function BlockManager( { ); } -export default withSelect( ( select ) => { - const { - getBlockTypes, - getCategories, - hasBlockSupport, - isMatchingSearchTerm, - } = select( blocksStore ); - const { getHiddenBlockTypes } = select( editPostStore ); +export default compose( [ + withSelect( ( select ) => { + const { + getBlockTypes, + getCategories, + hasBlockSupport, + isMatchingSearchTerm, + } = select( blocksStore ); + const { getHiddenBlockTypes } = select( editPostStore ); - // Some hidden blocks become unregistered - // by removing for instance the plugin that registered them, yet - // they're still remain as hidden by the user's action. - // We consider "hidden", blocks which were hidden and - // are still registered. - const blockTypes = getBlockTypes(); - const hiddenBlockTypes = getHiddenBlockTypes().filter( ( hiddenBlock ) => { - return blockTypes.some( - ( registeredBlock ) => registeredBlock.name === hiddenBlock + // Some hidden blocks become unregistered + // by removing for instance the plugin that registered them, yet + // they're still remain as hidden by the user's action. + // We consider "hidden", blocks which were hidden and + // are still registered. + const blockTypes = getBlockTypes(); + const hiddenBlockTypes = getHiddenBlockTypes().filter( + ( hiddenBlock ) => { + return blockTypes.some( + ( registeredBlock ) => registeredBlock.name === hiddenBlock + ); + } ); - } ); - const numberOfHiddenBlocks = - Array.isArray( hiddenBlockTypes ) && hiddenBlockTypes.length; + const numberOfHiddenBlocks = + Array.isArray( hiddenBlockTypes ) && hiddenBlockTypes.length; - return { - blockTypes, - categories: getCategories(), - hasBlockSupport, - isMatchingSearchTerm, - numberOfHiddenBlocks, - }; -} )( BlockManager ); + return { + blockTypes, + categories: getCategories(), + hasBlockSupport, + isMatchingSearchTerm, + numberOfHiddenBlocks, + }; + } ), + withDispatch( ( dispatch ) => { + const { showBlockTypes } = dispatch( editPostStore ); + return { + enableAllBlockTypes: ( blockTypes ) => { + const blockNames = blockTypes.map( ( { name } ) => name ); + showBlockTypes( blockNames ); + }, + }; + } ), +] )( BlockManager ); diff --git a/packages/edit-post/src/components/block-manager/style.scss b/packages/edit-post/src/components/block-manager/style.scss index d8f9b78fe5a391..c62e5fea93202d 100644 --- a/packages/edit-post/src/components/block-manager/style.scss +++ b/packages/edit-post/src/components/block-manager/style.scss @@ -16,7 +16,6 @@ padding: $grid-unit-10; background-color: $white; text-align: center; - font-style: italic; position: sticky; // When sticking, tuck the top border beneath the modal header border top: -1px; @@ -26,6 +25,9 @@ ~ .edit-post-block-manager__results .edit-post-block-manager__category-title { top: 35px; } + .is-link { + margin-left: 12px; + } } .edit-post-block-manager__category { @@ -34,7 +36,7 @@ .edit-post-block-manager__category-title { position: sticky; - top: 0; + top: - $grid-unit-05; // Offsets the top padding on the modal content container padding: $grid-unit-20 0; background-color: $white; z-index: z-index(".edit-post-block-manager__category-title"); diff --git a/packages/edit-post/src/components/device-preview/index.js b/packages/edit-post/src/components/device-preview/index.js index 2ac65ca587ae0c..ab38523969ace1 100644 --- a/packages/edit-post/src/components/device-preview/index.js +++ b/packages/edit-post/src/components/device-preview/index.js @@ -15,26 +15,22 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as editPostStore } from '../../store'; export default function DevicePreview() { - const { - hasActiveMetaboxes, - isPostSaveable, - isSaving, - isViewable, - deviceType, - } = useSelect( ( select ) => { - const { getEditedPostAttribute } = select( editorStore ); - const { getPostType } = select( coreStore ); - const postType = getPostType( getEditedPostAttribute( 'type' ) ); + const { hasActiveMetaboxes, isPostSaveable, isViewable, deviceType } = + useSelect( ( select ) => { + const { getEditedPostAttribute } = select( editorStore ); + const { getPostType } = select( coreStore ); + const postType = getPostType( getEditedPostAttribute( 'type' ) ); - return { - hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), - isSaving: select( editPostStore ).isSavingMetaBoxes(), - isPostSaveable: select( editorStore ).isEditedPostSaveable(), - isViewable: postType?.viewable ?? false, - deviceType: - select( editPostStore ).__experimentalGetPreviewDeviceType(), - }; - }, [] ); + return { + hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), + isPostSaveable: select( editorStore ).isEditedPostSaveable(), + isViewable: postType?.viewable ?? false, + deviceType: + select( + editPostStore + ).__experimentalGetPreviewDeviceType(), + }; + }, [] ); const { __experimentalSetPreviewDeviceType: setPreviewDeviceType } = useDispatch( editPostStore ); @@ -44,29 +40,28 @@ export default function DevicePreview() { className="edit-post-post-preview-dropdown" deviceType={ deviceType } setDeviceType={ setPreviewDeviceType } - /* translators: button label text should, if possible, be under 16 characters. */ - viewLabel={ __( 'Preview' ) } + label={ __( 'Preview' ) } > - { isViewable && ( - <MenuGroup> - <div className="edit-post-header-preview__grouping-external"> - <PostPreviewButton - className={ - 'edit-post-header-preview__button-external' - } - role="menuitem" - forceIsAutosaveable={ hasActiveMetaboxes } - forcePreviewLink={ isSaving ? null : undefined } - textContent={ - <> - { __( 'Preview in new tab' ) } - <Icon icon={ external } /> - </> - } - /> - </div> - </MenuGroup> - ) } + { ( { onClose } ) => + isViewable && ( + <MenuGroup> + <div className="edit-post-header-preview__grouping-external"> + <PostPreviewButton + className="edit-post-header-preview__button-external" + role="menuitem" + forceIsAutosaveable={ hasActiveMetaboxes } + textContent={ + <> + { __( 'Preview in new tab' ) } + <Icon icon={ external } /> + </> + } + onPreview={ onClose } + /> + </div> + </MenuGroup> + ) + } </PreviewOptions> ); } diff --git a/packages/edit-post/src/components/header/document-actions/index.js b/packages/edit-post/src/components/header/document-actions/index.js new file mode 100644 index 00000000000000..52df978e2cd5b3 --- /dev/null +++ b/packages/edit-post/src/components/header/document-actions/index.js @@ -0,0 +1,85 @@ +/** + * WordPress dependencies + */ +import { __, isRTL } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { BlockIcon, store as blockEditorStore } from '@wordpress/block-editor'; +import { + Button, + VisuallyHidden, + __experimentalHStack as HStack, + __experimentalText as Text, +} from '@wordpress/components'; +import { layout, chevronLeftSmall, chevronRightSmall } from '@wordpress/icons'; +import { store as commandsStore } from '@wordpress/commands'; +import { displayShortcut } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { store as editPostStore } from '../../../store'; + +function DocumentActions() { + const { template, isEditing } = useSelect( ( select ) => { + const { isEditingTemplate, getEditedPostTemplate } = + select( editPostStore ); + const _isEditing = isEditingTemplate(); + + return { + template: _isEditing ? getEditedPostTemplate() : null, + isEditing: _isEditing, + }; + }, [] ); + const { clearSelectedBlock } = useDispatch( blockEditorStore ); + const { setIsEditingTemplate } = useDispatch( editPostStore ); + const { open: openCommandCenter } = useDispatch( commandsStore ); + + if ( ! isEditing || ! template ) { + return null; + } + + let templateTitle = __( 'Default' ); + if ( template?.title ) { + templateTitle = template.title; + } else if ( !! template ) { + templateTitle = template.slug; + } + + return ( + <div className="edit-post-document-actions"> + <Button + className="edit-post-document-actions__back" + onClick={ () => { + clearSelectedBlock(); + setIsEditingTemplate( false ); + } } + icon={ isRTL() ? chevronRightSmall : chevronLeftSmall } + > + { __( 'Back' ) } + </Button> + <Button + className="edit-post-document-actions__command" + onClick={ () => openCommandCenter() } + > + <HStack + className="edit-post-document-actions__title" + spacing={ 1 } + justify="center" + > + <BlockIcon icon={ layout } /> + <Text size="body" as="h1"> + <VisuallyHidden as="span"> + { __( 'Editing template: ' ) } + </VisuallyHidden> + { templateTitle } + </Text> + </HStack> + <span className="edit-post-document-actions__shortcut"> + { displayShortcut.primary( 'k' ) } + </span> + </Button> + </div> + ); +} + +export default DocumentActions; diff --git a/packages/edit-post/src/components/header/document-actions/style.scss b/packages/edit-post/src/components/header/document-actions/style.scss new file mode 100644 index 00000000000000..7eb77f9c0bd88c --- /dev/null +++ b/packages/edit-post/src/components/header/document-actions/style.scss @@ -0,0 +1,64 @@ +.edit-post-document-actions { + display: flex; + align-items: center; + gap: $grid-unit; + height: $button-size; + justify-content: space-between; + // Flex items will, by default, refuse to shrink below a minimum + // intrinsic width. In order to shrink this flexbox item, and + // subsequently truncate child text, we set an explicit min-width. + // See https://dev.w3.org/csswg/css-flexbox/#min-size-auto + min-width: 0; + background: $gray-100; + border-radius: 4px; + width: min(100%, 450px); + + .components-button { + &:hover { + color: var(--wp-block-synced-color); + background: $gray-200; + } + } +} + +.edit-post-document-actions__command { + flex-grow: 1; + color: var(--wp-block-synced-color); + overflow: hidden; +} + +.edit-post-document-actions__title { + flex-grow: 1; + color: var(--wp-block-synced-color); + overflow: hidden; + + &:hover { + color: var(--wp-block-synced-color); + } + + .block-editor-block-icon { + flex-shrink: 0; + } + + h1 { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--wp-block-synced-color); + } +} + +.edit-post-document-actions__shortcut { + color: $gray-800; +} + +.edit-post-document-actions__back.components-button.has-icon.has-text { + min-width: $button-size; + flex-shrink: 0; + color: $gray-700; + gap: 0; + + &:hover { + color: currentColor; + } +} diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index 337c27bed00d99..840067e9fb9b3d 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -19,12 +19,13 @@ import { Button, ToolbarItem } from '@wordpress/components'; import { listView, plus } from '@wordpress/icons'; import { useRef, useCallback } from '@wordpress/element'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ import { store as editPostStore } from '../../../store'; -import { unlock } from '../../../private-apis'; +import { unlock } from '../../../lock-unlock'; const { useShouldContextualToolbarShow } = unlock( blockEditorPrivateApis ); @@ -43,6 +44,7 @@ function HeaderToolbar() { showIconLabels, isListViewOpen, listViewShortcut, + hasFixedToolbar, } = useSelect( ( select ) => { const { hasInserterItems, getBlockRootClientId, getBlockSelectionEnd } = select( blockEditorStore ); @@ -50,6 +52,7 @@ function HeaderToolbar() { const { getEditorMode, isFeatureActive, isListViewOpened } = select( editPostStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); + const { get: getPreference } = select( preferencesStore ); return { // This setting (richEditingEnabled) should not live in the block editor's setting. @@ -66,6 +69,7 @@ function HeaderToolbar() { listViewShortcut: getShortcutRepresentation( 'core/edit-post/toggle-list-view' ), + hasFixedToolbar: getPreference( 'core/edit-post', 'fixedToolbar' ), }; }, [] ); @@ -103,6 +107,7 @@ function HeaderToolbar() { shortcut={ listViewShortcut } showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } + aria-expanded={ isListViewOpen } /> </> ); @@ -144,10 +149,11 @@ function HeaderToolbar() { icon={ plus } label={ showIconLabels ? shortLabel : longLabel } showTooltip={ ! showIconLabels } + aria-expanded={ isInserterOpened } /> { ( isWideViewport || ! showIconLabels ) && ( <> - { isLargeViewport && ( + { isLargeViewport && ! hasFixedToolbar && ( <ToolbarItem as={ ToolSelector } showTooltip={ ! showIconLabels } diff --git a/packages/edit-post/src/components/header/header-toolbar/index.native.js b/packages/edit-post/src/components/header/header-toolbar/index.native.js index b9b91c8d4f5585..53574b15306cee 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.native.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.native.js @@ -1,13 +1,13 @@ /** * External dependencies */ -import { Platform, ScrollView, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; /** * WordPress dependencies */ -import { useCallback, useRef, useState } from '@wordpress/element'; -import { compose, withPreferredColorScheme } from '@wordpress/compose'; +import { useCallback, useRef, useEffect, Platform } from '@wordpress/element'; +import { compose, usePreferredColorSchemeStyle } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; import { withViewportMatch } from '@wordpress/viewport'; import { __ } from '@wordpress/i18n'; @@ -19,10 +19,19 @@ import { import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; import { keyboardClose, - undo as undoIcon, - redo as redoIcon, + audio as audioIcon, + media as imageIcon, + video as videoIcon, + gallery as galleryIcon, } from '@wordpress/icons'; import { store as editorStore } from '@wordpress/editor'; +import { createBlock } from '@wordpress/blocks'; +import { + toggleUndoButton, + toggleRedoButton, + subscribeOnUndoPressed, + subscribeOnRedoPressed, +} from '@wordpress/react-native-bridge'; /** * Internal dependencies @@ -30,6 +39,13 @@ import { store as editorStore } from '@wordpress/editor'; import styles from './style.scss'; import { store as editPostStore } from '../../../store'; +const shadowStyle = { + shadowOffset: { width: 2, height: 2 }, + shadowOpacity: 1, + shadowRadius: 6, + elevation: 18, +}; + function HeaderToolbar( { hasRedo, hasUndo, @@ -37,83 +53,130 @@ function HeaderToolbar( { undo, showInserter, showKeyboardHideButton, - getStylesFromColorScheme, + insertBlock, onHideKeyboard, isRTL, noContentSelected, } ) { - const wasNoContentSelected = useRef( noContentSelected ); - const [ isInserterOpen, setIsInserterOpen ] = useState( false ); + const anchorNodeRef = useRef(); + + const containerStyle = [ + usePreferredColorSchemeStyle( + styles[ 'header-toolbar__container' ], + styles[ 'header-toolbar__container--dark' ] + ), + { borderTopWidth: StyleSheet.hairlineWidth }, + ]; + + useEffect( () => { + const onUndoSubscription = subscribeOnUndoPressed( undo ); + const onRedoSubscription = subscribeOnRedoPressed( redo ); + + return () => { + onUndoSubscription?.remove(); + onRedoSubscription?.remove(); + }; + }, [ undo, redo ] ); + + useEffect( () => { + toggleUndoButton( ! hasUndo ); + }, [ hasUndo ] ); + + useEffect( () => { + toggleRedoButton( ! hasRedo ); + }, [ hasRedo ] ); const scrollViewRef = useRef( null ); const scrollToStart = () => { // scrollview doesn't seem to automatically adjust to RTL on Android so, scroll to end when Android - const isAndroid = Platform.OS === 'android'; - if ( isAndroid && isRTL ) { + if ( Platform.isAndroid && isRTL ) { scrollViewRef.current.scrollToEnd(); } else { scrollViewRef.current.scrollTo( { x: 0 } ); } }; - const renderHistoryButtons = () => { - const buttons = [ - /* TODO: replace with EditorHistoryRedo and EditorHistoryUndo. */ + + const onInsertBlock = useCallback( + ( blockType ) => () => { + insertBlock( createBlock( blockType ), undefined, undefined, true, { + source: 'inserter_menu', + inserterMethod: 'quick-inserter', + } ); + }, + [ insertBlock ] + ); + + const renderMediaButtons = ( + <ToolbarGroup> <ToolbarButton - key="undoButton" - title={ __( 'Undo' ) } - icon={ ! isRTL ? undoIcon : redoIcon } - isDisabled={ ! hasUndo } - onClick={ undo } + key="imageButton" + title={ __( 'Image' ) } + icon={ imageIcon } + onClick={ onInsertBlock( 'core/image' ) } + testID="insert-image-button" extraProps={ { - hint: __( 'Double tap to undo last change' ), + hint: __( 'Insert Image Block' ), } } - />, + /> <ToolbarButton - key="redoButton" - title={ __( 'Redo' ) } - icon={ ! isRTL ? redoIcon : undoIcon } - isDisabled={ ! hasRedo } - onClick={ redo } + key="videoButton" + title={ __( 'Video' ) } + icon={ videoIcon } + onClick={ onInsertBlock( 'core/video' ) } + testID="insert-video-button" extraProps={ { - hint: __( 'Double tap to redo last change' ), + hint: __( 'Insert Video Block' ), } } - />, - ]; - - return isRTL ? buttons.reverse() : buttons; - }; - - const onToggleInserter = useCallback( - ( isOpen ) => { - if ( isOpen ) { - wasNoContentSelected.current = noContentSelected; - } - setIsInserterOpen( isOpen ); - }, - [ noContentSelected ] + /> + <ToolbarButton + key="galleryButton" + title={ __( 'Gallery' ) } + icon={ galleryIcon } + onClick={ onInsertBlock( 'core/gallery' ) } + testID="insert-gallery-button" + extraProps={ { + hint: __( 'Insert Gallery Block' ), + } } + /> + <ToolbarButton + key="audioButton" + title={ __( 'Audio' ) } + icon={ audioIcon } + onClick={ onInsertBlock( 'core/audio' ) } + testID="insert-audio-button" + extraProps={ { + hint: __( 'Insert Audio Block' ), + } } + /> + </ToolbarGroup> ); - // Expanded mode should be preserved while the inserter is open. - // This way we prevent style updates during the opening transition. - const useExpandedMode = isInserterOpen - ? wasNoContentSelected.current - : noContentSelected; - /* translators: accessibility text for the editor toolbar */ const toolbarAriaLabel = __( 'Document tools' ); + const shadowColor = usePreferredColorSchemeStyle( + styles[ 'header-toolbar__keyboard-hide-shadow--light' ], + styles[ 'header-toolbar__keyboard-hide-shadow--dark' ] + ); + const showKeyboardButtonStyles = [ + usePreferredColorSchemeStyle( + styles[ 'header-toolbar__keyboard-hide-container' ], + styles[ 'header-toolbar__keyboard-hide-container--dark' ] + ), + shadowStyle, + { + shadowColor: Platform.isAndroid + ? styles[ 'header-toolbar__keyboard-hide-shadow--solid' ].color + : shadowColor.color, + }, + ]; + return ( <View + ref={ anchorNodeRef } testID={ toolbarAriaLabel } accessibilityLabel={ toolbarAriaLabel } - style={ [ - getStylesFromColorScheme( - styles[ 'header-toolbar__container' ], - styles[ 'header-toolbar__container--dark' ] - ), - useExpandedMode && - styles[ 'header-toolbar__container--expanded' ], - ] } + style={ containerStyle } > <ScrollView ref={ scrollViewRef } @@ -126,20 +189,13 @@ function HeaderToolbar( { styles[ 'header-toolbar__scrollable-content' ] } > - <Inserter - disabled={ ! showInserter } - useExpandedMode={ useExpandedMode } - onToggle={ onToggleInserter } - /> - { renderHistoryButtons() } - <BlockToolbar /> + <Inserter disabled={ ! showInserter } /> + + { noContentSelected && renderMediaButtons } + <BlockToolbar anchorNodeRef={ anchorNodeRef.current } /> </ScrollView> { showKeyboardHideButton && ( - <ToolbarGroup - passedStyle={ - styles[ 'header-toolbar__keyboard-hide-container' ] - } - > + <ToolbarGroup passedStyle={ showKeyboardButtonStyles }> <ToolbarButton title={ __( 'Hide keyboard' ) } icon={ keyboardClose } @@ -181,7 +237,8 @@ export default compose( [ }; } ), withDispatch( ( dispatch ) => { - const { clearSelectedBlock } = dispatch( blockEditorStore ); + const { clearSelectedBlock, insertBlock } = + dispatch( blockEditorStore ); const { togglePostTitleSelection } = dispatch( editorStore ); return { @@ -191,8 +248,8 @@ export default compose( [ clearSelectedBlock(); togglePostTitleSelection( false ); }, + insertBlock, }; } ), withViewportMatch( { isLargeViewport: 'medium' } ), - withPreferredColorScheme, ] )( HeaderToolbar ); diff --git a/packages/edit-post/src/components/header/header-toolbar/style.native.scss b/packages/edit-post/src/components/header/header-toolbar/style.native.scss index 031d082706656a..751f83d3dc63e6 100644 --- a/packages/edit-post/src/components/header/header-toolbar/style.native.scss +++ b/packages/edit-post/src/components/header/header-toolbar/style.native.scss @@ -3,13 +3,13 @@ height: $mobile-header-toolbar-height; flex-direction: row; background-color: $app-background; - border-top-color: #e9eff3; - border-top-width: 1px; + border-top-color: $light-quaternary; + overflow: hidden; } .header-toolbar__container--dark { - background-color: $app-background-dark-alt; - border-top-color: $background-dark-elevated; + background-color: $app-safe-area-background-dark; + border-top-color: $dark-quaternary; } .header-toolbar__container--expanded { @@ -18,6 +18,7 @@ .header-toolbar__scrollable-content { flex-grow: 1; // Fixes RTL issue on Android. + padding-right: 8px; } .header-toolbar__keyboard-hide-container { @@ -27,4 +28,22 @@ width: 44px; justify-content: center; align-items: center; + border-color: transparent; + background-color: $app-background; +} + +.header-toolbar__keyboard-hide-container--dark { + background-color: $app-background-dark-alt; +} + +.header-toolbar__keyboard-hide-shadow--solid { + color: $black; +} + +.header-toolbar__keyboard-hide-shadow--light { + color: $light-quaternary; +} + +.header-toolbar__keyboard-hide-shadow--dark { + color: $light-primary; } diff --git a/packages/edit-post/src/components/header/header-toolbar/style.scss b/packages/edit-post/src/components/header/header-toolbar/style.scss index 87aec00004c02b..4a53be477e0258 100644 --- a/packages/edit-post/src/components/header/header-toolbar/style.scss +++ b/packages/edit-post/src/components/header/header-toolbar/style.scss @@ -1,6 +1,5 @@ .edit-post-header-toolbar { display: inline-flex; - flex-grow: 1; align-items: center; border: none; @@ -83,6 +82,10 @@ align-items: center; padding-left: $grid-unit-10; + // Some plugins add buttons here despite best practices. + // Push them a bit rightwards to fit the top toolbar. + margin-right: $grid-unit-10; + @include break-small() { padding-left: $grid-unit-30; } diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index 09a93424f6903d..ab4bbd4bbc5d15 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -18,41 +18,31 @@ import { default as DevicePreview } from '../device-preview'; import ViewLink from '../view-link'; import MainDashboardButton from './main-dashboard-button'; import { store as editPostStore } from '../../store'; -import TemplateTitle from './template-title'; +import DocumentActions from './document-actions'; -function Header( { setEntitiesSavedStatesCallback } ) { - const isLargeViewport = useViewportMatch( 'large' ); - const { - hasActiveMetaboxes, - isPublishSidebarOpened, - isSaving, - showIconLabels, - isDistractionFreeMode, - } = useSelect( - ( select ) => ( { - hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), - isPublishSidebarOpened: - select( editPostStore ).isPublishSidebarOpened(), - isSaving: select( editPostStore ).isSavingMetaBoxes(), - showIconLabels: - select( editPostStore ).isFeatureActive( 'showIconLabels' ), - isDistractionFreeMode: - select( editPostStore ).isFeatureActive( 'distractionFree' ), - } ), - [] - ); +const slideY = { + hidden: { y: '-50px' }, + hover: { y: 0, transition: { type: 'tween', delay: 0.2 } }, +}; - const isDistractionFree = isDistractionFreeMode && isLargeViewport; +const slideX = { + hidden: { x: '-100%' }, + hover: { x: 0, transition: { type: 'tween', delay: 0.2 } }, +}; - const slideY = { - hidden: isDistractionFree ? { y: '-50' } : { y: 0 }, - hover: { y: 0, transition: { type: 'tween', delay: 0.2 } }, - }; - - const slideX = { - hidden: isDistractionFree ? { x: '-100%' } : { x: 0 }, - hover: { x: 0, transition: { type: 'tween', delay: 0.2 } }, - }; +function Header( { setEntitiesSavedStatesCallback } ) { + const isLargeViewport = useViewportMatch( 'large' ); + const { hasActiveMetaboxes, isPublishSidebarOpened, showIconLabels } = + useSelect( + ( select ) => ( { + hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), + isPublishSidebarOpened: + select( editPostStore ).isPublishSidebarOpened(), + showIconLabels: + select( editPostStore ).isFeatureActive( 'showIconLabels' ), + } ), + [] + ); return ( <div className="edit-post-header"> @@ -70,7 +60,9 @@ function Header( { setEntitiesSavedStatesCallback } ) { className="edit-post-header__toolbar" > <HeaderToolbar /> - <TemplateTitle /> + <div className="edit-post-header__center"> + <DocumentActions /> + </div> </motion.div> <motion.div variants={ slideY } @@ -85,19 +77,14 @@ function Header( { setEntitiesSavedStatesCallback } ) { // when the publish sidebar has been closed. <PostSavedState forceIsDirty={ hasActiveMetaboxes } - forceIsSaving={ isSaving } showIconLabels={ showIconLabels } /> ) } - <ViewLink /> <DevicePreview /> - <PostPreviewButton - forceIsAutosaveable={ hasActiveMetaboxes } - forcePreviewLink={ isSaving ? null : undefined } - /> + <PostPreviewButton forceIsAutosaveable={ hasActiveMetaboxes } /> + <ViewLink /> <PostPublishButtonOrToggle forceIsDirty={ hasActiveMetaboxes } - forceIsSaving={ isSaving } setEntitiesSavedStatesCallback={ setEntitiesSavedStatesCallback } diff --git a/packages/edit-post/src/components/header/mode-switcher/index.js b/packages/edit-post/src/components/header/mode-switcher/index.js index 2b5344d930048a..b8d7f912180b60 100644 --- a/packages/edit-post/src/components/header/mode-switcher/index.js +++ b/packages/edit-post/src/components/header/mode-switcher/index.js @@ -55,12 +55,31 @@ function ModeSwitcher() { return null; } - if ( ! isRichEditingEnabled || ! isCodeEditingEnabled ) { - return null; + let selectedMode = mode; + if ( ! isRichEditingEnabled && mode === 'visual' ) { + selectedMode = 'text'; + } + if ( ! isCodeEditingEnabled && mode === 'text' ) { + selectedMode = 'visual'; } const choices = MODES.map( ( choice ) => { - if ( choice.value !== mode ) { + if ( ! isCodeEditingEnabled && choice.value === 'text' ) { + choice = { + ...choice, + disabled: true, + }; + } + if ( ! isRichEditingEnabled && choice.value === 'visual' ) { + choice = { + ...choice, + disabled: true, + info: __( + 'You can enable the visual editor in your profile settings.' + ), + }; + } + if ( choice.value !== selectedMode && ! choice.disabled ) { return { ...choice, shortcut }; } return choice; @@ -70,7 +89,7 @@ function ModeSwitcher() { <MenuGroup label={ __( 'Editor' ) }> <MenuItemsChoice choices={ choices } - value={ mode } + value={ selectedMode } onSelect={ switchEditorMode } /> </MenuGroup> diff --git a/packages/edit-post/src/components/header/post-publish-button-or-toggle.js b/packages/edit-post/src/components/header/post-publish-button-or-toggle.js index 92f250ef2c5c38..c01f31454e7460 100644 --- a/packages/edit-post/src/components/header/post-publish-button-or-toggle.js +++ b/packages/edit-post/src/components/header/post-publish-button-or-toggle.js @@ -12,7 +12,6 @@ import { store as editPostStore } from '../../store'; export function PostPublishButtonOrToggle( { forceIsDirty, - forceIsSaving, hasPublishAction, isBeingScheduled, isPending, @@ -67,7 +66,6 @@ export function PostPublishButtonOrToggle( { return ( <PostPublishButton forceIsDirty={ forceIsDirty } - forceIsSaving={ forceIsSaving } isOpen={ isPublishSidebarOpened } isToggle={ component === IS_TOGGLE } onToggle={ togglePublishSidebar } diff --git a/packages/edit-post/src/components/header/preferences-menu-item/index.js b/packages/edit-post/src/components/header/preferences-menu-item/index.js index bc747ca0afde88..037a896fc7f070 100644 --- a/packages/edit-post/src/components/header/preferences-menu-item/index.js +++ b/packages/edit-post/src/components/header/preferences-menu-item/index.js @@ -4,18 +4,19 @@ import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { MenuItem } from '@wordpress/components'; +import { store as interfaceStore } from '@wordpress/interface'; /** * Internal dependencies */ -import { store as editPostStore } from '../../../store'; +import { PREFERENCES_MODAL_NAME } from '../../../components/preferences-modal'; export default function PreferencesMenuItem() { - const { openModal } = useDispatch( editPostStore ); + const { openModal } = useDispatch( interfaceStore ); return ( <MenuItem onClick={ () => { - openModal( 'edit-post/preferences' ); + openModal( PREFERENCES_MODAL_NAME ); } } > { __( 'Preferences' ) } diff --git a/packages/edit-post/src/components/header/style.scss b/packages/edit-post/src/components/header/style.scss index 160543684a702e..d83745196576f8 100644 --- a/packages/edit-post/src/components/header/style.scss +++ b/packages/edit-post/src/components/header/style.scss @@ -44,6 +44,12 @@ } } +.edit-post-header__center { + flex-grow: 1; + display: flex; + justify-content: center; +} + /** * Buttons on the right side */ diff --git a/packages/edit-post/src/components/header/template-title/delete-template.js b/packages/edit-post/src/components/header/template-title/delete-template.js deleted file mode 100644 index 6a6e6f58f0df18..00000000000000 --- a/packages/edit-post/src/components/header/template-title/delete-template.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * WordPress dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; -import { - MenuGroup, - MenuItem, - __experimentalConfirmDialog as ConfirmDialog, -} from '@wordpress/components'; -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { store as editorStore } from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; - -export default function DeleteTemplate() { - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { setIsEditingTemplate } = useDispatch( editPostStore ); - const { getEditorSettings } = useSelect( editorStore ); - const { updateEditorSettings, editPost } = useDispatch( editorStore ); - const { deleteEntityRecord } = useDispatch( coreStore ); - const { template } = useSelect( ( select ) => { - const { isEditingTemplate, getEditedPostTemplate } = - select( editPostStore ); - const _isEditing = isEditingTemplate(); - return { - template: _isEditing ? getEditedPostTemplate() : null, - }; - }, [] ); - const [ showConfirmDialog, setShowConfirmDialog ] = useState( false ); - - if ( ! template || ! template.wp_id ) { - return null; - } - let templateTitle = template.slug; - if ( template?.title ) { - templateTitle = template.title; - } - - const isRevertable = template?.has_theme_file; - - const onDelete = () => { - clearSelectedBlock(); - setIsEditingTemplate( false ); - setShowConfirmDialog( false ); - - editPost( { - template: '', - } ); - const settings = getEditorSettings(); - const newAvailableTemplates = Object.fromEntries( - Object.entries( settings.availableTemplates ?? {} ).filter( - ( [ id ] ) => id !== template.slug - ) - ); - updateEditorSettings( { - availableTemplates: newAvailableTemplates, - } ); - deleteEntityRecord( 'postType', 'wp_template', template.id, { - throwOnError: true, - } ); - }; - - return ( - <MenuGroup className="edit-post-template-top-area__second-menu-group"> - <> - <MenuItem - className="edit-post-template-top-area__delete-template-button" - isDestructive={ ! isRevertable } - onClick={ () => { - setShowConfirmDialog( true ); - } } - info={ - isRevertable - ? __( 'Use the template as supplied by the theme.' ) - : undefined - } - > - { isRevertable - ? __( 'Clear customizations' ) - : __( 'Delete template' ) } - </MenuItem> - <ConfirmDialog - isOpen={ showConfirmDialog } - onConfirm={ onDelete } - onCancel={ () => { - setShowConfirmDialog( false ); - } } - > - { sprintf( - /* translators: %s: template name */ - __( - 'Are you sure you want to delete the %s template? It may be used by other pages or posts.' - ), - templateTitle - ) } - </ConfirmDialog> - </> - </MenuGroup> - ); -} diff --git a/packages/edit-post/src/components/header/template-title/edit-template-title.js b/packages/edit-post/src/components/header/template-title/edit-template-title.js deleted file mode 100644 index 447ea5e4e02d72..00000000000000 --- a/packages/edit-post/src/components/header/template-title/edit-template-title.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { TextControl } from '@wordpress/components'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { useState } from '@wordpress/element'; -import { store as editorStore } from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; - -export default function EditTemplateTitle() { - const [ forceEmpty, setForceEmpty ] = useState( false ); - const { template } = useSelect( ( select ) => { - const { getEditedPostTemplate } = select( editPostStore ); - return { - template: getEditedPostTemplate(), - }; - }, [] ); - - const { editEntityRecord } = useDispatch( coreStore ); - const { getEditorSettings } = useSelect( editorStore ); - const { updateEditorSettings } = useDispatch( editorStore ); - - // Only user-created and non-default templates can change the name. - if ( ! template.is_custom || template.has_theme_file ) { - return null; - } - - let templateTitle = __( 'Default' ); - if ( template?.title ) { - templateTitle = template.title; - } else if ( !! template ) { - templateTitle = template.slug; - } - - return ( - <div className="edit-site-template-details__group"> - <TextControl - __nextHasNoMarginBottom - label={ __( 'Title' ) } - value={ forceEmpty ? '' : templateTitle } - help={ __( - 'Give the template a title that indicates its purpose, e.g. "Full Width".' - ) } - onChange={ ( newTitle ) => { - // Allow having the field temporarily empty while typing. - if ( ! newTitle && ! forceEmpty ) { - setForceEmpty( true ); - return; - } - setForceEmpty( false ); - - const settings = getEditorSettings(); - const newAvailableTemplates = Object.fromEntries( - Object.entries( settings.availableTemplates ?? {} ).map( - ( [ id, existingTitle ] ) => [ - id, - id !== template.slug ? existingTitle : newTitle, - ] - ) - ); - updateEditorSettings( { - availableTemplates: newAvailableTemplates, - } ); - editEntityRecord( 'postType', 'wp_template', template.id, { - title: newTitle, - } ); - } } - onBlur={ () => setForceEmpty( false ) } - /> - </div> - ); -} diff --git a/packages/edit-post/src/components/header/template-title/index.js b/packages/edit-post/src/components/header/template-title/index.js deleted file mode 100644 index c0745dc0451b74..00000000000000 --- a/packages/edit-post/src/components/header/template-title/index.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * WordPress dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { - Dropdown, - Button, - __experimentalText as Text, -} from '@wordpress/components'; -import { chevronDown } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { store as editorStore } from '@wordpress/editor'; -import DeleteTemplate from './delete-template'; -import EditTemplateTitle from './edit-template-title'; -import TemplateDescription from './template-description'; - -function TemplateTitle() { - const { template, isEditing, title } = useSelect( ( select ) => { - const { isEditingTemplate, getEditedPostTemplate } = - select( editPostStore ); - const { getEditedPostAttribute } = select( editorStore ); - - const _isEditing = isEditingTemplate(); - - return { - template: _isEditing ? getEditedPostTemplate() : null, - isEditing: _isEditing, - title: getEditedPostAttribute( 'title' ) - ? getEditedPostAttribute( 'title' ) - : __( 'Untitled' ), - }; - }, [] ); - - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { setIsEditingTemplate } = useDispatch( editPostStore ); - - if ( ! isEditing || ! template ) { - return null; - } - - let templateTitle = __( 'Default' ); - if ( template?.title ) { - templateTitle = template.title; - } else if ( !! template ) { - templateTitle = template.slug; - } - - const hasOptions = !! ( - template.custom || - template.wp_id || - template.description - ); - - return ( - <div className="edit-post-template-top-area"> - <Button - className="edit-post-template-post-title" - isLink - showTooltip - label={ sprintf( - /* translators: %s: Title of the referring post, e.g: "Hello World!" */ - __( 'Edit %s' ), - title - ) } - onClick={ () => { - clearSelectedBlock(); - setIsEditingTemplate( false ); - } } - > - { title } - </Button> - { hasOptions ? ( - <Dropdown - popoverProps={ { placement: 'bottom' } } - contentClassName="edit-post-template-top-area__popover" - renderToggle={ ( { onToggle } ) => ( - <Button - className="edit-post-template-title" - isLink - icon={ chevronDown } - showTooltip - onClick={ onToggle } - label={ __( 'Template Options' ) } - > - { templateTitle } - </Button> - ) } - renderContent={ () => ( - <> - <EditTemplateTitle /> - <TemplateDescription /> - <DeleteTemplate /> - </> - ) } - /> - ) : ( - <Text - className="edit-post-template-title" - size="body" - style={ { lineHeight: '24px' } } - > - { templateTitle } - </Text> - ) } - </div> - ); -} - -export default TemplateTitle; diff --git a/packages/edit-post/src/components/header/template-title/style.scss b/packages/edit-post/src/components/header/template-title/style.scss deleted file mode 100644 index b5fe5120bfb64c..00000000000000 --- a/packages/edit-post/src/components/header/template-title/style.scss +++ /dev/null @@ -1,94 +0,0 @@ -.edit-post-template-top-area { - display: flex; - flex-direction: column; - align-content: space-between; - width: 100%; - align-items: center; - - .edit-post-template-title, - .edit-post-template-post-title { - padding: 0; - text-decoration: none; - height: auto; - - &::before { - height: 100%; - } - - &.has-icon { - svg { - order: 1; - margin-right: 0; - } - } - } - - .edit-post-template-title { - color: $gray-900; - } - - .edit-post-template-post-title { - margin-top: $grid-unit-05; - max-width: 160px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: block; - - &::before { - left: 0; - right: 0; - } - - @include break-xlarge() { - max-width: 400px; - } - } -} - -.edit-post-template-top-area__popover { - .components-popover__content { - min-width: 280px; - padding: 0; - } - - .edit-site-template-details__group { - padding: $grid-unit-20; - - .components-base-control__help { - margin-bottom: 0; - } - } - - .edit-post-template-details__description { - color: $gray-700; - } -} - -.edit-post-template-top-area__second-menu-group { - border-top: $border-width solid $gray-300; - padding: $grid-unit-20 $grid-unit-10; - - .edit-post-template-top-area__delete-template-button { - display: flex; - justify-content: center; - padding: $grid-unit-05 $grid-unit; - - &.is-destructive { - padding: inherit; - margin-left: $grid-unit-10; - margin-right: $grid-unit-10; - width: calc(100% - #{($grid-unit * 2)}); - - .components-menu-item__item { - width: auto; - } - } - - .components-menu-item__item { - margin-right: 0; - min-width: 0; - width: 100%; - } - } -} diff --git a/packages/edit-post/src/components/header/template-title/template-description.js b/packages/edit-post/src/components/header/template-title/template-description.js deleted file mode 100644 index 3513496852c339..00000000000000 --- a/packages/edit-post/src/components/header/template-title/template-description.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { - __experimentalHeading as Heading, - __experimentalText as Text, -} from '@wordpress/components'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; - -export default function TemplateDescription() { - const { description, title } = useSelect( ( select ) => { - const { getEditedPostTemplate } = select( editPostStore ); - return { - title: getEditedPostTemplate().title, - description: getEditedPostTemplate().description, - }; - }, [] ); - if ( ! description ) { - return null; - } - - return ( - <div className="edit-site-template-details__group"> - <Heading level={ 4 } weight={ 600 }> - { title } - </Heading> - <Text - className="edit-post-template-details__description" - size="body" - as="p" - style={ { marginTop: '12px' } } - > - { description } - </Text> - </div> - ); -} diff --git a/packages/edit-post/src/components/header/writing-menu/index.js b/packages/edit-post/src/components/header/writing-menu/index.js index 6cea56392381e1..c0d6ff994815e6 100644 --- a/packages/edit-post/src/components/header/writing-menu/index.js +++ b/packages/edit-post/src/components/header/writing-menu/index.js @@ -25,26 +25,16 @@ function WritingMenu() { [] ); - const blocks = useSelect( - ( select ) => select( blockEditorStore ).getBlocks(), - [] - ); - const { setIsInserterOpened, setIsListViewOpened, closeGeneralSidebar } = useDispatch( postEditorStore ); const { set: setPreference } = useDispatch( preferencesStore ); - const { selectBlock } = useDispatch( blockEditorStore ); - const toggleDistractionFree = () => { registry.batch( () => { setPreference( 'core/edit-post', 'fixedToolbar', false ); setIsInserterOpened( false ); setIsListViewOpened( false ); closeGeneralSidebar(); - if ( ! isDistractionFree && !! blocks.length ) { - selectBlock( blocks[ 0 ].clientId ); - } } ); }; diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js index 54565bb5dcd5b5..9a7ce46704d479 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js @@ -14,6 +14,7 @@ import { } from '@wordpress/keyboard-shortcuts'; import { withSelect, withDispatch, useSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; +import { store as interfaceStore } from '@wordpress/interface'; /** * Internal dependencies @@ -21,9 +22,9 @@ import { compose } from '@wordpress/compose'; import { textFormattingShortcuts } from './config'; import Shortcut from './shortcut'; import DynamicShortcut from './dynamic-shortcut'; -import { store as editPostStore } from '../../store'; -const MODAL_NAME = 'edit-post/keyboard-shortcut-help'; +export const KEYBOARD_SHORTCUT_HELP_MODAL_NAME = + 'edit-post/keyboard-shortcut-help'; const ShortcutList = ( { shortcuts } ) => ( /* @@ -141,14 +142,18 @@ export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) { export default compose( [ withSelect( ( select ) => ( { - isModalActive: select( editPostStore ).isModalActive( MODAL_NAME ), + isModalActive: select( interfaceStore ).isModalActive( + KEYBOARD_SHORTCUT_HELP_MODAL_NAME + ), } ) ), withDispatch( ( dispatch, { isModalActive } ) => { - const { openModal, closeModal } = dispatch( editPostStore ); + const { openModal, closeModal } = dispatch( interfaceStore ); return { toggleModal: () => - isModalActive ? closeModal() : openModal( MODAL_NAME ), + isModalActive + ? closeModal() + : openModal( KEYBOARD_SHORTCUT_HELP_MODAL_NAME ), }; } ), ] )( KeyboardShortcutHelpModal ); diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap index 2ad8e457bae303..b98bd562f0a6a3 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap @@ -272,6 +272,35 @@ exports[`KeyboardShortcutHelpModal should match snapshot when the modal is activ </kbd> </div> </li> + <li + class="edit-post-keyboard-shortcut-help-modal__shortcut" + > + <div + class="edit-post-keyboard-shortcut-help-modal__shortcut-description" + > + Select text across multiple blocks. + </div> + <div + class="edit-post-keyboard-shortcut-help-modal__shortcut-term" + > + <kbd + aria-label="Shift + Arrow" + class="edit-post-keyboard-shortcut-help-modal__shortcut-key-combination" + > + <kbd + class="edit-post-keyboard-shortcut-help-modal__shortcut-key" + > + Shift + </kbd> + + + <kbd + class="edit-post-keyboard-shortcut-help-modal__shortcut-key" + > + Arrow + </kbd> + </kbd> + </div> + </li> </ul> </section> <section diff --git a/packages/edit-post/src/components/keyboard-shortcuts/index.js b/packages/edit-post/src/components/keyboard-shortcuts/index.js index 5344d9155c8a47..1760edd8d3fc0c 100644 --- a/packages/edit-post/src/components/keyboard-shortcuts/index.js +++ b/packages/edit-post/src/components/keyboard-shortcuts/index.js @@ -184,7 +184,7 @@ function KeyboardShortcuts() { } ); registerShortcut( { - name: `core/edit-post/transform-heading-to-paragraph`, + name: 'core/edit-post/transform-heading-to-paragraph', category: 'block-library', description: __( 'Transform heading to paragraph.' ), keyCombination: { @@ -223,14 +223,12 @@ function KeyboardShortcuts() { } ); useShortcut( 'core/edit-post/toggle-distraction-free', () => { - closeGeneralSidebar(); - setIsListViewOpened( false ); toggleDistractionFree(); toggleFeature( 'distractionFree' ); createInfoNotice( isFeatureActive( 'distractionFree' ) - ? __( 'Distraction free mode turned on.' ) - : __( 'Distraction free mode turned off.' ), + ? __( 'Distraction free on.' ) + : __( 'Distraction free off.' ), { id: 'core/edit-post/distraction-free-mode/notice', type: 'snackbar', diff --git a/packages/edit-post/src/components/layout/actions-panel.js b/packages/edit-post/src/components/layout/actions-panel.js index 3381dc13b9ad8b..d8dd73ed4c6031 100644 --- a/packages/edit-post/src/components/layout/actions-panel.js +++ b/packages/edit-post/src/components/layout/actions-panel.js @@ -31,18 +31,17 @@ export default function ActionsPanel( { const { publishSidebarOpened, hasActiveMetaboxes, - isSavingMetaBoxes, hasNonPostEntityChanges, - } = useSelect( ( select ) => { - return { + } = useSelect( + ( select ) => ( { publishSidebarOpened: select( editPostStore ).isPublishSidebarOpened(), hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), - isSavingMetaBoxes: select( editPostStore ).isSavingMetaBoxes(), hasNonPostEntityChanges: select( editorStore ).hasNonPostEntityChanges(), - }; - }, [] ); + } ), + [] + ); const openEntitiesSavedStates = useCallback( () => setEntitiesSavedStatesCallback( true ), @@ -57,7 +56,6 @@ export default function ActionsPanel( { <PostPublishPanel onClose={ closePublishSidebar } forceIsDirty={ hasActiveMetaboxes } - forceIsSaving={ isSavingMetaBoxes } PrePublishExtension={ PluginPrePublishPanel.Slot } PostPublishExtension={ PluginPostPublishPanel.Slot } /> diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index 15bc017900daa2..c0018d40d6ef82 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -12,12 +12,18 @@ import { UnsavedChangesWarning, EditorNotices, EditorKeyboardShortcutsRegister, + EditorKeyboardShortcuts, EditorSnackbars, + PostSyncStatusModal, store as editorStore, } from '@wordpress/editor'; import { useSelect, useDispatch } from '@wordpress/data'; -import { BlockBreadcrumb } from '@wordpress/block-editor'; -import { Button, ScrollLock, Popover } from '@wordpress/components'; +import { + useBlockCommands, + BlockBreadcrumb, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; +import { Button, ScrollLock } from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; import { PluginArea } from '@wordpress/plugins'; import { __, _x, sprintf } from '@wordpress/i18n'; @@ -27,7 +33,7 @@ import { InterfaceSkeleton, store as interfaceStore, } from '@wordpress/interface'; -import { useState, useEffect, useCallback } from '@wordpress/element'; +import { useState, useEffect, useCallback, useMemo } from '@wordpress/element'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; import { store as noticesStore } from '@wordpress/notices'; @@ -49,6 +55,9 @@ import WelcomeGuide from '../welcome-guide'; import ActionsPanel from './actions-panel'; import StartPageOptions from '../start-page-options'; import { store as editPostStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +const { getLayoutStyles } = unlock( blockEditorPrivateApis ); const interfaceLabels = { /* translators: accessibility text for the editor top bar landmark region. */ @@ -63,7 +72,59 @@ const interfaceLabels = { footer: __( 'Editor footer' ), }; -function Layout( { styles } ) { +function useEditorStyles() { + const { hasThemeStyleSupport, editorSettings } = useSelect( + ( select ) => ( { + hasThemeStyleSupport: + select( editPostStore ).isFeatureActive( 'themeStyles' ), + editorSettings: select( editorStore ).getEditorSettings(), + } ), + [] + ); + + // Compute the default styles. + return useMemo( () => { + const presetStyles = + editorSettings.styles?.filter( + ( style ) => + style.__unstableType && style.__unstableType !== 'theme' + ) ?? []; + + const defaultEditorStyles = [ + ...editorSettings.defaultEditorStyles, + ...presetStyles, + ]; + + // Has theme styles if the theme supports them and if some styles were not preset styles (in which case they're theme styles). + const hasThemeStyles = + hasThemeStyleSupport && + presetStyles.length !== ( editorSettings.styles?.length ?? 0 ); + + // If theme styles are not present or displayed, ensure that + // base layout styles are still present in the editor. + if ( ! editorSettings.disableLayoutStyles && ! hasThemeStyles ) { + defaultEditorStyles.push( { + css: getLayoutStyles( { + style: {}, + selector: 'body', + hasBlockGapSupport: false, + hasFallbackGapSupport: true, + fallbackGapValue: '0.5em', + } ), + } ); + } + + return hasThemeStyles ? editorSettings.styles : defaultEditorStyles; + }, [ + editorSettings.defaultEditorStyles, + editorSettings.disableLayoutStyles, + editorSettings.styles, + hasThemeStyleSupport, + ] ); +} + +function Layout() { + useBlockCommands(); const isMobileViewport = useViewportMatch( 'medium', '<' ); const isHugeViewport = useViewportMatch( 'huge', '>=' ); const isLargeViewport = useViewportMatch( 'large' ); @@ -126,6 +187,8 @@ function Layout( { styles } ) { }; }, [] ); + const styles = useEditorStyles(); + const openSidebarPanel = () => openGeneralSidebar( hasBlockSelected ? 'edit-post/block' : 'edit-post/document' @@ -202,6 +265,7 @@ function Layout( { styles } ) { <LocalAutosaveMonitor /> <EditPostKeyboardShortcuts /> <EditorKeyboardShortcutsRegister /> + <EditorKeyboardShortcuts /> <SettingsSidebar /> <InterfaceSkeleton isDistractionFree={ isDistractionFree && isLargeViewport } @@ -291,8 +355,8 @@ function Layout( { styles } ) { <EditPostPreferencesModal /> <KeyboardShortcutHelpModal /> <WelcomeGuide /> + <PostSyncStatusModal /> <StartPageOptions /> - <Popover.Slot /> <PluginArea onError={ onPluginAreaError } /> </> ); diff --git a/packages/edit-post/src/components/layout/style.native.scss b/packages/edit-post/src/components/layout/style.native.scss index faee2799e54eb8..765314bf15955c 100644 --- a/packages/edit-post/src/components/layout/style.native.scss +++ b/packages/edit-post/src/components/layout/style.native.scss @@ -6,7 +6,7 @@ } .containerDark { - background-color: $app-background-dark-alt; + background-color: $app-safe-area-background-dark; } .background { diff --git a/packages/edit-post/src/components/preferences-modal/index.js b/packages/edit-post/src/components/preferences-modal/index.js index 77c6383b13f32c..825b6c60c735f2 100644 --- a/packages/edit-post/src/components/preferences-modal/index.js +++ b/packages/edit-post/src/components/preferences-modal/index.js @@ -18,6 +18,7 @@ import { PreferencesModal, PreferencesModalTabs, PreferencesModalSection, + store as interfaceStore, } from '@wordpress/interface'; import { store as preferencesStore } from '@wordpress/preferences'; @@ -35,17 +36,18 @@ import MetaBoxesSection from './meta-boxes-section'; import { store as editPostStore } from '../../store'; import BlockManager from '../block-manager'; -const MODAL_NAME = 'edit-post/preferences'; +export const PREFERENCES_MODAL_NAME = 'edit-post/preferences'; export default function EditPostPreferencesModal() { const isLargeViewport = useViewportMatch( 'medium' ); - const { closeModal } = useDispatch( editPostStore ); + const { closeModal } = useDispatch( interfaceStore ); const [ isModalActive, showBlockBreadcrumbsOption ] = useSelect( ( select ) => { const { getEditorSettings } = select( editorStore ); const { getEditorMode, isFeatureActive } = select( editPostStore ); - const modalActive = - select( editPostStore ).isModalActive( MODAL_NAME ); + const modalActive = select( interfaceStore ).isModalActive( + PREFERENCES_MODAL_NAME + ); const mode = getEditorMode(); const isRichEditingEnabled = getEditorSettings().richEditingEnabled; const isDistractionFreeEnabled = @@ -150,6 +152,15 @@ export default function EditPostPreferencesModal() { label={ __( 'Display block breadcrumbs' ) } /> ) } + <EnableFeature + featureName="allowRightClickOverrides" + help={ __( + 'Allows contextual menus via right-click, overriding browser defaults.' + ) } + label={ __( + 'Allow right-click contextual menus' + ) } + /> </PreferencesModalSection> </> ), diff --git a/packages/edit-post/src/components/preferences-modal/options/enable-custom-fields.js b/packages/edit-post/src/components/preferences-modal/options/enable-custom-fields.js index 063875786c3aff..15521b80b361b2 100644 --- a/packages/edit-post/src/components/preferences-modal/options/enable-custom-fields.js +++ b/packages/edit-post/src/components/preferences-modal/options/enable-custom-fields.js @@ -7,10 +7,23 @@ import { Button } from '@wordpress/components'; import { withSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { ___unstablePreferencesModalBaseOption as BaseOption } from '@wordpress/interface'; +import { getPathAndQueryString } from '@wordpress/url'; + +function submitCustomFieldsForm() { + const customFieldsForm = document.getElementById( + 'toggle-custom-fields-form' + ); + + // Ensure the referrer values is up to update with any + customFieldsForm + .querySelector( '[name="_wp_http_referer"]' ) + .setAttribute( 'value', getPathAndQueryString( window.location.href ) ); + + customFieldsForm.submit(); +} export function CustomFieldsConfirmation( { willEnable } ) { const [ isReloading, setIsReloading ] = useState( false ); - return ( <> <p className="edit-post-preferences-modal__custom-fields-confirmation-message"> @@ -25,14 +38,12 @@ export function CustomFieldsConfirmation( { willEnable } ) { disabled={ isReloading } onClick={ () => { setIsReloading( true ); - document - .getElementById( 'toggle-custom-fields-form' ) - .submit(); + submitCustomFieldsForm(); } } > { willEnable - ? __( 'Enable & Reload' ) - : __( 'Disable & Reload' ) } + ? __( 'Show & Reload Page' ) + : __( 'Hide & Reload Page' ) } </Button> </> ); diff --git a/packages/edit-post/src/components/preferences-modal/options/test/__snapshots__/enable-custom-fields.js.snap b/packages/edit-post/src/components/preferences-modal/options/test/__snapshots__/enable-custom-fields.js.snap index a405a1c39ee1cd..781cff283f1646 100644 --- a/packages/edit-post/src/components/preferences-modal/options/test/__snapshots__/enable-custom-fields.js.snap +++ b/packages/edit-post/src/components/preferences-modal/options/test/__snapshots__/enable-custom-fields.js.snap @@ -100,7 +100,7 @@ exports[`EnableCustomFieldsOption renders a checked checkbox and a confirmation class="components-button edit-post-preferences-modal__custom-fields-confirmation-button is-secondary" type="button" > - Enable & Reload + Show & Reload Page </button> </div> </div> @@ -303,7 +303,7 @@ exports[`EnableCustomFieldsOption renders an unchecked checkbox and a confirmati class="components-button edit-post-preferences-modal__custom-fields-confirmation-button is-secondary" type="button" > - Disable & Reload + Hide & Reload Page </button> </div> </div> diff --git a/packages/edit-post/src/components/preferences-modal/options/test/enable-custom-fields.js b/packages/edit-post/src/components/preferences-modal/options/test/enable-custom-fields.js index 80c5bcef2565b1..2dbeadec8350ac 100644 --- a/packages/edit-post/src/components/preferences-modal/options/test/enable-custom-fields.js +++ b/packages/edit-post/src/components/preferences-modal/options/test/enable-custom-fields.js @@ -58,12 +58,13 @@ describe( 'CustomFieldsConfirmation', () => { it( 'submits the toggle-custom-fields-form', async () => { const user = userEvent.setup(); const submit = jest.fn(); + const setAttribute = jest.fn(); const getElementById = jest .spyOn( document, 'getElementById' ) .mockImplementation( () => ( { submit, + querySelector: () => ( { setAttribute } ), } ) ); - render( <CustomFieldsConfirmation /> ); await user.click( screen.getByRole( 'button' ) ); @@ -71,6 +72,10 @@ describe( 'CustomFieldsConfirmation', () => { expect( getElementById ).toHaveBeenCalledWith( 'toggle-custom-fields-form' ); + expect( setAttribute ).toHaveBeenCalledWith( + 'value', + '/' // This is the path returned by getPathAndQueryString. + ); expect( submit ).toHaveBeenCalled(); getElementById.mockRestore(); diff --git a/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap index 2d43aa7922f361..545baf6939f711 100644 --- a/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap @@ -115,6 +115,8 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active aria-controls="tab-panel-0-general-view" aria-selected="true" class="components-button components-tab-panel__tabs-item is-active" + data-active-item="" + data-command="" id="tab-panel-0-general" role="tab" type="button" @@ -125,9 +127,9 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active aria-controls="tab-panel-0-blocks-view" aria-selected="false" class="components-button components-tab-panel__tabs-item" + data-command="" id="tab-panel-0-blocks" role="tab" - tabindex="-1" type="button" > Blocks @@ -136,9 +138,9 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active aria-controls="tab-panel-0-panels-view" aria-selected="false" class="components-button components-tab-panel__tabs-item" + data-command="" id="tab-panel-0-panels" role="tab" - tabindex="-1" type="button" > Panels @@ -149,6 +151,7 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active class="components-tab-panel__tab-content" id="tab-panel-0-general-view" role="tabpanel" + tabindex="0" > <fieldset class="interface-preferences-modal__section" @@ -521,6 +524,54 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active </p> </div> </div> + <div + class="interface-preferences-modal__option" + > + <div + class="components-base-control components-toggle-control emotion-0 emotion-1" + > + <div + class="components-base-control__field emotion-2 emotion-3" + > + <div + class="components-flex components-h-stack emotion-4 emotion-5" + data-wp-c16t="true" + data-wp-component="HStack" + > + <span + class="components-form-toggle" + > + <input + aria-describedby="inspector-toggle-control-7__help" + class="components-form-toggle__input" + id="inspector-toggle-control-7" + type="checkbox" + /> + <span + class="components-form-toggle__track" + /> + <span + class="components-form-toggle__thumb" + /> + </span> + <label + class="components-flex-item components-flex-block components-toggle-control__label emotion-6 emotion-5" + data-wp-c16t="true" + data-wp-component="FlexBlock" + for="inspector-toggle-control-7" + > + Allow right-click contextual menus + </label> + </div> + </div> + <p + class="components-base-control__help emotion-8 emotion-9" + id="inspector-toggle-control-7__help" + > + Allows contextual menus via right-click, overriding browser defaults. + </p> + </div> + </div> </fieldset> </div> </div> @@ -590,6 +641,8 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active } .emotion-13 { + font-size: 13px; + font-family: inherit; -webkit-appearance: none; -moz-appearance: none; -ms-appearance: none; @@ -607,15 +660,27 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active border-radius: 2px; } +.emotion-13 svg, +.emotion-13 path { + fill: currentColor; +} + .emotion-13:hover { color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); } .emotion-13:focus { - background-color: transparent; - color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); - border-color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); - outline: 3px solid transparent; + box-shadow: none; + outline: none; +} + +.emotion-13:focus-visible { + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) var( + --wp-components-color-accent, + var( --wp-admin-theme-color, var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)) ) + ); + outline: 2px solid transparent; + outline-offset: 0; } .emotion-15 { diff --git a/packages/edit-post/src/components/preferences-modal/test/index.js b/packages/edit-post/src/components/preferences-modal/test/index.js index d1ff306488cb9d..cc88386ff0d520 100644 --- a/packages/edit-post/src/components/preferences-modal/test/index.js +++ b/packages/edit-post/src/components/preferences-modal/test/index.js @@ -20,10 +20,14 @@ jest.mock( '@wordpress/compose/src/hooks/use-viewport-match', () => jest.fn() ); describe( 'EditPostPreferencesModal', () => { describe( 'should match snapshot when the modal is active', () => { - it( 'large viewports', () => { + it( 'large viewports', async () => { useSelect.mockImplementation( () => [ true, true, false ] ); useViewportMatch.mockImplementation( () => true ); render( <EditPostPreferencesModal /> ); + await screen.findByRole( 'tab', { + name: 'General', + selected: true, + } ); expect( screen.getByRole( 'dialog', { name: 'Preferences' } ) ).toMatchSnapshot(); diff --git a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js index 69aaf573b840ec..77a56617cb1c63 100644 --- a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js @@ -1,13 +1,8 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ import { __experimentalListView as ListView } from '@wordpress/block-editor'; -import { Button } from '@wordpress/components'; +import { Button, TabPanel } from '@wordpress/components'; import { useFocusOnMount, useFocusReturn, @@ -16,7 +11,7 @@ import { import { useDispatch } from '@wordpress/data'; import { focus } from '@wordpress/dom'; import { useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { useShortcut } from '@wordpress/keyboard-shortcuts'; import { ESCAPE } from '@wordpress/keycodes'; @@ -30,7 +25,9 @@ import ListViewOutline from './list-view-outline'; export default function ListViewSidebar() { const { setIsListViewOpened } = useDispatch( editPostStore ); + // This hook handles focus when the sidebar first renders. const focusOnMountRef = useFocusOnMount( 'firstElement' ); + // The next 2 hooks handle focus for when the sidebar closes and returning focus to the element that had focus before sidebar opened. const headerFocusReturnRef = useFocusReturn(); const contentFocusReturnRef = useFocusReturn(); @@ -44,18 +41,24 @@ export default function ListViewSidebar() { // Use internal state instead of a ref to make sure that the component // re-renders when the dropZoneElement updates. const [ dropZoneElement, setDropZoneElement ] = useState( null ); - + // Tracks our current tab. const [ tab, setTab ] = useState( 'list-view' ); // This ref refers to the sidebar as a whole. const sidebarRef = useRef(); - // This ref refers to the list view tab button. - const listViewTabRef = useRef(); - // This ref refers to the outline tab button. - const outlineTabRef = useRef(); + // This ref refers to the tab panel. + const tabPanelRef = useRef(); // This ref refers to the list view application area. const listViewRef = useRef(); + // Must merge the refs together so focus can be handled properly in the next function. + const listViewContainerRef = useMergeRefs( [ + contentFocusReturnRef, + focusOnMountRef, + listViewRef, + setDropZoneElement, + ] ); + /* * Callback function to handle list view or outline focus. * @@ -64,9 +67,11 @@ export default function ListViewSidebar() { * @return void */ function handleSidebarFocus( currentTab ) { + // Tab panel focus. + const tabPanelFocus = focus.tabbable.find( tabPanelRef.current )[ 0 ]; // List view tab is selected. if ( currentTab === 'list-view' ) { - // Either focus the list view or the list view tab button. Must have a fallback because the list view does not render when there are no blocks. + // Either focus the list view or the tab panel. Must have a fallback because the list view does not render when there are no blocks. const listViewApplicationFocus = focus.tabbable.find( listViewRef.current )[ 0 ]; @@ -74,11 +79,11 @@ export default function ListViewSidebar() { listViewApplicationFocus ) ? listViewApplicationFocus - : listViewTabRef.current; + : tabPanelFocus; listViewFocusArea.focus(); // Outline tab is selected. } else { - outlineTabRef.current.focus(); + tabPanelFocus.focus(); } } @@ -97,6 +102,22 @@ export default function ListViewSidebar() { } } ); + /** + * Render tab content for a given tab name. + * + * @param {string} tabName The name of the tab to render. + */ + function renderTabContent( tabName ) { + if ( tabName === 'list-view' ) { + return ( + <div className="edit-post-editor__list-view-panel-content"> + <ListView dropZoneElement={ dropZoneElement } /> + </div> + ); + } + return <ListViewOutline />; + } + return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions <div @@ -104,64 +125,40 @@ export default function ListViewSidebar() { onKeyDown={ closeOnEscape } ref={ sidebarRef } > - <div - className="edit-post-editor__document-overview-panel-header components-panel__header edit-post-sidebar__panel-tabs" + <Button + className="edit-post-editor__document-overview-panel__close-button" ref={ headerFocusReturnRef } + icon={ closeSmall } + label={ __( 'Close' ) } + onClick={ () => setIsListViewOpened( false ) } + /> + <TabPanel + className="edit-post-editor__document-overview-panel__tab-panel" + ref={ tabPanelRef } + onSelect={ ( tabName ) => setTab( tabName ) } + selectOnMove={ false } + tabs={ [ + { + name: 'list-view', + title: _x( 'List View', 'Post overview' ), + className: 'edit-post-sidebar__panel-tab', + }, + { + name: 'outline', + title: _x( 'Outline', 'Post overview' ), + className: 'edit-post-sidebar__panel-tab', + }, + ] } > - <Button - icon={ closeSmall } - label={ __( 'Close' ) } - onClick={ () => setIsListViewOpened( false ) } - /> - <ul> - <li> - <Button - ref={ listViewTabRef } - onClick={ () => { - setTab( 'list-view' ); - } } - className={ classnames( - 'edit-post-sidebar__panel-tab', - { 'is-active': tab === 'list-view' } - ) } - aria-current={ tab === 'list-view' } - > - { __( 'List View' ) } - </Button> - </li> - <li> - <Button - ref={ outlineTabRef } - onClick={ () => { - setTab( 'outline' ); - } } - className={ classnames( - 'edit-post-sidebar__panel-tab', - { 'is-active': tab === 'outline' } - ) } - aria-current={ tab === 'outline' } - > - { __( 'Outline' ) } - </Button> - </li> - </ul> - </div> - <div - ref={ useMergeRefs( [ - contentFocusReturnRef, - focusOnMountRef, - listViewRef, - setDropZoneElement, - ] ) } - className="edit-post-editor__list-view-container" - > - { tab === 'list-view' && ( - <div className="edit-post-editor__list-view-panel-content"> - <ListView dropZoneElement={ dropZoneElement } /> + { ( currentTab ) => ( + <div + className="edit-post-editor__list-view-container" + ref={ listViewContainerRef } + > + { renderTabContent( currentTab.name ) } </div> ) } - { tab === 'outline' && <ListViewOutline /> } - </div> + </TabPanel> </div> ); } diff --git a/packages/edit-post/src/components/secondary-sidebar/style.scss b/packages/edit-post/src/components/secondary-sidebar/style.scss index 63a3746e1b8443..122d2ec3c9c525 100644 --- a/packages/edit-post/src/components/secondary-sidebar/style.scss +++ b/packages/edit-post/src/components/secondary-sidebar/style.scss @@ -17,8 +17,30 @@ width: 350px; } - .edit-post-sidebar__panel-tabs { - flex-direction: row-reverse; + .edit-post-editor__document-overview-panel__close-button { + position: absolute; + right: $grid-unit-10; + top: math.div($grid-unit-60 - $button-size, 2); // ( tab height - button size ) / 2 + z-index: 1; + background: $white; + } + + // The TabPanel style overrides in the following blocks should be removed when the new TabPanel is available. + .components-tab-panel__tabs { + border-bottom: $border-width solid $gray-300; + box-sizing: border-box; + display: flex; + width: 100%; + padding-right: $grid-unit-70; + + .edit-post-sidebar__panel-tab { + width: 50%; + margin-bottom: -$border-width; + } + } + + .components-tab-panel__tab-content { + height: calc(100% - #{$grid-unit-60 - $border-width}); } } @@ -37,34 +59,6 @@ } } -.edit-post-editor__document-overview-panel-header { - border-bottom: $border-width solid $gray-300; - display: flex; - justify-content: space-between; - height: $grid-unit-60; - padding-left: $grid-unit-20; - padding-right: $grid-unit-05; - ul { - width: calc(100% - #{ $grid-unit-50 }); - } - li { - width: 50%; - button { - width: 100%; - text-align: initial; - } - } - li:only-child { - width: 100%; - } - - &.components-panel__header.edit-post-sidebar__panel-tabs { - .components-button.has-icon { - display: flex; - } - } -} - .edit-post-editor__list-view-panel-content, .edit-post-editor__list-view-container > .document-outline, .edit-post-editor__list-view-empty-headings { @@ -118,5 +112,9 @@ .edit-post-editor__list-view-container { display: flex; flex-direction: column; - height: calc(100% - #{$grid-unit-60}); + height: 100%; +} + +.edit-post-editor__document-overview-panel__tab-panel { + height: 100%; } diff --git a/packages/edit-post/src/components/sidebar/discussion-panel/index.js b/packages/edit-post/src/components/sidebar/discussion-panel/index.js index f6f416ddebd8ce..c8e63f23fac8dd 100644 --- a/packages/edit-post/src/components/sidebar/discussion-panel/index.js +++ b/packages/edit-post/src/components/sidebar/discussion-panel/index.js @@ -8,8 +8,7 @@ import { PostPingbacks, PostTypeSupportCheck, } from '@wordpress/editor'; -import { compose } from '@wordpress/compose'; -import { withSelect, withDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -21,7 +20,18 @@ import { store as editPostStore } from '../../../store'; */ const PANEL_NAME = 'discussion-panel'; -function DiscussionPanel( { isEnabled, isOpened, onTogglePanel } ) { +function DiscussionPanel() { + const { isEnabled, isOpened } = useSelect( ( select ) => { + const { isEditorPanelEnabled, isEditorPanelOpened } = + select( editPostStore ); + return { + isEnabled: isEditorPanelEnabled( PANEL_NAME ), + isOpened: isEditorPanelOpened( PANEL_NAME ), + }; + }, [] ); + + const { toggleEditorPanelOpened } = useDispatch( editPostStore ); + if ( ! isEnabled ) { return null; } @@ -31,7 +41,7 @@ function DiscussionPanel( { isEnabled, isOpened, onTogglePanel } ) { <PanelBody title={ __( 'Discussion' ) } opened={ isOpened } - onToggle={ onTogglePanel } + onToggle={ () => toggleEditorPanelOpened( PANEL_NAME ) } > <PostTypeSupportCheck supportKeys="comments"> <PanelRow> @@ -49,19 +59,4 @@ function DiscussionPanel( { isEnabled, isOpened, onTogglePanel } ) { ); } -export default compose( [ - withSelect( ( select ) => { - return { - isEnabled: - select( editPostStore ).isEditorPanelEnabled( PANEL_NAME ), - isOpened: select( editPostStore ).isEditorPanelOpened( PANEL_NAME ), - }; - } ), - withDispatch( ( dispatch ) => ( { - onTogglePanel() { - return dispatch( editPostStore ).toggleEditorPanelOpened( - PANEL_NAME - ); - }, - } ) ), -] )( DiscussionPanel ); +export default DiscussionPanel; diff --git a/packages/edit-post/src/components/sidebar/plugin-document-setting-panel/index.js b/packages/edit-post/src/components/sidebar/plugin-document-setting-panel/index.js index d83c2061946720..506de5bc907e80 100644 --- a/packages/edit-post/src/components/sidebar/plugin-document-setting-panel/index.js +++ b/packages/edit-post/src/components/sidebar/plugin-document-setting-panel/index.js @@ -1,14 +1,9 @@ -/** - * Defines as extensibility slot for the Settings sidebar - */ - /** * WordPress dependencies */ import { createSlotFill, PanelBody } from '@wordpress/components'; -import { compose } from '@wordpress/compose'; -import { withPluginContext } from '@wordpress/plugins'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { usePluginContext } from '@wordpress/plugins'; +import { useDispatch, useSelect } from '@wordpress/data'; import warning from '@wordpress/warning'; /** @@ -19,47 +14,15 @@ import { store as editPostStore } from '../../../store'; const { Fill, Slot } = createSlotFill( 'PluginDocumentSettingPanel' ); -const PluginDocumentSettingFill = ( { - isEnabled, - panelName, - opened, - onToggle, - className, - title, - icon, - children, -} ) => { - return ( - <> - <EnablePluginDocumentSettingPanelOption - label={ title } - panelName={ panelName } - /> - <Fill> - { isEnabled && ( - <PanelBody - className={ className } - title={ title } - icon={ icon } - opened={ opened } - onToggle={ onToggle } - > - { children } - </PanelBody> - ) } - </Fill> - </> - ); -}; - /** * Renders items below the Status & Availability panel in the Document Sidebar. * * @param {Object} props Component properties. - * @param {string} [props.name] The machine-friendly name for the panel. + * @param {string} props.name Required. A machine-friendly name for the panel. * @param {string} [props.className] An optional class name added to the row. * @param {string} [props.title] The title of the panel * @param {WPBlockTypeIconRender} [props.icon=inherits from the plugin] The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. + * @param {WPElement} props.children Children to be rendered * * @example * ```js @@ -75,6 +38,7 @@ const PluginDocumentSettingFill = ( { * { * className: 'my-document-setting-plugin', * title: 'My Panel', + * name: 'my-panel', * }, * __( 'My Document Setting Panel' ) * ); @@ -92,7 +56,7 @@ const PluginDocumentSettingFill = ( { * import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; * * const MyDocumentSettingTest = () => ( - * <PluginDocumentSettingPanel className="my-document-setting-plugin" title="My Panel"> + * <PluginDocumentSettingPanel className="my-document-setting-plugin" title="My Panel" name="my-panel"> * <p>My Document Setting Panel</p> * </PluginDocumentSettingPanel> * ); @@ -102,30 +66,55 @@ const PluginDocumentSettingFill = ( { * * @return {WPComponent} The component to be rendered. */ -const PluginDocumentSettingPanel = compose( - withPluginContext( ( context, ownProps ) => { - if ( undefined === ownProps.name ) { - warning( 'PluginDocumentSettingPanel requires a name property.' ); - } - return { - panelName: `${ context.name }/${ ownProps.name }`, - }; - } ), - withSelect( ( select, { panelName } ) => { - return { - opened: select( editPostStore ).isEditorPanelOpened( panelName ), - isEnabled: - select( editPostStore ).isEditorPanelEnabled( panelName ), - }; - } ), - withDispatch( ( dispatch, { panelName } ) => ( { - onToggle() { - return dispatch( editPostStore ).toggleEditorPanelOpened( - panelName - ); +const PluginDocumentSettingPanel = ( { + name, + className, + title, + icon, + children, +} ) => { + const { name: pluginName } = usePluginContext(); + const panelName = `${ pluginName }/${ name }`; + const { opened, isEnabled } = useSelect( + ( select ) => { + const { isEditorPanelOpened, isEditorPanelEnabled } = + select( editPostStore ); + + return { + opened: isEditorPanelOpened( panelName ), + isEnabled: isEditorPanelEnabled( panelName ), + }; }, - } ) ) -)( PluginDocumentSettingFill ); + [ panelName ] + ); + const { toggleEditorPanelOpened } = useDispatch( editPostStore ); + + if ( undefined === name ) { + warning( 'PluginDocumentSettingPanel requires a name property.' ); + } + + return ( + <> + <EnablePluginDocumentSettingPanelOption + label={ title } + panelName={ panelName } + /> + <Fill> + { isEnabled && ( + <PanelBody + className={ className } + title={ title } + icon={ icon } + opened={ opened } + onToggle={ () => toggleEditorPanelOpened( panelName ) } + > + { children } + </PanelBody> + ) } + </Fill> + </> + ); +}; PluginDocumentSettingPanel.Slot = Slot; diff --git a/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/index.js b/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/index.js index aa697e32cf96ec..f355cacd763084 100644 --- a/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/index.js +++ b/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/index.js @@ -1,30 +1,11 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; -import { withPluginContext } from '@wordpress/plugins'; +import { usePluginContext } from '@wordpress/plugins'; import { createSlotFill, PanelBody } from '@wordpress/components'; const { Fill, Slot } = createSlotFill( 'PluginPostPublishPanel' ); -const PluginPostPublishPanelFill = ( { - children, - className, - title, - initialOpen = false, - icon, -} ) => ( - <Fill> - <PanelBody - className={ className } - initialOpen={ initialOpen || ! title } - title={ title } - icon={ icon } - > - { children } - </PanelBody> - </Fill> -); /** * Renders provided content to the post-publish panel in the publish flow * (side panel that opens after a user publishes the post). @@ -34,6 +15,7 @@ const PluginPostPublishPanelFill = ( { * @param {string} [props.title] Title displayed at the top of the panel. * @param {boolean} [props.initialOpen=false] Whether to have the panel initially opened. When no title is provided it is always opened. * @param {WPBlockTypeIconRender} [props.icon=inherits from the plugin] The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. + * @param {WPElement} props.children Children to be rendered * * @example * ```js @@ -73,14 +55,28 @@ const PluginPostPublishPanelFill = ( { * * @return {WPComponent} The component to be rendered. */ +const PluginPostPublishPanel = ( { + children, + className, + title, + initialOpen = false, + icon, +} ) => { + const { icon: pluginIcon } = usePluginContext(); -const PluginPostPublishPanel = compose( - withPluginContext( ( context, ownProps ) => { - return { - icon: ownProps.icon || context.icon, - }; - } ) -)( PluginPostPublishPanelFill ); + return ( + <Fill> + <PanelBody + className={ className } + initialOpen={ initialOpen || ! title } + title={ title } + icon={ icon ?? pluginIcon } + > + { children } + </PanelBody> + </Fill> + ); +}; PluginPostPublishPanel.Slot = Slot; diff --git a/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/index.js b/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/index.js index b5912df3d498c9..91392ab7883ed5 100644 --- a/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/index.js +++ b/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/index.js @@ -2,28 +2,9 @@ * WordPress dependencies */ import { createSlotFill, PanelBody } from '@wordpress/components'; -import { compose } from '@wordpress/compose'; -import { withPluginContext } from '@wordpress/plugins'; -const { Fill, Slot } = createSlotFill( 'PluginPrePublishPanel' ); +import { usePluginContext } from '@wordpress/plugins'; -const PluginPrePublishPanelFill = ( { - children, - className, - title, - initialOpen = false, - icon, -} ) => ( - <Fill> - <PanelBody - className={ className } - initialOpen={ initialOpen || ! title } - title={ title } - icon={ icon } - > - { children } - </PanelBody> - </Fill> -); +const { Fill, Slot } = createSlotFill( 'PluginPrePublishPanel' ); /** * Renders provided content to the pre-publish side panel in the publish flow @@ -37,6 +18,7 @@ const PluginPrePublishPanelFill = ( { * @param {WPBlockTypeIconRender} [props.icon=inherits from the plugin] The [Dashicon](https://developer.wordpress.org/resource/dashicons/) * icon slug string, or an SVG WP element, to be rendered when * the sidebar is pinned to toolbar. + * @param {WPElement} props.children Children to be rendered * * @example * ```js @@ -76,13 +58,28 @@ const PluginPrePublishPanelFill = ( { * * @return {WPComponent} The component to be rendered. */ -const PluginPrePublishPanel = compose( - withPluginContext( ( context, ownProps ) => { - return { - icon: ownProps.icon || context.icon, - }; - } ) -)( PluginPrePublishPanelFill ); +const PluginPrePublishPanel = ( { + children, + className, + title, + initialOpen = false, + icon, +} ) => { + const { icon: pluginIcon } = usePluginContext(); + + return ( + <Fill> + <PanelBody + className={ className } + initialOpen={ initialOpen || ! title } + title={ title } + icon={ icon ?? pluginIcon } + > + { children } + </PanelBody> + </Fill> + ); +}; PluginPrePublishPanel.Slot = Slot; diff --git a/packages/edit-post/src/components/sidebar/post-schedule/style.scss b/packages/edit-post/src/components/sidebar/post-schedule/style.scss index 95b7ddeff6c08d..056c2e68dbb5f1 100644 --- a/packages/edit-post/src/components/sidebar/post-schedule/style.scss +++ b/packages/edit-post/src/components/sidebar/post-schedule/style.scss @@ -2,6 +2,7 @@ width: 100%; position: relative; justify-content: flex-start; + align-items: flex-start; span { display: block; @@ -15,6 +16,7 @@ .components-button.edit-post-post-schedule__toggle { text-align: left; white-space: normal; + height: auto; // This span is added by the Popover in Tooltip when no anchor is // provided. We set its width to 0 so that it does not cause the button text diff --git a/packages/edit-post/src/components/sidebar/post-status/index.js b/packages/edit-post/src/components/sidebar/post-status/index.js index 8304bb8b4f6ea4..d0633f26cf254e 100644 --- a/packages/edit-post/src/components/sidebar/post-status/index.js +++ b/packages/edit-post/src/components/sidebar/post-status/index.js @@ -8,7 +8,7 @@ import { } from '@wordpress/components'; import { withSelect, withDispatch } from '@wordpress/data'; import { compose, ifCondition } from '@wordpress/compose'; -import { PostSwitchToDraftButton } from '@wordpress/editor'; +import { PostSwitchToDraftButton, PostSyncStatus } from '@wordpress/editor'; /** * Internal dependencies @@ -51,12 +51,14 @@ function PostStatus( { isOpened, onTogglePanel } ) { <PostFormat /> <PostSlug /> <PostAuthor /> + <PostSyncStatus /> { fills } <HStack style={ { marginTop: '16px', } } spacing={ 4 } + wrap > <PostSwitchToDraftButton /> <PostTrash /> diff --git a/packages/edit-post/src/components/sidebar/post-taxonomies/taxonomy-panel.js b/packages/edit-post/src/components/sidebar/post-taxonomies/taxonomy-panel.js index 9e254003c8cf1e..88c4366d1cfe8a 100644 --- a/packages/edit-post/src/components/sidebar/post-taxonomies/taxonomy-panel.js +++ b/packages/edit-post/src/components/sidebar/post-taxonomies/taxonomy-panel.js @@ -1,22 +1,30 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; import { PanelBody } from '@wordpress/components'; -import { withSelect, withDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies */ import { store as editPostStore } from '../../../store'; -function TaxonomyPanel( { - isEnabled, - taxonomy, - isOpened, - onTogglePanel, - children, -} ) { +function TaxonomyPanel( { taxonomy, children } ) { + const slug = taxonomy?.slug; + const panelName = slug ? `taxonomy-panel-${ slug }` : ''; + const { isEnabled, isOpened } = useSelect( + ( select ) => { + const { isEditorPanelEnabled, isEditorPanelOpened } = + select( editPostStore ); + return { + isEnabled: slug ? isEditorPanelEnabled( panelName ) : false, + isOpened: slug ? isEditorPanelOpened( panelName ) : false, + }; + }, + [ panelName, slug ] + ); + const { toggleEditorPanelOpened } = useDispatch( editPostStore ); + if ( ! isEnabled ) { return null; } @@ -30,32 +38,11 @@ function TaxonomyPanel( { <PanelBody title={ taxonomyMenuName } opened={ isOpened } - onToggle={ onTogglePanel } + onToggle={ () => toggleEditorPanelOpened( panelName ) } > { children } </PanelBody> ); } -export default compose( - withSelect( ( select, ownProps ) => { - const slug = ownProps.taxonomy?.slug; - const panelName = slug ? `taxonomy-panel-${ slug }` : ''; - return { - panelName, - isEnabled: slug - ? select( editPostStore ).isEditorPanelEnabled( panelName ) - : false, - isOpened: slug - ? select( editPostStore ).isEditorPanelOpened( panelName ) - : false, - }; - } ), - withDispatch( ( dispatch, ownProps ) => ( { - onTogglePanel: () => { - dispatch( editPostStore ).toggleEditorPanelOpened( - ownProps.panelName - ); - }, - } ) ) -)( TaxonomyPanel ); +export default TaxonomyPanel; diff --git a/packages/edit-post/src/components/sidebar/post-trash/index.js b/packages/edit-post/src/components/sidebar/post-trash/index.js index 885be537952c0b..d77c7a6d82988c 100644 --- a/packages/edit-post/src/components/sidebar/post-trash/index.js +++ b/packages/edit-post/src/components/sidebar/post-trash/index.js @@ -2,14 +2,11 @@ * WordPress dependencies */ import { PostTrash as PostTrashLink, PostTrashCheck } from '@wordpress/editor'; -import { FlexItem } from '@wordpress/components'; export default function PostTrash() { return ( <PostTrashCheck> - <FlexItem isBlock> - <PostTrashLink /> - </FlexItem> + <PostTrashLink /> </PostTrashCheck> ); } diff --git a/packages/edit-post/src/components/start-page-options/index.js b/packages/edit-post/src/components/start-page-options/index.js index e1a475bc59aa3c..8eafa5487b718f 100644 --- a/packages/edit-post/src/components/start-page-options/index.js +++ b/packages/edit-post/src/components/start-page-options/index.js @@ -19,7 +19,7 @@ import { store as editPostStore } from '../../store'; function useStartPatterns() { // A pattern is a start pattern if it includes 'core/post-content' in its blockTypes, - // and it has no postTypes declares and the current post type is page or if + // and it has no postTypes declared and the current post type is page or if // the current post type is part of the postTypes declared. const { blockPatternsWithPostContentBlockType, postType } = useSelect( ( select ) => { @@ -47,8 +47,7 @@ function useStartPatterns() { }, [ postType, blockPatternsWithPostContentBlockType ] ); } -function PatternSelection( { onChoosePattern } ) { - const blockPatterns = useStartPatterns(); +function PatternSelection( { blockPatterns, onChoosePattern } ) { const shownBlockPatterns = useAsyncList( blockPatterns ); const { resetEditorBlocks } = useDispatch( editorStore ); return ( @@ -63,70 +62,55 @@ function PatternSelection( { onChoosePattern } ) { ); } -const START_PAGE_MODAL_STATES = { - INITIAL: 'INITIAL', - PATTERN: 'PATTERN', - CLOSED: 'CLOSED', -}; - -export default function StartPageOptions() { - const [ modalState, setModalState ] = useState( - START_PAGE_MODAL_STATES.INITIAL - ); - const blockPatterns = useStartPatterns(); - const hasStartPattern = blockPatterns.length > 0; - const shouldOpenModel = useSelect( - ( select ) => { - if ( - ! hasStartPattern || - modalState !== START_PAGE_MODAL_STATES.INITIAL - ) { - return false; - } - const { getEditedPostContent, isEditedPostSaveable } = - select( editorStore ); - const { isEditingTemplate, isFeatureActive } = - select( editPostStore ); - return ( - ! isEditedPostSaveable() && - '' === getEditedPostContent() && - ! isEditingTemplate() && - ! isFeatureActive( 'welcomeGuide' ) - ); - }, - [ modalState, hasStartPattern ] - ); +function StartPageOptionsModal() { + const [ modalState, setModalState ] = useState( 'initial' ); + const startPatterns = useStartPatterns(); + const hasStartPattern = startPatterns.length > 0; + const shouldOpenModal = hasStartPattern && modalState === 'initial'; useEffect( () => { - if ( shouldOpenModel ) { - setModalState( START_PAGE_MODAL_STATES.PATTERN ); + if ( shouldOpenModal ) { + setModalState( 'open' ); } - }, [ shouldOpenModel ] ); + }, [ shouldOpenModal ] ); - if ( - modalState === START_PAGE_MODAL_STATES.INITIAL || - modalState === START_PAGE_MODAL_STATES.CLOSED - ) { + if ( modalState !== 'open' ) { return null; } + return ( <Modal className="edit-post-start-page-options__modal" title={ __( 'Choose a pattern' ) } - isFullScreen={ true } - onRequestClose={ () => { - setModalState( START_PAGE_MODAL_STATES.CLOSED ); - } } + isFullScreen + onRequestClose={ () => setModalState( 'closed' ) } > <div className="edit-post-start-page-options__modal-content"> - { modalState === START_PAGE_MODAL_STATES.PATTERN && ( - <PatternSelection - onChoosePattern={ () => { - setModalState( START_PAGE_MODAL_STATES.CLOSED ); - } } - /> - ) } + <PatternSelection + blockPatterns={ startPatterns } + onChoosePattern={ () => setModalState( 'closed' ) } + /> </div> </Modal> ); } + +export default function StartPageOptions() { + const shouldEnableModal = useSelect( ( select ) => { + const { getEditedPostContent, isEditedPostSaveable } = + select( editorStore ); + const { isEditingTemplate, isFeatureActive } = select( editPostStore ); + return ( + ! isEditedPostSaveable() && + '' === getEditedPostContent() && + ! isEditingTemplate() && + ! isFeatureActive( 'welcomeGuide' ) + ); + }, [] ); + + if ( ! shouldEnableModal ) { + return null; + } + + return <StartPageOptionsModal />; +} diff --git a/packages/edit-post/src/components/start-page-options/style.scss b/packages/edit-post/src/components/start-page-options/style.scss index 0e26c93e386374..52f3f5ff9a8889 100644 --- a/packages/edit-post/src/components/start-page-options/style.scss +++ b/packages/edit-post/src/components/start-page-options/style.scss @@ -3,9 +3,6 @@ column-count: 2; column-gap: $grid-unit-30; - // Small top padding required to avoid cutting off the visible outline when hovering items - padding-top: $border-width-focus-fallback; - @include break-medium() { column-count: 3; } diff --git a/packages/edit-post/src/components/text-editor/index.js b/packages/edit-post/src/components/text-editor/index.js index 9c9b3c29e99481..b4b2ad64133a82 100644 --- a/packages/edit-post/src/components/text-editor/index.js +++ b/packages/edit-post/src/components/text-editor/index.js @@ -4,7 +4,6 @@ import { PostTextEditor, PostTitle, - TextEditorGlobalKeyboardShortcuts, store as editorStore, } from '@wordpress/editor'; import { Button } from '@wordpress/components'; @@ -25,7 +24,6 @@ export default function TextEditor() { return ( <div className="edit-post-text-editor"> - <TextEditorGlobalKeyboardShortcuts /> { isRichEditingEnabled && ( <div className="edit-post-text-editor__toolbar"> <h2>{ __( 'Editing code' ) }</h2> diff --git a/packages/edit-post/src/components/visual-editor/header.native.js b/packages/edit-post/src/components/visual-editor/header.native.js index 20ed9ae5d8164b..f7679e531ec9ea 100644 --- a/packages/edit-post/src/components/visual-editor/header.native.js +++ b/packages/edit-post/src/components/visual-editor/header.native.js @@ -16,23 +16,9 @@ import { useEditorWrapperStyles, } from '@wordpress/block-editor'; -/** - * Internal dependencies - */ -import styles from './style.scss'; - const Header = memo( - function EditorHeader( { - editTitle, - setTitleRef, - title, - getStylesFromColorScheme, - } ) { + function EditorHeader( { editTitle, setTitleRef, title } ) { const [ wrapperStyles ] = useEditorWrapperStyles(); - const blockHolderFocusedStyle = getStylesFromColorScheme( - styles.blockHolderFocused, - styles.blockHolderFocusedDark - ); return ( <View style={ wrapperStyles }> <PostTitle @@ -40,8 +26,6 @@ const Header = memo( title={ title } onUpdate={ editTitle } placeholder={ __( 'Add title' ) } - borderStyle={ styles.blockHolderFullBordered } - focusedBorderColor={ blockHolderFocusedStyle.borderColor } accessibilityLabel="post-title" /> </View> diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index ac8902f6a5f7a1..fb764b967e67d5 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -6,11 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - VisualEditorGlobalKeyboardShortcuts, - PostTitle, - store as editorStore, -} from '@wordpress/editor'; +import { PostTitle, store as editorStore } from '@wordpress/editor'; import { WritingFlow, BlockList, @@ -23,26 +19,27 @@ import { __experimentalUseResizeCanvas as useResizeCanvas, __unstableEditorStyles as EditorStyles, useSetting, - __experimentalLayoutStyle as LayoutStyle, __unstableUseMouseMoveTypingReset as useMouseMoveTypingReset, __unstableIframe as Iframe, __experimentalRecursionProvider as RecursionProvider, - __experimentaluseLayoutClasses as useLayoutClasses, - __experimentaluseLayoutStyles as useLayoutStyles, + privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { useEffect, useRef, useMemo } from '@wordpress/element'; -import { Button, __unstableMotion as motion } from '@wordpress/components'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { __unstableMotion as motion } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; import { useMergeRefs } from '@wordpress/compose'; -import { arrowLeft } from '@wordpress/icons'; -import { __ } from '@wordpress/i18n'; -import { parse } from '@wordpress/blocks'; +import { parse, store as blocksStore } from '@wordpress/blocks'; import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ import { store as editPostStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +const { LayoutStyle, useLayoutClasses, useLayoutStyles } = unlock( + blockEditorPrivateApis +); const isGutenbergPlugin = process.env.IS_GUTENBERG_PLUGIN ? true : false; @@ -114,6 +111,7 @@ export default function VisualEditor( { styles } ) { wrapperBlockName, wrapperUniqueId, isBlockBasedTheme, + hasV3BlocksOnly, } = useSelect( ( select ) => { const { isFeatureActive, @@ -123,6 +121,7 @@ export default function VisualEditor( { styles } ) { } = select( editPostStore ); const { getCurrentPostId, getCurrentPostType, getEditorSettings } = select( editorStore ); + const { getBlockTypes } = select( blocksStore ); const _isTemplateMode = isEditingTemplate(); let _wrapperBlockName; @@ -153,6 +152,9 @@ export default function VisualEditor( { styles } ) { wrapperBlockName: _wrapperBlockName, wrapperUniqueId: getCurrentPostId(), isBlockBasedTheme: editorSettings.__unstableIsBlockBasedTheme, + hasV3BlocksOnly: getBlockTypes().every( ( type ) => { + return type.apiVersion >= 3; + } ), }; }, [] ); const { isCleanNewPost } = useSelect( editorStore ); @@ -175,12 +177,11 @@ export default function VisualEditor( { styles } ) { _settings.__experimentalFeatures?.useRootPaddingAwareAlignments, }; }, [] ); - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { setIsEditingTemplate } = useDispatch( editPostStore ); const desktopCanvasStyles = { height: '100%', width: '100%', - margin: 0, + marginLeft: 'auto', + marginRight: 'auto', display: 'flex', flexFlow: 'column', // Default background color so that grey @@ -217,7 +218,6 @@ export default function VisualEditor( { styles } ) { ref, useClipboardHandler(), useTypewriter(), - useTypingObserver(), useBlockSelectionClearer(), ] ); @@ -305,6 +305,7 @@ export default function VisualEditor( { styles } ) { ? postContentLayout : fallbackLayout; + const observeTypingRef = useTypingObserver(); const titleRef = useRef(); useEffect( () => { if ( isWelcomeGuideVisible || ! isCleanNewPost() ) { @@ -334,14 +335,21 @@ export default function VisualEditor( { styles } ) { .is-root-container.alignfull { max-width: none; margin-left: auto; margin-right: auto;} .is-root-container.alignfull:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: none;}`; + const isToBeIframed = + ( ( hasV3BlocksOnly || ( isGutenbergPlugin && isBlockBasedTheme ) ) && + ! hasMetaBoxes ) || + isTemplateMode || + deviceType === 'Tablet' || + deviceType === 'Mobile'; + return ( <BlockTools __unstableContentRef={ ref } className={ classnames( 'edit-post-visual-editor', { 'is-template-mode': isTemplateMode, + 'has-inline-canvas': ! isToBeIframed, } ) } > - <VisualEditorGlobalKeyboardShortcuts /> <motion.div className="edit-post-visual-editor__content-area" animate={ { @@ -349,32 +357,13 @@ export default function VisualEditor( { styles } ) { } } ref={ blockSelectionClearerRef } > - { isTemplateMode && ( - <Button - className="edit-post-visual-editor__exit-template-mode" - icon={ arrowLeft } - onClick={ () => { - clearSelectedBlock(); - setIsEditingTemplate( false ); - } } - > - { __( 'Back' ) } - </Button> - ) } <motion.div animate={ animatedStyles } initial={ desktopCanvasStyles } className={ previewMode } > <MaybeIframe - shouldIframe={ - ( isGutenbergPlugin && - isBlockBasedTheme && - ! hasMetaBoxes ) || - isTemplateMode || - deviceType === 'Tablet' || - deviceType === 'Mobile' - } + shouldIframe={ isToBeIframed } contentRef={ contentRef } styles={ styles } > @@ -383,11 +372,12 @@ export default function VisualEditor( { styles } ) { ! isTemplateMode && ( <> <LayoutStyle - selector=".edit-post-visual-editor__post-title-wrapper, .block-editor-block-list__layout.is-root-container" + selector=".edit-post-visual-editor__post-title-wrapper" layout={ fallbackLayout } - layoutDefinitions={ - globalLayoutSettings?.definitions - } + /> + <LayoutStyle + selector=".block-editor-block-list__layout.is-root-container" + layout={ blockListLayout } /> { align && ( <LayoutStyle css={ alignCSS } /> @@ -396,9 +386,6 @@ export default function VisualEditor( { styles } ) { <LayoutStyle layout={ postContentLayout } css={ postContentLayoutStyles } - layoutDefinitions={ - globalLayoutSettings?.definitions - } /> ) } </> @@ -414,6 +401,7 @@ export default function VisualEditor( { styles } ) { } ) } contentEditable={ false } + ref={ observeTypingRef } > <PostTitle ref={ titleRef } /> </div> @@ -428,7 +416,7 @@ export default function VisualEditor( { styles } ) { ? 'wp-site-blocks' : `${ blockListLayoutClass } wp-block-post-content` // Ensure root level blocks receive default/flow blockGap styling rules. } - __experimentalLayout={ blockListLayout } + layout={ blockListLayout } /> </RecursionProvider> </MaybeIframe> diff --git a/packages/edit-post/src/components/visual-editor/style.native.scss b/packages/edit-post/src/components/visual-editor/style.native.scss deleted file mode 100644 index 7475caa801a2b6..00000000000000 --- a/packages/edit-post/src/components/visual-editor/style.native.scss +++ /dev/null @@ -1,18 +0,0 @@ -.blockHolderFullBordered { - border-top-width: $block-selected-border-width; - border-bottom-width: $block-selected-border-width; - border-left-width: $block-selected-border-width; - border-right-width: $block-selected-border-width; - border-radius: 4px; - border-style: solid; - margin-left: 4px; - margin-right: 4px; -} - -.blockHolderFocused { - border-color: $blue-wordpress; -} - -.blockHolderFocusedDark { - border-color: $blue-30; -} diff --git a/packages/edit-post/src/components/visual-editor/style.scss b/packages/edit-post/src/components/visual-editor/style.scss index e9216f0cf8f2fa..fa61cc9889cf9c 100644 --- a/packages/edit-post/src/components/visual-editor/style.scss +++ b/packages/edit-post/src/components/visual-editor/style.scss @@ -2,7 +2,11 @@ position: relative; display: flex; flex-flow: column; - overflow: hidden; + // In the iframed canvas this keeps extra scrollbars from appearing (when block toolbars overflow). In the + // legacy (non-iframed) canvas, overflow must not be hidden in order to maintain support for sticky positioning. + &:not(.has-inline-canvas) { + overflow: hidden; + } // Gray preview overlay (desktop/tablet/mobile) is intentionally not set on an element with scrolling content like // interface-interface-skeleton__content. This causes graphical glitches (flashes of the background color) @@ -50,19 +54,6 @@ margin-bottom: var(--wp--style--block-gap); } -.edit-post-visual-editor__exit-template-mode { - position: absolute; - top: $grid-unit-10; - left: $grid-unit-10; - color: $white; - - &:active:not([aria-disabled="true"]), - &:focus:not([aria-disabled="true"]), - &:hover { - color: $gray-100; - } -} - .edit-post-visual-editor__content-area { width: 100%; height: 100%; diff --git a/packages/edit-post/src/components/visual-editor/test/__snapshots__/index.native.js.snap b/packages/edit-post/src/components/visual-editor/test/__snapshots__/index.native.js.snap new file mode 100644 index 00000000000000..de7091fa947357 --- /dev/null +++ b/packages/edit-post/src/components/visual-editor/test/__snapshots__/index.native.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`when nothing is selected media buttons and picker display correctly 1`] = ` +"<!-- wp:paragraph --> +<p>First example paragraph.</p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p>Second example paragraph.</p> +<!-- /wp:paragraph --> + +<!-- wp:gallery {"linkTo":"none"} --> +<figure class="wp-block-gallery has-nested-images columns-default is-cropped"></figure> +<!-- /wp:gallery -->" +`; diff --git a/packages/edit-post/src/components/visual-editor/test/index.native.js b/packages/edit-post/src/components/visual-editor/test/index.native.js index af07e4309ab691..8c6e041880a830 100644 --- a/packages/edit-post/src/components/visual-editor/test/index.native.js +++ b/packages/edit-post/src/components/visual-editor/test/index.native.js @@ -1,11 +1,12 @@ /** * External dependencies */ -import { initializeEditor, fireEvent } from 'test/helpers'; +import { initializeEditor, getEditorHtml, fireEvent } from 'test/helpers'; /** * WordPress dependencies */ +import { Platform } from '@wordpress/element'; import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; @@ -21,6 +22,12 @@ afterAll( () => { } ); } ); +const MEDIA_OPTIONS = [ + 'Choose from device', + 'Take a Photo', + 'WordPress Media Library', +]; + const initialHtml = ` <!-- wp:paragraph --> <p>First example paragraph.</p> @@ -63,6 +70,38 @@ describe( 'when title is focused', () => { screen.getAllByLabelText( /Paragraph Block. Row 3/ )[ 0 ] ).toBeDefined(); } ); + + it( 'media blocks should be displayed', async () => { + const screen = await initializeEditor( { + initialHtml, + } ); + + // Focus first block + fireEvent.press( + screen.getAllByLabelText( /Paragraph Block. Row 1/ )[ 0 ] + ); + + // Focus title + fireEvent( + screen.getAllByLabelText( 'Post title. test' )[ 0 ], + 'select' + ); + + // Assert that the media buttons are visible + const imageButton = await screen.findByTestId( 'insert-image-button' ); + expect( imageButton ).toBeVisible(); + + const videoButton = await screen.findByTestId( 'insert-video-button' ); + expect( videoButton ).toBeVisible(); + + const galleryButton = await screen.findByTestId( + 'insert-gallery-button' + ); + expect( galleryButton ).toBeVisible(); + + const audioButton = await screen.findByTestId( 'insert-audio-button' ); + expect( audioButton ).toBeVisible(); + } ); } ); describe( 'when title is no longer focused', () => { @@ -101,4 +140,82 @@ describe( 'when title is no longer focused', () => { screen.getAllByLabelText( /Heading Block. Row 3/ )[ 0 ] ).toBeDefined(); } ); + + it( 'media blocks should not be displayed', async () => { + const screen = await initializeEditor( { + initialHtml, + } ); + + // Focus first block + fireEvent.press( + screen.getAllByLabelText( /Paragraph Block. Row 1/ )[ 0 ] + ); + + // Focus title + fireEvent( + screen.getAllByLabelText( 'Post title. test' )[ 0 ], + 'select' + ); + + // Focus last block + fireEvent.press( + screen.getAllByLabelText( /Paragraph Block. Row 2/ )[ 0 ] + ); + + // Assert that the media buttons are not visible + const imageButton = screen.queryByTestId( 'insert-image-button' ); + expect( imageButton ).toBeNull(); + + const videoButton = screen.queryByTestId( 'insert-video-button' ); + expect( videoButton ).toBeNull(); + + const galleryButton = screen.queryByTestId( 'insert-gallery-button' ); + expect( galleryButton ).toBeNull(); + + const audioButton = screen.queryByTestId( 'insert-audio-button' ); + expect( audioButton ).toBeNull(); + } ); +} ); + +describe( 'when nothing is selected', () => { + it( 'media buttons and picker display correctly', async () => { + const screen = await initializeEditor( { + initialHtml, + } ); + + const { getByText, getByTestId } = screen; + + // Check that the gallery button is visible within the toolbar + const galleryButton = await screen.queryByTestId( + 'insert-gallery-button' + ); + expect( galleryButton ).toBeVisible(); + + // Press the toolbar Gallery button + fireEvent.press( galleryButton ); + + // Expect the block to be created + expect( + screen.getAllByLabelText( /Gallery Block. Row 3/ )[ 0 ] + ).toBeDefined(); + + expect( getByText( 'Choose images' ) ).toBeVisible(); + MEDIA_OPTIONS.forEach( ( option ) => + expect( getByText( option ) ).toBeVisible() + ); + + // Dismiss the picker + if ( Platform.isIOS ) { + fireEvent.press( getByText( 'Cancel' ) ); + } else { + fireEvent( getByTestId( 'media-options-picker' ), 'backdropPress' ); + } + + // Expect the Gallery block to remain + expect( + screen.getAllByLabelText( /Gallery Block. Row 3/ )[ 0 ] + ).toBeDefined(); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index 62e92218e878ea..8f8edf08894453 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -23,19 +23,21 @@ import { privateApis as coreCommandsPrivateApis } from '@wordpress/core-commands import Layout from './components/layout'; import EditorInitialization from './components/editor-initialization'; import { store as editPostStore } from './store'; -import { unlock } from './private-apis'; +import { unlock } from './lock-unlock'; +import useCommonCommands from './hooks/commands/use-common-commands'; const { ExperimentalEditorProvider } = unlock( editorPrivateApis ); const { useCommands } = unlock( coreCommandsPrivateApis ); function Editor( { postId, postType, settings, initialEdits, ...props } ) { useCommands(); + useCommonCommands(); const { + allowRightClickOverrides, hasFixedToolbar, focusMode, isDistractionFree, hasInlineToolbar, - hasThemeStyles, post, preferredStyleVariations, hiddenBlockTypes, @@ -47,7 +49,6 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { ( select ) => { const { isFeatureActive, - __experimentalGetPreviewDeviceType, isEditingTemplate, getEditedPostTemplate, getHiddenBlockTypes, @@ -76,13 +77,13 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { const canEditTemplate = canUser( 'create', 'templates' ); return { - hasFixedToolbar: - isFeatureActive( 'fixedToolbar' ) || - __experimentalGetPreviewDeviceType() !== 'Desktop', + allowRightClickOverrides: isFeatureActive( + 'allowRightClickOverrides' + ), + hasFixedToolbar: isFeatureActive( 'fixedToolbar' ), focusMode: isFeatureActive( 'focusMode' ), isDistractionFree: isFeatureActive( 'distractionFree' ), hasInlineToolbar: isFeatureActive( 'inlineToolbar' ), - hasThemeStyles: isFeatureActive( 'themeStyles' ), preferredStyleVariations: select( preferencesStore ).get( 'core/edit-post', 'preferredStyleVariations' @@ -115,6 +116,7 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { focusMode, isDistractionFree, hasInlineToolbar, + allowRightClickOverrides, // This is marked as experimental to give time for the quick inserter to mature. __experimentalSetIsInserterOpened: setIsInserterOpened, @@ -142,6 +144,7 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { return result; }, [ settings, + allowRightClickOverrides, hasFixedToolbar, hasInlineToolbar, focusMode, @@ -154,25 +157,6 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { keepCaretInsideBlock, ] ); - const styles = useMemo( () => { - const themeStyles = []; - const presetStyles = []; - settings.styles?.forEach( ( style ) => { - if ( ! style.__unstableType || style.__unstableType === 'theme' ) { - themeStyles.push( style ); - } else { - presetStyles.push( style ); - } - } ); - const defaultEditorStyles = [ - ...settings.defaultEditorStyles, - ...presetStyles, - ]; - return hasThemeStyles && themeStyles.length - ? settings.styles - : defaultEditorStyles; - }, [ settings, hasThemeStyles ] ); - if ( ! post ) { return null; } @@ -191,7 +175,7 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { <ErrorBoundary> <CommandMenu /> <EditorInitialization postId={ postId } /> - <Layout styles={ styles } /> + <Layout /> </ErrorBoundary> <PostLockedModal /> </ExperimentalEditorProvider> diff --git a/packages/edit-post/src/hooks/commands/use-common-commands.js b/packages/edit-post/src/hooks/commands/use-common-commands.js new file mode 100644 index 00000000000000..dfb7c82a1029ca --- /dev/null +++ b/packages/edit-post/src/hooks/commands/use-common-commands.js @@ -0,0 +1,217 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { __, isRTL } from '@wordpress/i18n'; +import { + code, + drawerLeft, + drawerRight, + blockDefault, + keyboard, + desktop, + listView, + external, + formatListBullets, +} from '@wordpress/icons'; +import { useCommand } from '@wordpress/commands'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { store as interfaceStore } from '@wordpress/interface'; +import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { KEYBOARD_SHORTCUT_HELP_MODAL_NAME } from '../../components/keyboard-shortcut-help-modal'; +import { PREFERENCES_MODAL_NAME } from '../../components/preferences-modal'; +import { store as editPostStore } from '../../store'; + +export default function useCommonCommands() { + const { + openGeneralSidebar, + closeGeneralSidebar, + switchEditorMode, + setIsListViewOpened, + } = useDispatch( editPostStore ); + const { openModal } = useDispatch( interfaceStore ); + const { + editorMode, + activeSidebar, + isListViewOpen, + isPublishSidebarEnabled, + showBlockBreadcrumbs, + } = useSelect( ( select ) => { + const { getEditorMode, isListViewOpened, isFeatureActive } = + select( editPostStore ); + return { + activeSidebar: select( interfaceStore ).getActiveComplementaryArea( + editPostStore.name + ), + editorMode: getEditorMode(), + isListViewOpen: isListViewOpened(), + isPublishSidebarEnabled: + select( editorStore ).isPublishSidebarEnabled(), + showBlockBreadcrumbs: isFeatureActive( 'showBlockBreadcrumbs' ), + }; + }, [] ); + const { toggle } = useDispatch( preferencesStore ); + const { createInfoNotice } = useDispatch( noticesStore ); + const { __unstableSaveForPreview } = useDispatch( editorStore ); + const { getCurrentPostId } = useSelect( editorStore ); + + useCommand( { + name: 'core/open-settings-sidebar', + label: __( 'Toggle settings sidebar' ), + icon: isRTL() ? drawerLeft : drawerRight, + callback: ( { close } ) => { + close(); + if ( activeSidebar === 'edit-post/document' ) { + closeGeneralSidebar(); + } else { + openGeneralSidebar( 'edit-post/document' ); + } + }, + } ); + + useCommand( { + name: 'core/open-block-inspector', + label: __( 'Toggle block inspector' ), + icon: blockDefault, + callback: ( { close } ) => { + close(); + if ( activeSidebar === 'edit-post/block' ) { + closeGeneralSidebar(); + } else { + openGeneralSidebar( 'edit-post/block' ); + } + }, + } ); + + useCommand( { + name: 'core/toggle-distraction-free', + label: __( 'Toggle distraction free' ), + callback: ( { close } ) => { + toggle( 'core/edit-post', 'distractionFree' ); + close(); + }, + } ); + + useCommand( { + name: 'core/toggle-spotlight-mode', + label: __( 'Toggle spotlight mode' ), + callback: ( { close } ) => { + toggle( 'core/edit-post', 'focusMode' ); + close(); + }, + } ); + + useCommand( { + name: 'core/toggle-fullscreen-mode', + label: __( 'Toggle fullscreen mode' ), + icon: desktop, + callback: ( { close } ) => { + toggle( 'core/edit-post', 'fullscreenMode' ); + close(); + }, + } ); + + useCommand( { + name: 'core/toggle-list-view', + label: __( 'Toggle list view' ), + icon: listView, + callback: ( { close } ) => { + setIsListViewOpened( ! isListViewOpen ); + close(); + }, + } ); + + useCommand( { + name: 'core/toggle-top-toolbar', + label: __( 'Toggle top toolbar' ), + callback: ( { close } ) => { + toggle( 'core/edit-post', 'fixedToolbar' ); + close(); + }, + } ); + + useCommand( { + name: 'core/toggle-code-editor', + label: __( 'Toggle code editor' ), + icon: code, + callback: ( { close } ) => { + switchEditorMode( editorMode === 'visual' ? 'text' : 'visual' ); + close(); + }, + } ); + + useCommand( { + name: 'core/open-preferences', + label: __( 'Editor preferences' ), + callback: () => { + openModal( PREFERENCES_MODAL_NAME ); + }, + } ); + + useCommand( { + name: 'core/open-shortcut-help', + label: __( 'Keyboard shortcuts' ), + icon: keyboard, + callback: () => { + openModal( KEYBOARD_SHORTCUT_HELP_MODAL_NAME ); + }, + } ); + + useCommand( { + name: 'core/toggle-breadcrumbs', + label: showBlockBreadcrumbs + ? __( 'Hide block breadcrumbs' ) + : __( 'Show block breadcrumbs' ), + callback: ( { close } ) => { + toggle( 'core/edit-post', 'showBlockBreadcrumbs' ); + close(); + createInfoNotice( + showBlockBreadcrumbs + ? __( 'Breadcrumbs hidden.' ) + : __( 'Breadcrumbs visible.' ), + { + id: 'core/edit-post/toggle-breadcrumbs/notice', + type: 'snackbar', + } + ); + }, + } ); + + useCommand( { + name: 'core/toggle-publish-sidebar', + label: isPublishSidebarEnabled + ? __( 'Disable pre-publish checklist' ) + : __( 'Enable pre-publish checklist' ), + icon: formatListBullets, + callback: ( { close } ) => { + close(); + toggle( 'core/edit-post', 'isPublishSidebarEnabled' ); + createInfoNotice( + isPublishSidebarEnabled + ? __( 'Pre-publish checklist off.' ) + : __( 'Pre-publish checklist on.' ), + { + id: 'core/edit-post/publish-sidebar/notice', + type: 'snackbar', + } + ); + }, + } ); + + useCommand( { + name: 'core/preview-link', + label: __( 'Preview in a new tab' ), + icon: external, + callback: async ( { close } ) => { + close(); + const postId = getCurrentPostId(); + const link = await __unstableSaveForPreview(); + window.open( link, `wp-preview-${ postId }` ); + }, + } ); +} diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index 156c434e7a3761..9d01f308e1b40f 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -46,6 +46,7 @@ export function initializeEditor( const root = createRoot( target ); dispatch( preferencesStore ).setDefaults( 'core/edit-post', { + allowRightClickOverrides: true, editorMode: 'visual', fixedToolbar: false, fullscreenMode: true, @@ -62,10 +63,14 @@ export function initializeEditor( welcomeGuideTemplate: true, } ); - dispatch( blocksStore ).__experimentalReapplyBlockTypeFilters(); + dispatch( blocksStore ).reapplyBlockTypeFilters(); // Check if the block list view should be open by default. - if ( select( editPostStore ).isFeatureActive( 'showListViewByDefault' ) ) { + // If `distractionFree` mode is enabled, the block list view should not be open. + if ( + select( editPostStore ).isFeatureActive( 'showListViewByDefault' ) && + ! select( editPostStore ).isFeatureActive( 'distractionFree' ) + ) { dispatch( editPostStore ).setIsListViewOpened( true ); } @@ -79,7 +84,7 @@ export function initializeEditor( } /* - * Prevent adding template part and post content block in the post editor. + * Prevent adding template part in the post editor. * Only add the filter when the post editor is initialized, not imported. * Also only add the filter(s) after registerCoreBlocks() * so that common filters in the block library are not overwritten. @@ -90,8 +95,7 @@ export function initializeEditor( ( canInsert, blockType ) => { if ( ! select( editPostStore ).isEditingTemplate() && - ( blockType.name === 'core/template-part' || - blockType.name === 'core/post-content' ) + blockType.name === 'core/template-part' ) { return false; } @@ -99,6 +103,34 @@ export function initializeEditor( } ); + /* + * Prevent adding post content block (except in query block) in the post editor. + * Only add the filter when the post editor is initialized, not imported. + * Also only add the filter(s) after registerCoreBlocks() + * so that common filters in the block library are not overwritten. + */ + addFilter( + 'blockEditor.__unstableCanInsertBlockType', + 'removePostContentFromInserter', + ( + canInsert, + blockType, + rootClientId, + { getBlockParentsByBlockName } + ) => { + if ( + ! select( editPostStore ).isEditingTemplate() && + blockType.name === 'core/post-content' + ) { + return ( + getBlockParentsByBlockName( rootClientId, 'core/query' ) + .length > 0 + ); + } + return canInsert; + } + ); + // Show a console log warning if the browser is not in Standards rendering mode. const documentMode = document.compatMode === 'CSS1Compat' ? 'Standards' : 'Quirks'; diff --git a/packages/edit-post/src/private-apis.js b/packages/edit-post/src/lock-unlock.js similarity index 100% rename from packages/edit-post/src/private-apis.js rename to packages/edit-post/src/lock-unlock.js diff --git a/packages/edit-post/src/plugins/copy-content-menu-item/index.js b/packages/edit-post/src/plugins/copy-content-menu-item/index.js index 3cbaf8697cd36d..60bbbf9c83d6eb 100644 --- a/packages/edit-post/src/plugins/copy-content-menu-item/index.js +++ b/packages/edit-post/src/plugins/copy-content-menu-item/index.js @@ -10,11 +10,11 @@ import { store as editorStore } from '@wordpress/editor'; export default function CopyContentMenuItem() { const { createNotice } = useDispatch( noticesStore ); - const getText = useSelect( - ( select ) => () => - select( editorStore ).getEditedPostAttribute( 'content' ), - [] - ); + const { getEditedPostAttribute } = useSelect( editorStore ); + + function getText() { + return getEditedPostAttribute( 'content' ); + } function onSuccess() { createNotice( 'info', __( 'All content copied.' ), { diff --git a/packages/edit-post/src/plugins/index.js b/packages/edit-post/src/plugins/index.js index b065f618912be2..aa663659acbdc1 100644 --- a/packages/edit-post/src/plugins/index.js +++ b/packages/edit-post/src/plugins/index.js @@ -2,6 +2,9 @@ * WordPress dependencies */ import { MenuItem, VisuallyHidden } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { useSelect } from '@wordpress/data'; import { external } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { registerPlugin } from '@wordpress/plugins'; @@ -15,6 +18,34 @@ import KeyboardShortcutsHelpMenuItem from './keyboard-shortcuts-help-menu-item'; import ToolsMoreMenuGroup from '../components/header/tools-more-menu-group'; import WelcomeGuideMenuItem from './welcome-guide-menu-item'; +function ManagePatternsMenuItem() { + const url = useSelect( ( select ) => { + const { canUser } = select( coreStore ); + const { getEditorSettings } = select( editorStore ); + + const isBlockTheme = getEditorSettings().__unstableIsBlockBasedTheme; + const defaultUrl = addQueryArgs( 'edit.php', { + post_type: 'wp_block', + } ); + const patternsUrl = addQueryArgs( 'site-editor.php', { + path: '/patterns', + } ); + + // The site editor and templates both check whether the user has + // edit_theme_options capabilities. We can leverage that here and not + // display the manage patterns link if the user can't access it. + return canUser( 'read', 'templates' ) && isBlockTheme + ? patternsUrl + : defaultUrl; + }, [] ); + + return ( + <MenuItem role="menuitem" href={ url }> + { __( 'Manage patterns' ) } + </MenuItem> + ); +} + registerPlugin( 'edit-post', { render() { return ( @@ -22,14 +53,7 @@ registerPlugin( 'edit-post', { <ToolsMoreMenuGroup> { ( { onClose } ) => ( <> - <MenuItem - role="menuitem" - href={ addQueryArgs( 'edit.php', { - post_type: 'wp_block', - } ) } - > - { __( 'Manage Reusable blocks' ) } - </MenuItem> + <ManagePatternsMenuItem /> <KeyboardShortcutsHelpMenuItem onSelect={ onClose } /> diff --git a/packages/edit-post/src/plugins/keyboard-shortcuts-help-menu-item/index.js b/packages/edit-post/src/plugins/keyboard-shortcuts-help-menu-item/index.js index 69f7b35c3f1167..930f420a241299 100644 --- a/packages/edit-post/src/plugins/keyboard-shortcuts-help-menu-item/index.js +++ b/packages/edit-post/src/plugins/keyboard-shortcuts-help-menu-item/index.js @@ -5,17 +5,18 @@ import { MenuItem } from '@wordpress/components'; import { withDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { displayShortcut } from '@wordpress/keycodes'; +import { store as interfaceStore } from '@wordpress/interface'; /** * Internal dependencies */ -import { store as editPostStore } from '../../store'; +import { KEYBOARD_SHORTCUT_HELP_MODAL_NAME } from '../../components/keyboard-shortcut-help-modal'; export function KeyboardShortcutsHelpMenuItem( { openModal } ) { return ( <MenuItem onClick={ () => { - openModal( 'edit-post/keyboard-shortcut-help' ); + openModal( KEYBOARD_SHORTCUT_HELP_MODAL_NAME ); } } shortcut={ displayShortcut.access( 'h' ) } > @@ -25,7 +26,7 @@ export function KeyboardShortcutsHelpMenuItem( { openModal } ) { } export default withDispatch( ( dispatch ) => { - const { openModal } = dispatch( editPostStore ); + const { openModal } = dispatch( interfaceStore ); return { openModal, diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js index 90f29f37b145cf..0ee1efb62b02ea 100644 --- a/packages/edit-post/src/store/actions.js +++ b/packages/edit-post/src/store/actions.js @@ -10,6 +10,8 @@ import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as editorStore } from '@wordpress/editor'; +import deprecated from '@wordpress/deprecated'; +import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies @@ -42,27 +44,39 @@ export const closeGeneralSidebar = /** * Returns an action object used in signalling that the user opened a modal. * + * @deprecated since WP 6.3 use `core/interface` store's action with the same name instead. + * + * * @param {string} name A string that uniquely identifies the modal. * * @return {Object} Action object. */ -export function openModal( name ) { - return { - type: 'OPEN_MODAL', - name, +export const openModal = + ( name ) => + ( { registry } ) => { + deprecated( "select( 'core/edit-post' ).openModal( name )", { + since: '6.3', + alternative: "select( 'core/interface').openModal( name )", + } ); + return registry.dispatch( interfaceStore ).openModal( name ); }; -} /** * Returns an action object signalling that the user closed a modal. * + * @deprecated since WP 6.3 use `core/interface` store's action with the same name instead. + * * @return {Object} Action object. */ -export function closeModal() { - return { - type: 'CLOSE_MODAL', +export const closeModal = + () => + ( { registry } ) => { + deprecated( "select( 'core/edit-post' ).closeModal()", { + since: '6.3', + alternative: "select( 'core/interface').closeModal()", + } ); + return registry.dispatch( interfaceStore ).closeModal(); }; -} /** * Returns an action object used in signalling that the user opened the publish @@ -554,33 +568,23 @@ export const initializeMetaBoxes = metaBoxesInitialized = true; - let wasSavingPost = registry.select( editorStore ).isSavingPost(); - let wasAutosavingPost = registry - .select( editorStore ) - .isAutosavingPost(); - - // Save metaboxes when performing a full save on the post. - registry.subscribe( async () => { - const isSavingPost = registry.select( editorStore ).isSavingPost(); - const isAutosavingPost = registry - .select( editorStore ) - .isAutosavingPost(); - - // Save metaboxes on save completion, except for autosaves. - const shouldTriggerMetaboxesSave = - wasSavingPost && - ! wasAutosavingPost && - ! isSavingPost && - select.hasMetaBoxes(); - - // Save current state for next inspection. - wasSavingPost = isSavingPost; - wasAutosavingPost = isAutosavingPost; - - if ( shouldTriggerMetaboxesSave ) { - await dispatch.requestMetaBoxUpdates(); - } - } ); + // Save metaboxes on save completion, except for autosaves. + addFilter( + 'editor.__unstableSavePost', + 'core/edit-post/save-metaboxes', + ( previous, options ) => + previous.then( () => { + if ( options.isAutosave ) { + return; + } + + if ( ! select.hasMetaBoxes() ) { + return; + } + + return dispatch.requestMetaBoxUpdates(); + } ) + ); dispatch( { type: 'META_BOXES_INITIALIZED', diff --git a/packages/edit-post/src/store/reducer.js b/packages/edit-post/src/store/reducer.js index 8a4031ecf9b098..622b2e2667f7fc 100644 --- a/packages/edit-post/src/store/reducer.js +++ b/packages/edit-post/src/store/reducer.js @@ -22,25 +22,6 @@ export function removedPanels( state = [], action ) { return state; } -/** - * Reducer for storing the name of the open modal, or null if no modal is open. - * - * @param {Object} state Previous state. - * @param {Object} action Action object containing the `name` of the modal - * - * @return {Object} Updated state - */ -export function activeModal( state = null, action ) { - switch ( action.type ) { - case 'OPEN_MODAL': - return action.name; - case 'CLOSE_MODAL': - return null; - } - - return state; -} - export function publishSidebarActive( state = false, action ) { switch ( action.type ) { case 'OPEN_PUBLISH_SIDEBAR': @@ -209,7 +190,6 @@ const metaBoxes = combineReducers( { } ); export default combineReducers( { - activeModal, metaBoxes, publishSidebarActive, removedPanels, diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index b84e2b6887431a..570c03d930a7ec 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -15,6 +15,11 @@ import deprecated from '@wordpress/deprecated'; const EMPTY_ARRAY = []; const EMPTY_OBJECT = {}; +const EMPTY_INSERTION_POINT = { + rootClientId: undefined, + insertionIndex: undefined, + filterValue: undefined, +}; /** * Returns the current editing mode. @@ -298,14 +303,22 @@ export const isEditorPanelOpened = createRegistrySelector( /** * Returns true if a modal is active, or false otherwise. * + * @deprecated since WP 6.3 use `core/interface` store's selector with the same name instead. + * * @param {Object} state Global application state. * @param {string} modalName A string that uniquely identifies the modal. * * @return {boolean} Whether the modal is active. */ -export function isModalActive( state, modalName ) { - return state.activeModal === modalName; -} +export const isModalActive = createRegistrySelector( + ( select ) => ( state, modalName ) => { + deprecated( `select( 'core/edit-post' ).isModalActive`, { + since: '6.3', + alternative: `select( 'core/interface' ).isModalActive`, + } ); + return !! select( interfaceStore ).isModalActive( modalName ); + } +); /** * Returns whether the given feature is enabled or not. @@ -464,9 +477,11 @@ export function isInserterOpened( state ) { * @return {Object} The root client ID, index to insert at and starting filter value. */ export function __experimentalGetInsertionPoint( state ) { - const { rootClientId, insertionIndex, filterValue } = - state.blockInserterPanel; - return { rootClientId, insertionIndex, filterValue }; + if ( typeof state.blockInserterPanel === 'boolean' ) { + return EMPTY_INSERTION_POINT; + } + + return state.blockInserterPanel; } /** diff --git a/packages/edit-post/src/store/test/reducer.js b/packages/edit-post/src/store/test/reducer.js index 4be7c7cb3ffd80..a083de9c672868 100644 --- a/packages/edit-post/src/store/test/reducer.js +++ b/packages/edit-post/src/store/test/reducer.js @@ -7,7 +7,6 @@ import deepFreeze from 'deep-freeze'; * Internal dependencies */ import { - activeModal, isSavingMetaBoxes, metaBoxLocations, removedPanels, @@ -18,30 +17,6 @@ import { import { setIsInserterOpened, setIsListViewOpened } from '../actions'; describe( 'state', () => { - describe( 'activeModal', () => { - it( 'should default to null', () => { - const state = activeModal( undefined, {} ); - expect( state ).toBeNull(); - } ); - - it( 'should set the activeModal to the provided name', () => { - const state = activeModal( null, { - type: 'OPEN_MODAL', - name: 'test-modal', - } ); - - expect( state ).toEqual( 'test-modal' ); - } ); - - it( 'should set the activeModal to null', () => { - const state = activeModal( 'test-modal', { - type: 'CLOSE_MODAL', - } ); - - expect( state ).toBeNull(); - } ); - } ); - describe( 'isSavingMetaBoxes', () => { it( 'should return default state', () => { const actual = isSavingMetaBoxes( undefined, {} ); diff --git a/packages/edit-post/src/store/test/selectors.js b/packages/edit-post/src/store/test/selectors.js index 5df8456dd8534d..34c66ed7cf8e67 100644 --- a/packages/edit-post/src/store/test/selectors.js +++ b/packages/edit-post/src/store/test/selectors.js @@ -7,7 +7,6 @@ import deepFreeze from 'deep-freeze'; * Internal dependencies */ import { - isModalActive, hasMetaBoxes, isSavingMetaBoxes, getActiveMetaBoxLocations, @@ -18,32 +17,6 @@ import { } from '../selectors'; describe( 'selectors', () => { - describe( 'isModalActive', () => { - it( 'returns true if the provided name matches the value in the preferences activeModal property', () => { - const state = { - activeModal: 'test-modal', - }; - - expect( isModalActive( state, 'test-modal' ) ).toBe( true ); - } ); - - it( 'returns false if the provided name does not match the preferences activeModal property', () => { - const state = { - activeModal: 'something-else', - }; - - expect( isModalActive( state, 'test-modal' ) ).toBe( false ); - } ); - - it( 'returns false if the preferences activeModal property is null', () => { - const state = { - activeModal: null, - }; - - expect( isModalActive( state, 'test-modal' ) ).toBe( false ); - } ); - } ); - describe( 'isEditorPanelRemoved', () => { it( 'should return false by default', () => { const state = deepFreeze( { diff --git a/packages/edit-post/src/style.scss b/packages/edit-post/src/style.scss index 81a1713943e95f..74f42ca498282c 100644 --- a/packages/edit-post/src/style.scss +++ b/packages/edit-post/src/style.scss @@ -2,7 +2,7 @@ @import "./components/header/style.scss"; @import "./components/header/fullscreen-mode-close/style.scss"; @import "./components/header/header-toolbar/style.scss"; -@import "./components/header/template-title/style.scss"; +@import "./components/header/document-actions/style.scss"; @import "./components/keyboard-shortcut-help-modal/style.scss"; @import "./components/layout/style.scss"; @import "./components/block-manager/style.scss"; @@ -41,13 +41,7 @@ } } -// In order to use mix-blend-mode, this element needs to have an explicitly set background-color -// We scope it to .wp-toolbar to be wp-admin only, to prevent bleed into other implementations -html.wp-toolbar { - background: $white; -} - -body.block-editor-page { +body.js.block-editor-page { @include wp-admin-reset( ".block-editor" ); } diff --git a/packages/edit-post/src/test/editor.native.js b/packages/edit-post/src/test/editor.native.js index 5e8397d22b854f..4911fb5128655c 100644 --- a/packages/edit-post/src/test/editor.native.js +++ b/packages/edit-post/src/test/editor.native.js @@ -1,18 +1,25 @@ /** * External dependencies */ -import { act, render } from 'test/helpers'; +import { + act, + addBlock, + fireEvent, + getBlock, + initializeEditor, + render, + setupCoreBlocks, +} from 'test/helpers'; /** * WordPress dependencies */ -import { registerCoreBlocks } from '@wordpress/block-library'; -import RNReactNativeGutenbergBridge from '@wordpress/react-native-bridge'; +import RNReactNativeGutenbergBridge, { + subscribeParentToggleHTMLMode, +} from '@wordpress/react-native-bridge'; // Force register 'core/editor' store. import { store } from '@wordpress/editor'; // eslint-disable-line no-unused-vars -jest.mock( '../components/layout', () => () => 'Layout' ); - /** * Internal dependencies */ @@ -34,11 +41,9 @@ afterAll( () => { jest.useRealTimers(); } ); -describe( 'Editor', () => { - beforeAll( () => { - registerCoreBlocks(); - } ); +setupCoreBlocks(); +describe( 'Editor', () => { it( 'detects unsupported block and sends hasUnsupportedBlocks true to native', () => { RNReactNativeGutenbergBridge.editorDidMount = jest.fn(); @@ -56,6 +61,27 @@ describe( 'Editor', () => { RNReactNativeGutenbergBridge.editorDidMount ).toHaveBeenCalledWith( [ 'core/notablock' ] ); } ); + + it( 'toggles the editor from Visual to HTML mode', async () => { + // Arrange + let toggleMode; + subscribeParentToggleHTMLMode.mockImplementation( ( callback ) => { + toggleMode = callback; + } ); + const screen = await initializeEditor(); + await addBlock( screen, 'Paragraph' ); + + // Act + const paragraphBlock = getBlock( screen, 'Paragraph' ); + fireEvent.press( paragraphBlock ); + act( () => { + toggleMode(); + } ); + + // Assert + const htmlEditor = screen.getByLabelText( 'html-view-content' ); + expect( htmlEditor ).toBeVisible(); + } ); } ); // Utilities. diff --git a/packages/edit-site/CHANGELOG.md b/packages/edit-site/CHANGELOG.md index 5d4562c160350c..2a904820625175 100644 --- a/packages/edit-site/CHANGELOG.md +++ b/packages/edit-site/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +## 5.17.0 (2023-08-16) + +## 5.16.0 (2023-08-10) + +## 5.15.0 (2023-07-20) + +## 5.14.0 (2023-07-05) + +## 5.13.0 (2023-06-23) + +### Enhancements +- Site editor sidebar: add home template details and controls [#51223](https://github.com/WordPress/gutenberg/pull/51223). +- Site editor sidebar: add footer to template part and ensure nested template areas display [#51669](https://github.com/WordPress/gutenberg/pull/51669). +- Global styles: split styles menus into revisions and other styles actions ([#51318](https://github.com/WordPress/gutenberg/pull/51318)). + +## 5.12.0 (2023-06-07) + ## 5.11.0 (2023-05-24) ## 5.10.0 (2023-05-10) diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 8eba789d5713ed..e0f9ac3dfcb5c3 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-site", - "version": "5.11.0", + "version": "5.17.0", "description": "Edit Site Page module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -43,6 +43,7 @@ "@wordpress/dom": "file:../dom", "@wordpress/editor": "file:../editor", "@wordpress/element": "file:../element", + "@wordpress/escape-html": "file:../escape-html", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", @@ -52,8 +53,10 @@ "@wordpress/keycodes": "file:../keycodes", "@wordpress/media-utils": "file:../media-utils", "@wordpress/notices": "file:../notices", + "@wordpress/patterns": "file:../patterns", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", + "@wordpress/primitives": "file:../primitives", "@wordpress/private-apis": "file:../private-apis", "@wordpress/reusable-blocks": "file:../reusable-blocks", "@wordpress/router": "file:../router", @@ -61,14 +64,18 @@ "@wordpress/url": "file:../url", "@wordpress/viewport": "file:../viewport", "@wordpress/widgets": "file:../widgets", + "@wordpress/wordcount": "file:../wordcount", + "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.9.2", + "deepmerge": "^4.3.0", "downloadjs": "^1.4.7", "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21", + "is-plain-object": "^5.0.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", - "rememo": "^4.0.2" + "rememo": "^4.0.2", + "remove-accents": "^0.5.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/edit-site/src/components/add-new-page/index.js b/packages/edit-site/src/components/add-new-page/index.js new file mode 100644 index 00000000000000..09d2e17ff94d1a --- /dev/null +++ b/packages/edit-site/src/components/add-new-page/index.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +import { + Button, + Modal, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + TextControl, +} from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; + +export default function AddNewPageModal( { onSave, onClose } ) { + const [ isCreatingPage, setIsCreatingPage ] = useState( false ); + const [ title, setTitle ] = useState( '' ); + + const { saveEntityRecord } = useDispatch( coreStore ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( noticesStore ); + + async function createPage( event ) { + event.preventDefault(); + + if ( isCreatingPage ) { + return; + } + setIsCreatingPage( true ); + try { + const newPage = await saveEntityRecord( + 'postType', + 'page', + { + status: 'draft', + title, + slug: title || __( 'No title' ), + }, + { throwOnError: true } + ); + + onSave( newPage ); + + createSuccessNotice( + sprintf( + // translators: %s: Title of the created template e.g: "Category". + __( '"%s" successfully created.' ), + newPage.title?.rendered || title + ), + { + type: 'snackbar', + } + ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while creating the page.' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } finally { + setIsCreatingPage( false ); + } + } + + return ( + <Modal title={ __( 'Draft a new page' ) } onRequestClose={ onClose }> + <form onSubmit={ createPage }> + <VStack spacing={ 3 }> + <TextControl + label={ __( 'Page title' ) } + onChange={ setTitle } + placeholder={ __( 'No title' ) } + value={ title } + /> + <HStack spacing={ 2 } justify="end"> + <Button variant="tertiary" onClick={ onClose }> + { __( 'Cancel' ) } + </Button> + <Button + variant="primary" + type="submit" + isBusy={ isCreatingPage } + aria-disabled={ isCreatingPage } + > + { __( 'Create draft' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> + ); +} diff --git a/packages/edit-site/src/components/add-new-pattern/index.js b/packages/edit-site/src/components/add-new-pattern/index.js new file mode 100644 index 00000000000000..5e0f1626fc8fd6 --- /dev/null +++ b/packages/edit-site/src/components/add-new-pattern/index.js @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import { DropdownMenu } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { plus, symbol, symbolFilled } from '@wordpress/icons'; +import { useSelect } from '@wordpress/data'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns'; + +/** + * Internal dependencies + */ +import CreateTemplatePartModal from '../create-template-part-modal'; +import SidebarButton from '../sidebar-button'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; + +const { useHistory } = unlock( routerPrivateApis ); +const { CreatePatternModal } = unlock( editPatternsPrivateApis ); + +export default function AddNewPattern() { + const history = useHistory(); + const [ showPatternModal, setShowPatternModal ] = useState( false ); + const [ showTemplatePartModal, setShowTemplatePartModal ] = + useState( false ); + const isTemplatePartsMode = useSelect( ( select ) => { + const settings = select( editSiteStore ).getSettings(); + return !! settings.supportsTemplatePartsMode; + }, [] ); + + function handleCreatePattern( { pattern, categoryId } ) { + setShowPatternModal( false ); + + history.push( { + postId: pattern.id, + postType: 'wp_block', + categoryType: 'wp_block', + categoryId, + canvas: 'edit', + } ); + } + + function handleCreateTemplatePart( templatePart ) { + setShowTemplatePartModal( false ); + + // Navigate to the created template part editor. + history.push( { + postId: templatePart.id, + postType: 'wp_template_part', + canvas: 'edit', + } ); + } + + function handleError() { + setShowPatternModal( false ); + setShowTemplatePartModal( false ); + } + + const controls = [ + { + icon: symbol, + onClick: () => setShowPatternModal( true ), + title: __( 'Create pattern' ), + }, + ]; + + // Remove condition when command palette issues are resolved. + // See: https://github.com/WordPress/gutenberg/issues/52154. + if ( ! isTemplatePartsMode ) { + controls.push( { + icon: symbolFilled, + onClick: () => setShowTemplatePartModal( true ), + title: __( 'Create template part' ), + } ); + } + + return ( + <> + <DropdownMenu + controls={ controls } + toggleProps={ { + as: SidebarButton, + } } + icon={ plus } + label={ __( 'Create pattern' ) } + /> + { showPatternModal && ( + <CreatePatternModal + onClose={ () => setShowPatternModal( false ) } + onSuccess={ handleCreatePattern } + onError={ handleError } + /> + ) } + { showTemplatePartModal && ( + <CreateTemplatePartModal + closeModal={ () => setShowTemplatePartModal( false ) } + blocks={ [] } + onCreate={ handleCreateTemplatePart } + onError={ handleError } + /> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/add-new-template/add-custom-generic-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-generic-template-modal-content.js index 9610ad1d4c3a47..345d26a60e659d 100644 --- a/packages/edit-site/src/components/add-new-template/add-custom-generic-template-modal-content.js +++ b/packages/edit-site/src/components/add-new-template/add-custom-generic-template-modal-content.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { kebabCase } from 'lodash'; +import { paramCase as kebabCase } from 'change-case'; /** * WordPress dependencies diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js index 5636ec16e1ac1d..80a20939dec251 100644 --- a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js +++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js @@ -15,13 +15,13 @@ import { __unstableUseCompositeState as useCompositeState, __unstableCompositeItem as CompositeItem, } from '@wordpress/components'; -import { useDebounce } from '@wordpress/compose'; import { useEntityRecords } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies */ +import useDebouncedInput from '../../utils/use-debounced-input'; import { mapToIHasNameAndId } from './utils'; const EMPTY_ARRAY = []; @@ -73,18 +73,6 @@ function SuggestionListItem( { ); } -function useDebouncedInput() { - const [ input, setInput ] = useState( '' ); - const [ debounced, setter ] = useState( '' ); - const setDebounced = useDebounce( setter, 250 ); - useEffect( () => { - if ( debounced !== input ) { - setDebounced( input ); - } - }, [ debounced, input ] ); - return [ input, setInput, debounced ]; -} - function useSearchSuggestions( entityForSuggestions, search ) { const { config } = entityForSuggestions; const query = useMemo( diff --git a/packages/edit-site/src/components/add-new-template/index.js b/packages/edit-site/src/components/add-new-template/index.js index c3ba06f065bb99..ffdf1b89376903 100644 --- a/packages/edit-site/src/components/add-new-template/index.js +++ b/packages/edit-site/src/components/add-new-template/index.js @@ -8,7 +8,6 @@ import { store as coreStore } from '@wordpress/core-data'; * Internal dependencies */ import NewTemplate from './new-template'; -import NewTemplatePart from './new-template-part'; export default function AddNewTemplate( { templateType = 'wp_template', @@ -25,8 +24,6 @@ export default function AddNewTemplate( { if ( templateType === 'wp_template' ) { return <NewTemplate { ...props } postType={ postType } />; - } else if ( templateType === 'wp_template_part' ) { - return <NewTemplatePart { ...props } postType={ postType } />; } return null; diff --git a/packages/edit-site/src/components/add-new-template/new-template-part.js b/packages/edit-site/src/components/add-new-template/new-template-part.js deleted file mode 100644 index e485dc40a87701..00000000000000 --- a/packages/edit-site/src/components/add-new-template/new-template-part.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; -import { Button } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; -import { store as coreStore } from '@wordpress/core-data'; -import { plus } from '@wordpress/icons'; -import { privateApis as routerPrivateApis } from '@wordpress/router'; - -/** - * Internal dependencies - */ -import CreateTemplatePartModal from '../create-template-part-modal'; -import { - useExistingTemplateParts, - getUniqueTemplatePartTitle, - getCleanTemplatePartSlug, -} from '../../utils/template-part-create'; -import { unlock } from '../../private-apis'; - -const { useHistory } = unlock( routerPrivateApis ); - -export default function NewTemplatePart( { - postType, - showIcon = true, - toggleProps, -} ) { - const history = useHistory(); - const [ isModalOpen, setIsModalOpen ] = useState( false ); - const { createErrorNotice } = useDispatch( noticesStore ); - const { saveEntityRecord } = useDispatch( coreStore ); - const existingTemplateParts = useExistingTemplateParts(); - - async function createTemplatePart( { title, area } ) { - if ( ! title ) { - createErrorNotice( __( 'Title is not defined.' ), { - type: 'snackbar', - } ); - return; - } - - try { - const uniqueTitle = getUniqueTemplatePartTitle( - title, - existingTemplateParts - ); - const cleanSlug = getCleanTemplatePartSlug( uniqueTitle ); - - const templatePart = await saveEntityRecord( - 'postType', - 'wp_template_part', - { - slug: cleanSlug, - title: uniqueTitle, - content: '', - area, - }, - { throwOnError: true } - ); - - setIsModalOpen( false ); - - // Navigate to the created template part editor. - history.push( { - postId: templatePart.id, - postType: 'wp_template_part', - canvas: 'edit', - } ); - - // TODO: Add a success notice? - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( - 'An error occurred while creating the template part.' - ); - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - - setIsModalOpen( false ); - } - } - const { as: Toggle = Button, ...restToggleProps } = toggleProps ?? {}; - - return ( - <> - <Toggle - { ...restToggleProps } - onClick={ () => { - setIsModalOpen( true ); - } } - icon={ showIcon ? plus : null } - label={ postType.labels.add_new } - > - { showIcon ? null : postType.labels.add_new } - </Toggle> - { isModalOpen && ( - <CreateTemplatePartModal - closeModal={ () => setIsModalOpen( false ) } - onCreate={ createTemplatePart } - /> - ) } - </> - ); -} diff --git a/packages/edit-site/src/components/add-new-template/new-template.js b/packages/edit-site/src/components/add-new-template/new-template.js index 0a3037603226fc..31ddd754562dbf 100644 --- a/packages/edit-site/src/components/add-new-template/new-template.js +++ b/packages/edit-site/src/components/add-new-template/new-template.js @@ -12,11 +12,32 @@ import { __experimentalGrid as Grid, __experimentalText as Text, __experimentalVStack as VStack, + Flex, + Icon, } from '@wordpress/components'; +import { decodeEntities } from '@wordpress/html-entities'; import { useState } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { plus } from '@wordpress/icons'; +import { + archive, + blockMeta, + calendar, + category, + commentAuthorAvatar, + edit, + home, + layout, + list, + media, + notFound, + page, + plus, + pin, + verse, + search, + tag, +} from '@wordpress/icons'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { privateApis as routerPrivateApis } from '@wordpress/router'; @@ -36,7 +57,7 @@ import { import AddCustomGenericTemplateModalContent from './add-custom-generic-template-modal-content'; import TemplateActionsLoadingScreen from './template-actions-loading-screen'; import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useHistory } = unlock( routerPrivateApis ); @@ -51,32 +72,68 @@ const DEFAULT_TEMPLATE_SLUGS = [ 'category', 'date', 'tag', - 'taxonomy', 'search', '404', ]; -function TemplateListItem( { title, description, onClick } ) { +const TEMPLATE_ICONS = { + 'front-page': home, + home: verse, + single: pin, + page, + archive, + search, + 404: notFound, + index: list, + category, + author: commentAuthorAvatar, + taxonomy: blockMeta, + date: calendar, + tag, + attachment: media, +}; + +function TemplateListItem( { + title, + direction, + className, + description, + icon, + onClick, + children, +} ) { return ( - <Button onClick={ onClick }> - <VStack + <Button + className={ className } + onClick={ onClick } + label={ description } + showTooltip={ !! description } + > + <Flex as="span" spacing={ 2 } - justify="flex-start" + align="center" + justify="center" style={ { width: '100%' } } + direction={ direction } > - <Text - weight={ 500 } - lineHeight={ 1.53846153846 } // 20px + <div className="edit-site-add-new-template__template-icon"> + <Icon icon={ icon } /> + </div> + <VStack + className="edit-site-add-new-template__template-name" + alignment="center" + spacing={ 0 } > - { title } - </Text> - <Text - lineHeight={ 1.53846153846 } // 20px - > - { description } - </Text> - </VStack> + <Text + weight={ 500 } + lineHeight={ 1.53846153846 } // 20px + > + { title } + </Text> + { children } + </VStack> + </Flex> </Button> ); } @@ -104,6 +161,26 @@ export default function NewTemplate( { const { createErrorNotice, createSuccessNotice } = useDispatch( noticesStore ); const { setTemplate } = unlock( useDispatch( editSiteStore ) ); + + const { homeUrl } = useSelect( ( select ) => { + const { + getUnstableBase, // Site index. + } = select( coreStore ); + + return { + homeUrl: getUnstableBase()?.home, + }; + }, [] ); + + const TEMPLATE_SHORT_DESCRIPTIONS = { + 'front-page': homeUrl, + date: sprintf( + // translators: %s: The homepage url. + __( 'E.g. %s' ), + homeUrl + '/' + new Date().getFullYear() + ), + }; + async function createTemplate( template, isWPSuggestion = true ) { if ( isCreatingTemplate ) { return; @@ -140,7 +217,7 @@ export default function NewTemplate( { sprintf( // translators: %s: Title of the created template e.g: "Category". __( '"%s" successfully created.' ), - newTemplate.title?.rendered || title + decodeEntities( newTemplate.title?.rendered || title ) ), { type: 'snackbar', @@ -220,14 +297,25 @@ export default function NewTemplate( { justify="center" className="edit-site-add-new-template__template-list__contents" > + <Flex className="edit-site-add-new-template__template-list__prompt"> + { __( + 'Select what the new template should apply to:' + ) } + </Flex> { missingTemplates.map( ( template ) => { - const { title, description, slug, onClick } = - template; + const { title, slug, onClick } = template; return ( <TemplateListItem key={ slug } title={ title } - description={ description } + direction="column" + className="edit-site-add-new-template__template-button" + description={ + TEMPLATE_SHORT_DESCRIPTIONS[ slug ] + } + icon={ + TEMPLATE_ICONS[ slug ] || layout + } onClick={ () => onClick ? onClick( template ) @@ -238,15 +326,23 @@ export default function NewTemplate( { } ) } <TemplateListItem title={ __( 'Custom template' ) } - description={ __( - 'A custom template can be manually applied to any post or page.' - ) } + direction="row" + className="edit-site-add-new-template__custom-template-button" + icon={ edit } onClick={ () => setModalContent( modalContentMap.customGenericTemplate ) } - /> + > + <Text + lineHeight={ 1.53846153846 } // 20px + > + { __( + 'A custom template can be manually applied to any post or page.' + ) } + </Text> + </TemplateListItem> </Grid> ) } { modalContent === modalContentMap.customTemplate && ( diff --git a/packages/edit-site/src/components/add-new-template/style.scss b/packages/edit-site/src/components/add-new-template/style.scss index c8c2ef9f54a071..b1c2b669e24cef 100644 --- a/packages/edit-site/src/components/add-new-template/style.scss +++ b/packages/edit-site/src/components/add-new-template/style.scss @@ -138,17 +138,39 @@ @include break-large() { width: calc(100% - #{$grid-unit-80 * 2}); } + + .edit-site-add-new-template__template-button, + .edit-site-add-new-template__custom-template-button { + svg { + fill: var(--wp-admin-theme-color); + } + } + + .edit-site-add-new-template__custom-template-button { + .edit-site-add-new-template__template-name { + flex-grow: 1; + align-items: flex-start; + } + } + + .edit-site-add-new-template__template-icon { + padding: $grid-unit-10; + background: rgba(var(--wp-admin-theme-color--rgb), 0.04); + border-radius: 100%; + max-height: $grid-unit-50; + max-width: $grid-unit-50; + } } .edit-site-custom-template-modal__contents, .edit-site-add-new-template__template-list__contents { > .components-button { - padding: $grid-unit-30; + padding: $grid-unit-40; border-radius: $radius-block-ui; display: flex; flex-direction: column; border: $border-width solid $gray-300; - min-height: $grid-unit-80 * 3; + justify-content: center; // Show the boundary of the button, in High Contrast Mode. outline: 1px solid transparent; @@ -183,6 +205,12 @@ } } } + + .edit-site-add-new-template__custom-template-button, + .edit-site-add-new-template__template-list__prompt { + grid-column-start: 1; + grid-column-end: 4; + } } .edit-site-add-new-template__template-list__contents { diff --git a/packages/edit-site/src/components/add-new-template/utils.js b/packages/edit-site/src/components/add-new-template/utils.js index 8de37c1f64632c..1869cdb72f5929 100644 --- a/packages/edit-site/src/components/add-new-template/utils.js +++ b/packages/edit-site/src/components/add-new-template/utils.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { get } from 'lodash'; - /** * WordPress dependencies */ @@ -20,6 +15,14 @@ import { blockMeta, post, archive } from '@wordpress/icons'; * @property {string} name The entity's name. */ +const getValueFromObjectPath = ( object, path ) => { + let value = object; + path.split( '.' ).forEach( ( fieldName ) => { + value = value?.[ fieldName ]; + } ); + return value; +}; + /** * Helper util to map records to add a `name` prop from a * provided path, in order to handle all entities in the same @@ -32,7 +35,7 @@ import { blockMeta, post, archive } from '@wordpress/icons'; export const mapToIHasNameAndId = ( entities, path ) => { return ( entities || [] ).map( ( entity ) => ( { ...entity, - name: decodeEntities( get( entity, path ) ), + name: decodeEntities( getValueFromObjectPath( entity, path ) ), } ) ); }; diff --git a/packages/edit-site/src/components/app/index.js b/packages/edit-site/src/components/app/index.js index 81502f23ebe735..ca1893c3c17750 100644 --- a/packages/edit-site/src/components/app/index.js +++ b/packages/edit-site/src/components/app/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { SlotFillProvider, Popover } from '@wordpress/components'; +import { SlotFillProvider } from '@wordpress/components'; import { UnsavedChangesWarning } from '@wordpress/editor'; import { ShortcutProvider } from '@wordpress/keyboard-shortcuts'; import { store as noticesStore } from '@wordpress/notices'; @@ -15,7 +15,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; */ import Layout from '../layout'; import { GlobalStylesProvider } from '../global-styles/global-styles-provider'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { RouterProvider } = unlock( routerPrivateApis ); @@ -38,7 +38,6 @@ export default function App() { <ShortcutProvider style={ { height: '100%' } }> <SlotFillProvider> <GlobalStylesProvider> - <Popover.Slot /> <UnsavedChangesWarning /> <RouterProvider> <Layout /> diff --git a/packages/edit-site/src/components/block-editor/back-button.js b/packages/edit-site/src/components/block-editor/back-button.js index 5b06937671a6e8..ebe5af44c0250c 100644 --- a/packages/edit-site/src/components/block-editor/back-button.js +++ b/packages/edit-site/src/components/block-editor/back-button.js @@ -9,7 +9,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useLocation, useHistory } = unlock( routerPrivateApis ); @@ -17,9 +17,12 @@ function BackButton() { const location = useLocation(); const history = useHistory(); const isTemplatePart = location.params.postType === 'wp_template_part'; + const isNavigationMenu = location.params.postType === 'wp_navigation'; const previousTemplateId = location.state?.fromTemplateId; - if ( ! isTemplatePart || ! previousTemplateId ) { + const isFocusMode = isTemplatePart || isNavigationMenu; + + if ( ! isFocusMode || ! previousTemplateId ) { return null; } diff --git a/packages/edit-site/src/components/block-editor/editor-canvas.js b/packages/edit-site/src/components/block-editor/editor-canvas.js index be07453d8fc9e9..a8bbe75e0261b5 100644 --- a/packages/edit-site/src/components/block-editor/editor-canvas.js +++ b/packages/edit-site/src/components/block-editor/editor-canvas.js @@ -21,7 +21,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; function EditorCanvas( { enableResizing, settings, children, ...props } ) { @@ -87,7 +87,9 @@ function EditorCanvas( { enableResizing, settings, children, ...props } ) { // which isn't a requirement in auto resize mode. enableResizing ? 'min-height:0!important;' : '' }}body{position:relative; ${ - canvasMode === 'view' ? 'cursor: pointer;' : '' + canvasMode === 'view' + ? 'cursor: pointer; min-height: 100vh;' + : '' }}}` }</style> { children } diff --git a/packages/edit-site/src/components/block-editor/get-block-editor-provider.js b/packages/edit-site/src/components/block-editor/get-block-editor-provider.js new file mode 100644 index 00000000000000..df8185605f13aa --- /dev/null +++ b/packages/edit-site/src/components/block-editor/get-block-editor-provider.js @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import DefaultBlockEditor from './providers/default-block-editor-provider'; +import NavigationBlockEditor from './providers/navigation-block-editor-provider'; + +/** + * Factory to isolate choosing the appropriate block editor + * component to handle a given entity type. + * + * @param {string} entityType the entity type being edited + * @return {JSX.Element} the block editor component to use. + */ +export default function getBlockEditorProvider( entityType ) { + let Provider = null; + + switch ( entityType ) { + case 'wp_navigation': + Provider = NavigationBlockEditor; + break; + case 'wp_template': + case 'wp_template_part': + default: + Provider = DefaultBlockEditor; + break; + } + + return Provider; +} diff --git a/packages/edit-site/src/components/block-editor/index.js b/packages/edit-site/src/components/block-editor/index.js index cc5e7c8d9254df..5bfcdb012d1f7e 100644 --- a/packages/edit-site/src/components/block-editor/index.js +++ b/packages/edit-site/src/components/block-editor/index.js @@ -1,217 +1,41 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { useMemo, useRef } from '@wordpress/element'; -import { useEntityBlockEditor, store as coreStore } from '@wordpress/core-data'; -import { - BlockList, - BlockInspector, - BlockTools, - __unstableUseClipboardHandler as useClipboardHandler, - __unstableUseTypingObserver as useTypingObserver, - BlockEditorKeyboardShortcuts, - store as blockEditorStore, - privateApis as blockEditorPrivateApis, -} from '@wordpress/block-editor'; -import { - useMergeRefs, - useViewportMatch, - useResizeObserver, -} from '@wordpress/compose'; -import { ReusableBlocksMenuItems } from '@wordpress/reusable-blocks'; +import { useSelect } from '@wordpress/data'; +import { BlockInspector } from '@wordpress/block-editor'; +import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns'; /** * Internal dependencies */ -import inserterMediaCategories from './inserter-media-categories'; import TemplatePartConverter from '../template-part-converter'; import { SidebarInspectorFill } from '../sidebar-edit-mode'; import { store as editSiteStore } from '../../store'; -import BackButton from './back-button'; -import ResizableEditor from './resizable-editor'; -import EditorCanvas from './editor-canvas'; -import { unlock } from '../../private-apis'; -import EditorCanvasContainer from '../editor-canvas-container'; - -const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); - -const LAYOUT = { - type: 'default', - // At the root level of the site editor, no alignments should be allowed. - alignments: [], -}; +import SiteEditorCanvas from './site-editor-canvas'; +import getBlockEditorProvider from './get-block-editor-provider'; +import { unlock } from '../../lock-unlock'; +const { PatternsMenuItems } = unlock( editPatternsPrivateApis ); export default function BlockEditor() { - const { setIsInserterOpened } = useDispatch( editSiteStore ); - const { storedSettings, templateType, canvasMode } = useSelect( - ( select ) => { - const { getSettings, getEditedPostType, getCanvasMode } = unlock( - select( editSiteStore ) - ); - - return { - storedSettings: getSettings( setIsInserterOpened ), - templateType: getEditedPostType(), - canvasMode: getCanvasMode(), - }; - }, - [ setIsInserterOpened ] - ); - - const settingsBlockPatterns = - storedSettings.__experimentalAdditionalBlockPatterns ?? // WP 6.0 - storedSettings.__experimentalBlockPatterns; // WP 5.9 - const settingsBlockPatternCategories = - storedSettings.__experimentalAdditionalBlockPatternCategories ?? // WP 6.0 - storedSettings.__experimentalBlockPatternCategories; // WP 5.9 - - const { restBlockPatterns, restBlockPatternCategories } = useSelect( - ( select ) => ( { - restBlockPatterns: select( coreStore ).getBlockPatterns(), - restBlockPatternCategories: - select( coreStore ).getBlockPatternCategories(), - } ), + const entityType = useSelect( + ( select ) => select( editSiteStore ).getEditedPostType(), [] ); - const blockPatterns = useMemo( - () => - [ - ...( settingsBlockPatterns || [] ), - ...( restBlockPatterns || [] ), - ] - .filter( - ( x, index, arr ) => - index === arr.findIndex( ( y ) => x.name === y.name ) - ) - .filter( ( { postTypes } ) => { - return ( - ! postTypes || - ( Array.isArray( postTypes ) && - postTypes.includes( templateType ) ) - ); - } ), - [ settingsBlockPatterns, restBlockPatterns, templateType ] - ); - - const blockPatternCategories = useMemo( - () => - [ - ...( settingsBlockPatternCategories || [] ), - ...( restBlockPatternCategories || [] ), - ].filter( - ( x, index, arr ) => - index === arr.findIndex( ( y ) => x.name === y.name ) - ), - [ settingsBlockPatternCategories, restBlockPatternCategories ] - ); - - const settings = useMemo( () => { - const { - __experimentalAdditionalBlockPatterns, - __experimentalAdditionalBlockPatternCategories, - ...restStoredSettings - } = storedSettings; - - return { - ...restStoredSettings, - inserterMediaCategories, - __experimentalBlockPatterns: blockPatterns, - __experimentalBlockPatternCategories: blockPatternCategories, - }; - }, [ storedSettings, blockPatterns, blockPatternCategories ] ); - - const [ blocks, onInput, onChange ] = useEntityBlockEditor( - 'postType', - templateType - ); - - const contentRef = useRef(); - const mergedRefs = useMergeRefs( [ - contentRef, - useClipboardHandler(), - useTypingObserver(), - ] ); - const isMobileViewport = useViewportMatch( 'small', '<' ); - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const [ resizeObserver, sizes ] = useResizeObserver(); - - const isTemplatePart = templateType === 'wp_template_part'; - - const hasBlocks = blocks.length !== 0; - const enableResizing = - isTemplatePart && - canvasMode !== 'view' && - // Disable resizing in mobile viewport. - ! isMobileViewport; - const isViewMode = canvasMode === 'view'; - const showBlockAppender = - ( isTemplatePart && hasBlocks ) || isViewMode ? false : undefined; + // Choose the provider based on the entity type currently + // being edited. + const BlockEditorProvider = getBlockEditorProvider( entityType ); return ( - <ExperimentalBlockEditorProvider - settings={ settings } - value={ blocks } - onInput={ onInput } - onChange={ onChange } - useSubRegistry={ false } - > + <BlockEditorProvider> <TemplatePartConverter /> <SidebarInspectorFill> <BlockInspector /> </SidebarInspectorFill> - <EditorCanvasContainer.Slot> - { ( [ editorCanvasView ] ) => - editorCanvasView ? ( - <div className="edit-site-visual-editor is-focus-mode"> - { editorCanvasView } - </div> - ) : ( - <BlockTools - className={ classnames( 'edit-site-visual-editor', { - 'is-focus-mode': - isTemplatePart || !! editorCanvasView, - 'is-view-mode': isViewMode, - } ) } - __unstableContentRef={ contentRef } - onClick={ ( event ) => { - // Clear selected block when clicking on the gray background. - if ( event.target === event.currentTarget ) { - clearSelectedBlock(); - } - } } - > - <BlockEditorKeyboardShortcuts.Register /> - <BackButton /> - <ResizableEditor - enableResizing={ enableResizing } - height={ sizes.height ?? '100%' } - > - <EditorCanvas - enableResizing={ enableResizing } - settings={ settings } - contentRef={ mergedRefs } - readonly={ canvasMode === 'view' } - > - { resizeObserver } - <BlockList - className="edit-site-block-editor__block-list wp-site-blocks" - __experimentalLayout={ LAYOUT } - renderAppender={ showBlockAppender } - /> - </EditorCanvas> - </ResizableEditor> - </BlockTools> - ) - } - </EditorCanvasContainer.Slot> - <ReusableBlocksMenuItems /> - </ExperimentalBlockEditorProvider> + + <SiteEditorCanvas /> + + <PatternsMenuItems /> + </BlockEditorProvider> ); } diff --git a/packages/edit-site/src/components/block-editor/inserter-media-categories.js b/packages/edit-site/src/components/block-editor/inserter-media-categories.js index 84bdf35cd26144..af591d1fa24688 100644 --- a/packages/edit-site/src/components/block-editor/inserter-media-categories.js +++ b/packages/edit-site/src/components/block-editor/inserter-media-categories.js @@ -20,27 +20,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** @typedef {import('@wordpress/block-editor').InserterMediaRequest} InserterMediaRequest */ /** @typedef {import('@wordpress/block-editor').InserterMediaItem} InserterMediaItem */ -/** - * Interface for inserter media category labels. - * - * @typedef {Object} InserterMediaCategoryLabels - * @property {string} name General name of the media category. It's used in the inserter media items list. - * @property {string} [search_items='Search'] Label for searching items. Default is ‘Search Posts’ / ‘Search Pages’. - */ -/** - * Interface for inserter media category. - * - * @typedef {Object} InserterMediaCategory - * @property {string} name The name of the media category, that should be unique among all media categories. - * @property {InserterMediaCategoryLabels} labels Labels for the media category. - * @property {('image'|'audio'|'video')} mediaType The media type of the media category. - * @property {(InserterMediaRequest) => Promise<InserterMediaItem[]>} fetch The function to fetch media items for the category. - * @property {(InserterMediaItem) => string} [getReportUrl] If the media category supports reporting media items, this function should return - * the report url for the media item. It accepts the `InserterMediaItem` as an argument. - * @property {boolean} [isExternalResource] If the media category is an external resource, this should be set to true. - * This is used to avoid making a request to the external resource when the user - * opens the inserter for the first time. - */ +/** @typedef {import('@wordpress/block-editor').InserterMediaCategory} InserterMediaCategory */ const getExternalLink = ( url, text ) => `<a ${ getExternalLinkAttributes( url ) }>${ text }</a>`; diff --git a/packages/edit-site/src/components/block-editor/providers/default-block-editor-provider.js b/packages/edit-site/src/components/block-editor/providers/default-block-editor-provider.js new file mode 100644 index 00000000000000..2ee0ae467f8d65 --- /dev/null +++ b/packages/edit-site/src/components/block-editor/providers/default-block-editor-provider.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { useEntityBlockEditor } from '@wordpress/core-data'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../../store'; +import { unlock } from '../../../lock-unlock'; +import useSiteEditorSettings from '../use-site-editor-settings'; + +const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); + +export default function DefaultBlockEditorProvider( { children } ) { + const settings = useSiteEditorSettings(); + + const { templateType } = useSelect( ( select ) => { + const { getEditedPostType } = unlock( select( editSiteStore ) ); + + return { + templateType: getEditedPostType(), + }; + }, [] ); + + const [ blocks, onInput, onChange ] = useEntityBlockEditor( + 'postType', + templateType + ); + + return ( + <ExperimentalBlockEditorProvider + settings={ settings } + value={ blocks } + onInput={ onInput } + onChange={ onChange } + useSubRegistry={ false } + > + { children } + </ExperimentalBlockEditorProvider> + ); +} diff --git a/packages/edit-site/src/components/block-editor/providers/navigation-block-editor-provider.js b/packages/edit-site/src/components/block-editor/providers/navigation-block-editor-provider.js new file mode 100644 index 00000000000000..833ff6b0ef378b --- /dev/null +++ b/packages/edit-site/src/components/block-editor/providers/navigation-block-editor-provider.js @@ -0,0 +1,113 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useMemo, useEffect } from '@wordpress/element'; +import { useEntityId } from '@wordpress/core-data'; +import { + store as blockEditorStore, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { unlock } from '../../../lock-unlock'; +import useSiteEditorSettings from '../use-site-editor-settings'; +import { store as editSiteStore } from '../../../store'; + +const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); + +const noop = () => {}; + +/** + * Block editor component for editing navigation menus. + * + * Note: Navigation entities require a wrapping Navigation block to provide + * them with some basic layout and styling. Therefore we create a "ghost" block + * and provide it will a reference to the navigation entity ID being edited. + * + * In this scenario it is the **block** that handles syncing the entity content + * whereas for other entities this is handled by entity block editor. + * + * @param {number} navigationMenuId the navigation menu ID + * @return {[WPBlock[], Function, Function]} The block array and setters. + */ +export default function NavigationBlockEditorProvider( { children } ) { + const defaultSettings = useSiteEditorSettings(); + + const navigationMenuId = useEntityId( 'postType', 'wp_navigation' ); + + const blocks = useMemo( () => { + return [ + createBlock( 'core/navigation', { + ref: navigationMenuId, + // As the parent editor is locked with `templateLock`, the template locking + // must be explicitly "unset" on the block itself to allow the user to modify + // the block's content. + templateLock: false, + } ), + ]; + }, [ navigationMenuId ] ); + + const { isEditMode } = useSelect( ( select ) => { + const { getCanvasMode } = unlock( select( editSiteStore ) ); + + return { + isEditMode: getCanvasMode() === 'edit', + }; + }, [] ); + + const { selectBlock, setBlockEditingMode, unsetBlockEditingMode } = + useDispatch( blockEditorStore ); + + const navigationBlockClientId = blocks && blocks[ 0 ]?.clientId; + + const settings = useMemo( () => { + return { + ...defaultSettings, + // Lock the editor to allow the root ("ghost") Navigation block only. + templateLock: 'insert', + template: [ [ 'core/navigation', {}, [] ] ], + }; + }, [ defaultSettings ] ); + + // Auto-select the Navigation block when entering Navigation focus mode. + useEffect( () => { + if ( navigationBlockClientId && isEditMode ) { + selectBlock( navigationBlockClientId ); + } + }, [ navigationBlockClientId, isEditMode, selectBlock ] ); + + // Set block editing mode to contentOnly when entering Navigation focus mode. + // This ensures that non-content controls on the block will be hidden and thus + // the user can focus on editing the Navigation Menu content only. + useEffect( () => { + if ( ! navigationBlockClientId ) { + return; + } + + setBlockEditingMode( navigationBlockClientId, 'contentOnly' ); + + return () => { + unsetBlockEditingMode( navigationBlockClientId ); + }; + }, [ + navigationBlockClientId, + unsetBlockEditingMode, + setBlockEditingMode, + ] ); + + return ( + <ExperimentalBlockEditorProvider + settings={ settings } + value={ blocks } + onInput={ noop } + onChange={ noop } + useSubRegistry={ false } + > + { children } + </ExperimentalBlockEditorProvider> + ); +} diff --git a/packages/edit-site/src/components/block-editor/site-editor-canvas.js b/packages/edit-site/src/components/block-editor/site-editor-canvas.js new file mode 100644 index 00000000000000..2fc77e1ee1a403 --- /dev/null +++ b/packages/edit-site/src/components/block-editor/site-editor-canvas.js @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useRef } from '@wordpress/element'; +import { + BlockList, + BlockTools, + __unstableUseClipboardHandler as useClipboardHandler, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { + useMergeRefs, + useViewportMatch, + useResizeObserver, +} from '@wordpress/compose'; +/** + * Internal dependencies + */ +import BackButton from './back-button'; +import ResizableEditor from './resizable-editor'; +import EditorCanvas from './editor-canvas'; +import EditorCanvasContainer from '../editor-canvas-container'; +import useSiteEditorSettings from './use-site-editor-settings'; +import { store as editSiteStore } from '../../store'; +import { FOCUSABLE_ENTITIES } from '../../utils/constants'; +import { unlock } from '../../lock-unlock'; +import PageContentFocusManager from '../page-content-focus-manager'; + +const LAYOUT = { + type: 'default', + // At the root level of the site editor, no alignments should be allowed. + alignments: [], +}; + +export default function SiteEditorCanvas() { + const { clearSelectedBlock } = useDispatch( blockEditorStore ); + + const { templateType, isFocusMode, isViewMode } = useSelect( ( select ) => { + const { getEditedPostType, getCanvasMode } = unlock( + select( editSiteStore ) + ); + + const _templateType = getEditedPostType(); + + return { + templateType: _templateType, + isFocusMode: FOCUSABLE_ENTITIES.includes( _templateType ), + isViewMode: getCanvasMode() === 'view', + }; + }, [] ); + + const [ resizeObserver, sizes ] = useResizeObserver(); + + const settings = useSiteEditorSettings(); + + const { hasBlocks } = useSelect( ( select ) => { + const { getBlockCount } = select( blockEditorStore ); + + const blocks = getBlockCount(); + + return { + hasBlocks: !! blocks, + }; + }, [] ); + + const isMobileViewport = useViewportMatch( 'small', '<' ); + const enableResizing = + isFocusMode && + ! isViewMode && + // Disable resizing in mobile viewport. + ! isMobileViewport; + + const contentRef = useRef(); + const mergedRefs = useMergeRefs( [ contentRef, useClipboardHandler() ] ); + + const isTemplateTypeNavigation = templateType === 'wp_navigation'; + + const isNavigationFocusMode = isTemplateTypeNavigation && isFocusMode; + + // Hide the appender when: + // - In navigation focus mode (should only allow the root Nav block). + // - In view mode (i.e. not editing). + const showBlockAppender = + ( isNavigationFocusMode && hasBlocks ) || isViewMode + ? false + : undefined; + + const forceFullHeight = isNavigationFocusMode; + + return ( + <> + <EditorCanvasContainer.Slot> + { ( [ editorCanvasView ] ) => + editorCanvasView ? ( + <div className="edit-site-visual-editor is-focus-mode"> + { editorCanvasView } + </div> + ) : ( + <BlockTools + className={ classnames( 'edit-site-visual-editor', { + 'is-focus-mode': + isFocusMode || !! editorCanvasView, + 'is-view-mode': isViewMode, + } ) } + __unstableContentRef={ contentRef } + onClick={ ( event ) => { + // Clear selected block when clicking on the gray background. + if ( event.target === event.currentTarget ) { + clearSelectedBlock(); + } + } } + > + <BackButton /> + <ResizableEditor + enableResizing={ enableResizing } + height={ + sizes.height && ! forceFullHeight + ? sizes.height + : '100%' + } + > + <EditorCanvas + enableResizing={ enableResizing } + settings={ settings } + contentRef={ mergedRefs } + readonly={ isViewMode } + > + { resizeObserver } + <BlockList + className={ classnames( + 'edit-site-block-editor__block-list wp-site-blocks', + { + 'is-navigation-block': + isTemplateTypeNavigation, + } + ) } + layout={ LAYOUT } + renderAppender={ showBlockAppender } + /> + </EditorCanvas> + </ResizableEditor> + </BlockTools> + ) + } + </EditorCanvasContainer.Slot> + <PageContentFocusManager contentRef={ contentRef } /> + </> + ); +} diff --git a/packages/edit-site/src/components/block-editor/style.scss b/packages/edit-site/src/components/block-editor/style.scss index f974f6d914b166..dce224998c0c07 100644 --- a/packages/edit-site/src/components/block-editor/style.scss +++ b/packages/edit-site/src/components/block-editor/style.scss @@ -12,6 +12,12 @@ } } +// Navigation focus mode requires padding around the root Navigation block +// for presentational purposes. +.edit-site-block-editor__block-list.is-navigation-block { + padding: $grid-unit-30; +} + .edit-site-visual-editor { position: relative; height: 100%; diff --git a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js new file mode 100644 index 00000000000000..d70a88e8215c02 --- /dev/null +++ b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js @@ -0,0 +1,186 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; +import { unlock } from '../../lock-unlock'; +import inserterMediaCategories from './inserter-media-categories'; + +function useArchiveLabel( templateSlug ) { + const taxonomyMatches = templateSlug?.match( + /^(category|tag|taxonomy-([^-]+))$|^(((category|tag)|taxonomy-([^-]+))-(.+))$/ + ); + let taxonomy; + let term; + let isAuthor = false; + let authorSlug; + if ( taxonomyMatches ) { + // If is for a all taxonomies of a type + if ( taxonomyMatches[ 1 ] ) { + taxonomy = taxonomyMatches[ 2 ] + ? taxonomyMatches[ 2 ] + : taxonomyMatches[ 1 ]; + } + // If is for a all taxonomies of a type + else if ( taxonomyMatches[ 3 ] ) { + taxonomy = taxonomyMatches[ 6 ] + ? taxonomyMatches[ 6 ] + : taxonomyMatches[ 4 ]; + term = taxonomyMatches[ 7 ]; + } + taxonomy = taxonomy === 'tag' ? 'post_tag' : taxonomy; + + //getTaxonomy( 'category' ); + //wp.data.select('core').getEntityRecords( 'taxonomy', 'category', {slug: 'newcat'} ); + } else { + const authorMatches = templateSlug?.match( /^(author)$|^author-(.+)$/ ); + if ( authorMatches ) { + isAuthor = true; + if ( authorMatches[ 2 ] ) { + authorSlug = authorMatches[ 2 ]; + } + } + } + return useSelect( + ( select ) => { + const { getEntityRecords, getTaxonomy, getAuthors } = + select( coreStore ); + let archiveTypeLabel; + let archiveNameLabel; + if ( taxonomy ) { + archiveTypeLabel = + getTaxonomy( taxonomy )?.labels?.singular_name; + } + if ( term ) { + const records = getEntityRecords( 'taxonomy', taxonomy, { + slug: term, + per_page: 1, + } ); + if ( records && records[ 0 ] ) { + archiveNameLabel = records[ 0 ].name; + } + } + if ( isAuthor ) { + archiveTypeLabel = 'Author'; + if ( authorSlug ) { + const authorRecords = getAuthors( { slug: authorSlug } ); + if ( authorRecords && authorRecords[ 0 ] ) { + archiveNameLabel = authorRecords[ 0 ].name; + } + } + } + return { + archiveTypeLabel, + archiveNameLabel, + }; + }, + [ authorSlug, isAuthor, taxonomy, term ] + ); +} + +export default function useSiteEditorSettings() { + const { setIsInserterOpened } = useDispatch( editSiteStore ); + const { storedSettings, canvasMode, templateType } = useSelect( + ( select ) => { + const { getSettings, getCanvasMode, getEditedPostType } = unlock( + select( editSiteStore ) + ); + return { + storedSettings: getSettings( setIsInserterOpened ), + canvasMode: getCanvasMode(), + templateType: getEditedPostType(), + }; + }, + [ setIsInserterOpened ] + ); + + const settingsBlockPatterns = + storedSettings.__experimentalAdditionalBlockPatterns ?? // WP 6.0 + storedSettings.__experimentalBlockPatterns; // WP 5.9 + const settingsBlockPatternCategories = + storedSettings.__experimentalAdditionalBlockPatternCategories ?? // WP 6.0 + storedSettings.__experimentalBlockPatternCategories; // WP 5.9 + + const { restBlockPatterns, restBlockPatternCategories, templateSlug } = + useSelect( ( select ) => { + const { getEditedPostType, getEditedPostId } = + select( editSiteStore ); + const { getEditedEntityRecord } = select( coreStore ); + const usedPostType = getEditedPostType(); + const usedPostId = getEditedPostId(); + const _record = getEditedEntityRecord( + 'postType', + usedPostType, + usedPostId + ); + return { + restBlockPatterns: select( coreStore ).getBlockPatterns(), + restBlockPatternCategories: + select( coreStore ).getBlockPatternCategories(), + templateSlug: _record.slug, + }; + }, [] ); + const archiveLabels = useArchiveLabel( templateSlug ); + + const blockPatterns = useMemo( + () => + [ + ...( settingsBlockPatterns || [] ), + ...( restBlockPatterns || [] ), + ] + .filter( + ( x, index, arr ) => + index === arr.findIndex( ( y ) => x.name === y.name ) + ) + .filter( ( { postTypes } ) => { + return ( + ! postTypes || + ( Array.isArray( postTypes ) && + postTypes.includes( templateType ) ) + ); + } ), + [ settingsBlockPatterns, restBlockPatterns, templateType ] + ); + + const blockPatternCategories = useMemo( + () => + [ + ...( settingsBlockPatternCategories || [] ), + ...( restBlockPatternCategories || [] ), + ].filter( + ( x, index, arr ) => + index === arr.findIndex( ( y ) => x.name === y.name ) + ), + [ settingsBlockPatternCategories, restBlockPatternCategories ] + ); + return useMemo( () => { + const { + __experimentalAdditionalBlockPatterns, + __experimentalAdditionalBlockPatternCategories, + focusMode, + ...restStoredSettings + } = storedSettings; + + return { + ...restStoredSettings, + inserterMediaCategories, + __experimentalBlockPatterns: blockPatterns, + __experimentalBlockPatternCategories: blockPatternCategories, + focusMode: canvasMode === 'view' && focusMode ? false : focusMode, + __experimentalArchiveTitleTypeLabel: archiveLabels.archiveTypeLabel, + __experimentalArchiveTitleNameLabel: archiveLabels.archiveNameLabel, + }; + }, [ + storedSettings, + blockPatterns, + blockPatternCategories, + canvasMode, + archiveLabels.archiveTypeLabel, + archiveLabels.archiveNameLabel, + ] ); +} diff --git a/packages/edit-site/src/components/canvas-loader/index.js b/packages/edit-site/src/components/canvas-loader/index.js new file mode 100644 index 00000000000000..fad8d59bbc3443 --- /dev/null +++ b/packages/edit-site/src/components/canvas-loader/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { useStylesPreviewColors } from '../global-styles/hooks'; + +const { ProgressBar, Theme } = unlock( componentsPrivateApis ); +const { useGlobalStyle } = unlock( blockEditorPrivateApis ); + +export default function CanvasLoader( { id } ) { + const [ fallbackIndicatorColor ] = useGlobalStyle( 'color.text' ); + const [ backgroundColor ] = useGlobalStyle( 'color.background' ); + const { highlightedColors } = useStylesPreviewColors(); + const indicatorColor = + highlightedColors[ 0 ]?.color ?? fallbackIndicatorColor; + + return ( + <div className="edit-site-canvas-loader"> + <Theme accent={ indicatorColor } background={ backgroundColor }> + <ProgressBar id={ id } /> + </Theme> + </div> + ); +} diff --git a/packages/edit-site/src/components/canvas-loader/style.scss b/packages/edit-site/src/components/canvas-loader/style.scss new file mode 100644 index 00000000000000..7bef5868e87073 --- /dev/null +++ b/packages/edit-site/src/components/canvas-loader/style.scss @@ -0,0 +1,28 @@ +.edit-site-canvas-loader { + width: 100%; + height: 100%; + display: flex; + position: absolute; + top: 0; + left: 0; + opacity: 0; + align-items: center; + justify-content: center; + + animation: 0.5s ease 1s edit-site-canvas-loader__fade-in-animation; + animation-fill-mode: forwards; + @include reduce-motion("animation"); + + & > div { + width: 160px; + } +} + +@keyframes edit-site-canvas-loader__fade-in-animation { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/packages/edit-site/src/components/canvas-spinner/index.js b/packages/edit-site/src/components/canvas-spinner/index.js deleted file mode 100644 index d5fae75fd7d952..00000000000000 --- a/packages/edit-site/src/components/canvas-spinner/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * WordPress dependencies - */ -import { Spinner } from '@wordpress/components'; - -export default function CanvasSpinner() { - return ( - <div className="edit-site-canvas-spinner"> - <Spinner /> - </div> - ); -} diff --git a/packages/edit-site/src/components/canvas-spinner/style.scss b/packages/edit-site/src/components/canvas-spinner/style.scss deleted file mode 100644 index 3178cbaeec58d4..00000000000000 --- a/packages/edit-site/src/components/canvas-spinner/style.scss +++ /dev/null @@ -1,7 +0,0 @@ -.edit-site-canvas-spinner { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} diff --git a/packages/edit-site/src/components/code-editor/code-editor-text-area.js b/packages/edit-site/src/components/code-editor/code-editor-text-area.js deleted file mode 100644 index 1a907ca8e59f68..00000000000000 --- a/packages/edit-site/src/components/code-editor/code-editor-text-area.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * External dependencies - */ -import Textarea from 'react-autosize-textarea'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { useEffect, useState, useRef } from '@wordpress/element'; -import { useInstanceId } from '@wordpress/compose'; -import { VisuallyHidden } from '@wordpress/components'; - -export default function CodeEditorTextArea( { value, onChange, onInput } ) { - const [ stateValue, setStateValue ] = useState( value ); - const [ isDirty, setIsDirty ] = useState( false ); - const instanceId = useInstanceId( CodeEditorTextArea ); - const valueRef = useRef(); - - if ( ! isDirty && stateValue !== value ) { - setStateValue( value ); - } - - /** - * Handles a textarea change event to notify the onChange prop callback and - * reflect the new value in the component's own state. This marks the start - * of the user's edits, if not already changed, preventing future props - * changes to value from replacing the rendered value. This is expected to - * be followed by a reset to dirty state via `stopEditing`. - * - * @see stopEditing - * - * @param {Event} event Change event. - */ - const onChangeHandler = ( event ) => { - const newValue = event.target.value; - onInput( newValue ); - setStateValue( newValue ); - setIsDirty( true ); - valueRef.current = newValue; - }; - - /** - * Function called when the user has completed their edits, responsible for - * ensuring that changes, if made, are surfaced to the onPersist prop - * callback and resetting dirty state. - */ - const stopEditing = () => { - if ( isDirty ) { - onChange( stateValue ); - setIsDirty( false ); - } - }; - - // Ensure changes aren't lost when component unmounts. - useEffect( () => { - return () => { - if ( valueRef.current ) { - onChange( valueRef.current ); - } - }; - }, [] ); - - return ( - <> - <VisuallyHidden - as="label" - htmlFor={ `code-editor-text-area-${ instanceId }` } - > - { __( 'Type text or HTML' ) } - </VisuallyHidden> - <Textarea - autoComplete="off" - dir="auto" - value={ stateValue } - onChange={ onChangeHandler } - onBlur={ stopEditing } - className="edit-site-code-editor-text-area" - id={ `code-editor-text-area-${ instanceId }` } - placeholder={ __( 'Start writing with text or HTML' ) } - /> - </> - ); -} diff --git a/packages/edit-site/src/components/code-editor/index.js b/packages/edit-site/src/components/code-editor/index.js index 178ff2e5f7099c..1fb4a5b7cd6bfd 100644 --- a/packages/edit-site/src/components/code-editor/index.js +++ b/packages/edit-site/src/components/code-editor/index.js @@ -1,50 +1,56 @@ +/** + * External dependencies + */ +import Textarea from 'react-autosize-textarea'; + /** * WordPress dependencies */ -import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; -import { useEntityBlockEditor, useEntityProp } from '@wordpress/core-data'; +import { __unstableSerializeAndClean } from '@wordpress/blocks'; +import { store as coreStore } from '@wordpress/core-data'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; import { __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; +import { Button, VisuallyHidden } from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; +import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import CodeEditorTextArea from './code-editor-text-area'; export default function CodeEditor() { - const { templateType, shortcut } = useSelect( ( select ) => { - const { getEditedPostType } = select( editSiteStore ); + const instanceId = useInstanceId( CodeEditor ); + const { shortcut, content, blocks, type, id } = useSelect( ( select ) => { + const { getEditedEntityRecord } = select( coreStore ); + const { getEditedPostType, getEditedPostId } = select( editSiteStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); + const _type = getEditedPostType(); + const _id = getEditedPostId(); + const editedRecord = getEditedEntityRecord( 'postType', _type, _id ); + return { - templateType: getEditedPostType(), shortcut: getShortcutRepresentation( 'core/edit-site/toggle-mode' ), + content: editedRecord?.content, + blocks: editedRecord?.blocks, + type: _type, + id: _id, }; }, [] ); - const [ contentStructure, setContent ] = useEntityProp( - 'postType', - templateType, - 'content' - ); - const [ blocks, , onChange ] = useEntityBlockEditor( - 'postType', - templateType - ); - + const { editEntityRecord } = useDispatch( coreStore ); // Replicates the logic found in getEditedPostContent(). - let content; - if ( contentStructure instanceof Function ) { - content = contentStructure( { blocks } ); - } else if ( blocks ) { - // If we have parsed blocks already, they should be our source of truth. - // Parsing applies block deprecations and legacy block conversions that - // unparsed content will not have. - content = __unstableSerializeAndClean( blocks ); - } else { - content = contentStructure; - } + const realContent = useMemo( () => { + if ( content instanceof Function ) { + return content( { blocks } ); + } else if ( blocks ) { + // If we have parsed blocks already, they should be our source of truth. + // Parsing applies block deprecations and legacy block conversions that + // unparsed content will not have. + return __unstableSerializeAndClean( blocks ); + } + return content; + }, [ content, blocks ] ); const { switchEditorMode } = useDispatch( editSiteStore ); return ( @@ -60,14 +66,26 @@ export default function CodeEditor() { </Button> </div> <div className="edit-site-code-editor__body"> - <CodeEditorTextArea - value={ content } - onChange={ ( newContent ) => { - onChange( parse( newContent ), { + <VisuallyHidden + as="label" + htmlFor={ `code-editor-text-area-${ instanceId }` } + > + { __( 'Type text or HTML' ) } + </VisuallyHidden> + <Textarea + autoComplete="off" + dir="auto" + value={ realContent } + onChange={ ( event ) => { + editEntityRecord( 'postType', type, id, { + content: event.target.value, + blocks: undefined, selection: undefined, } ); } } - onInput={ setContent } + className="edit-site-code-editor-text-area" + id={ `code-editor-text-area-${ instanceId }` } + placeholder={ __( 'Start writing with text or HTML' ) } /> </div> </div> diff --git a/packages/edit-site/src/components/create-template-part-modal/index.js b/packages/edit-site/src/components/create-template-part-modal/index.js index f91ec2c8c8b2de..a006ba6f2cb439 100644 --- a/packages/edit-site/src/components/create-template-part-modal/index.js +++ b/packages/edit-site/src/components/create-template-part-modal/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { Icon, BaseControl, @@ -20,14 +20,31 @@ import { __ } from '@wordpress/i18n'; import { useState } from '@wordpress/element'; import { useInstanceId } from '@wordpress/compose'; import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; +import { store as coreStore } from '@wordpress/core-data'; import { check } from '@wordpress/icons'; +import { serialize } from '@wordpress/blocks'; /** * Internal dependencies */ import { TEMPLATE_PART_AREA_GENERAL } from '../../store/constants'; +import { + useExistingTemplateParts, + getUniqueTemplatePartTitle, + getCleanTemplatePartSlug, +} from '../../utils/template-part-create'; + +export default function CreateTemplatePartModal( { + closeModal, + blocks = [], + onCreate, + onError, +} ) { + const { createErrorNotice } = useDispatch( noticesStore ); + const { saveEntityRecord } = useDispatch( coreStore ); + const existingTemplateParts = useExistingTemplateParts(); -export default function CreateTemplatePartModal( { closeModal, onCreate } ) { const [ title, setTitle ] = useState( '' ); const [ area, setArea ] = useState( TEMPLATE_PART_AREA_GENERAL ); const [ isSubmitting, setIsSubmitting ] = useState( false ); @@ -39,9 +56,52 @@ export default function CreateTemplatePartModal( { closeModal, onCreate } ) { [] ); + async function createTemplatePart() { + if ( ! title ) { + createErrorNotice( __( 'Please enter a title.' ), { + type: 'snackbar', + } ); + return; + } + + try { + const uniqueTitle = getUniqueTemplatePartTitle( + title, + existingTemplateParts + ); + const cleanSlug = getCleanTemplatePartSlug( uniqueTitle ); + + const templatePart = await saveEntityRecord( + 'postType', + 'wp_template_part', + { + slug: cleanSlug, + title: uniqueTitle, + content: serialize( blocks ), + area, + }, + { throwOnError: true } + ); + await onCreate( templatePart ); + + // TODO: Add a success notice? + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( + 'An error occurred while creating the template part.' + ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + + onError?.(); + } + } + return ( <Modal - title={ __( 'Create a template part' ) } + title={ __( 'Create template part' ) } onRequestClose={ closeModal } overlayClassName="edit-site-create-template-part-modal" > @@ -52,7 +112,7 @@ export default function CreateTemplatePartModal( { closeModal, onCreate } ) { return; } setIsSubmitting( true ); - await onCreate( { title, area } ); + await createTemplatePart(); } } > <VStack spacing="4"> diff --git a/packages/edit-site/src/components/editor-canvas-container/index.js b/packages/edit-site/src/components/editor-canvas-container/index.js index 3647ff2e49f43f..c036374e7907e1 100644 --- a/packages/edit-site/src/components/editor-canvas-container/index.js +++ b/packages/edit-site/src/components/editor-canvas-container/index.js @@ -12,11 +12,12 @@ import { __ } from '@wordpress/i18n'; import { useDispatch, useSelect } from '@wordpress/data'; import { closeSmall } from '@wordpress/icons'; import { useFocusOnMount, useFocusReturn } from '@wordpress/compose'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import ResizableEditor from '../block-editor/resizable-editor'; @@ -53,9 +54,22 @@ function EditorCanvasContainer( { onClose, enableResizing = false, } ) { - const editorCanvasContainerView = useSelect( - ( select ) => - unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), + const { editorCanvasContainerView, showListViewByDefault } = useSelect( + ( select ) => { + const _editorCanvasContainerView = unlock( + select( editSiteStore ) + ).getEditorCanvasContainerView(); + + const _showListViewByDefault = select( preferencesStore ).get( + 'core/edit-site', + 'showListViewByDefault' + ); + + return { + editorCanvasContainerView: _editorCanvasContainerView, + showListViewByDefault: _showListViewByDefault, + }; + }, [] ); const [ isClosed, setIsClosed ] = useState( false ); @@ -69,10 +83,13 @@ function EditorCanvasContainer( { [ editorCanvasContainerView ] ); + const { setIsListViewOpened } = useDispatch( editSiteStore ); + function onCloseContainer() { if ( typeof onClose === 'function' ) { onClose(); } + setIsListViewOpened( showListViewByDefault ); setEditorCanvasContainerView( undefined ); setIsClosed( true ); } diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index 72a8d41fb22f04..4bdd98b5d170e9 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -9,12 +9,14 @@ import classnames from 'classnames'; import { useMemo } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { Notice } from '@wordpress/components'; +import { useInstanceId } from '@wordpress/compose'; import { EntityProvider } from '@wordpress/core-data'; import { store as preferencesStore } from '@wordpress/preferences'; import { BlockContextProvider, BlockBreadcrumb, store as blockEditorStore, + privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { InterfaceSkeleton, @@ -37,13 +39,14 @@ import WelcomeGuide from '../welcome-guide'; import StartTemplateOptions from '../start-template-options'; import { store as editSiteStore } from '../../store'; import { GlobalStylesRenderer } from '../global-styles-renderer'; - import useTitle from '../routes/use-title'; -import CanvasSpinner from '../canvas-spinner'; -import { unlock } from '../../private-apis'; +import CanvasLoader from '../canvas-loader'; +import { unlock } from '../../lock-unlock'; import useEditedEntityRecord from '../use-edited-entity-record'; import { SidebarFixedBottomSlot } from '../sidebar-edit-mode/sidebar-fixed-bottom'; +const { BlockRemovalWarningModal } = unlock( blockEditorPrivateApis ); + const interfaceLabels = { /* translators: accessibility text for the editor content landmark region. */ body: __( 'Editor content' ), @@ -55,6 +58,25 @@ const interfaceLabels = { footer: __( 'Editor footer' ), }; +const typeLabels = { + wp_template: __( 'Template' ), + wp_template_part: __( 'Template Part' ), + wp_block: __( 'Pattern' ), + wp_navigation: __( 'Navigation' ), +}; + +// Prevent accidental removal of certain blocks, asking the user for +// confirmation. +const blockRemovalRules = { + 'core/query': __( 'Query Loop displays a list of posts or pages.' ), + 'core/post-content': __( + 'Post Content displays the content of a post or page.' + ), + 'core/post-template': __( + 'Post Template displays each post or page in a Query Loop.' + ), +}; + export default function Editor( { isLoading } ) { const { record: editedPost, @@ -74,6 +96,7 @@ export default function Editor( { isLoading } ) { isListViewOpen, showIconLabels, showBlockBreadcrumbs, + hasPageContentFocus, } = useSelect( ( select ) => { const { getEditedPostContext, @@ -81,6 +104,7 @@ export default function Editor( { isLoading } ) { getCanvasMode, isInserterOpened, isListViewOpened, + hasPageContentFocus: _hasPageContentFocus, } = unlock( select( editSiteStore ) ); const { __unstableGetEditorMode } = select( blockEditorStore ); const { getActiveComplementaryArea } = select( interfaceStore ); @@ -105,6 +129,7 @@ export default function Editor( { isLoading } ) { 'core/edit-site', 'showBlockBreadcrumbs' ), + hasPageContentFocus: _hasPageContentFocus(), }; }, [] ); const { setEditedPostContext } = useDispatch( editSiteStore ); @@ -112,7 +137,7 @@ export default function Editor( { isLoading } ) { const isViewMode = canvasMode === 'view'; const isEditMode = canvasMode === 'edit'; const showVisualEditor = isViewMode || editorMode === 'visual'; - const shouldShowBlockBreakcrumbs = + const shouldShowBlockBreadcrumbs = showBlockBreadcrumbs && isEditMode && showVisualEditor && @@ -122,9 +147,10 @@ export default function Editor( { isLoading } ) { const secondarySidebarLabel = isListViewOpen ? __( 'List View' ) : __( 'Block Library' ); - const blockContext = useMemo( - () => ( { - ...context, + const blockContext = useMemo( () => { + const { postType, postId, ...nonPostFields } = context ?? {}; + return { + ...( hasPageContentFocus ? context : nonPostFields ), queryContext: [ context?.queryContext || { page: 1 }, ( newQueryContext ) => @@ -136,31 +162,38 @@ export default function Editor( { isLoading } ) { }, } ), ], - } ), - [ context, setEditedPostContext ] - ); + }; + }, [ hasPageContentFocus, context, setEditedPostContext ] ); let title; if ( hasLoadedPost ) { - const type = - editedPostType === 'wp_template' - ? __( 'Template' ) - : __( 'Template Part' ); title = sprintf( // translators: A breadcrumb trail in browser tab. %1$s: title of template being edited, %2$s: type of template (Template or Template Part). __( '%1$s ‹ %2$s ‹ Editor' ), getTitle(), - type + typeLabels[ editedPostType ] ?? typeLabels.wp_template ); } // Only announce the title once the editor is ready to prevent "Replace" - // action in <URlQueryController> from double-announcing. + // action in <URLQueryController> from double-announcing. useTitle( hasLoadedPost && title ); + const loadingProgressId = useInstanceId( + CanvasLoader, + 'edit-site-editor__loading-progress' + ); + + const contentProps = isLoading + ? { + 'aria-busy': 'true', + 'aria-describedby': loadingProgressId, + } + : undefined; + return ( <> - { isLoading ? <CanvasSpinner /> : null } + { isLoading ? <CanvasLoader id={ loadingProgressId } /> : null } { isEditMode && <WelcomeGuide /> } <EntityProvider kind="root" type="site"> <EntityProvider @@ -172,6 +205,7 @@ export default function Editor( { isLoading } ) { <SidebarComplementaryAreaFills /> { isEditMode && <StartTemplateOptions /> } <InterfaceSkeleton + isDistractionFree={ true } enableRegionNavigation={ false } className={ classnames( 'edit-site-editor__interface-skeleton', @@ -186,7 +220,12 @@ export default function Editor( { isLoading } ) { <GlobalStylesRenderer /> { isEditMode && <EditorNotices /> } { showVisualEditor && editedPost && ( - <BlockEditor /> + <> + <BlockEditor /> + <BlockRemovalWarningModal + rules={ blockRemovalRules } + /> + </> ) } { editorMode === 'text' && editedPost && @@ -206,6 +245,7 @@ export default function Editor( { isLoading } ) { ) } </> } + contentProps={ contentProps } secondarySidebar={ isEditMode && ( ( shouldShowInserter && ( @@ -225,9 +265,13 @@ export default function Editor( { isLoading } ) { ) } footer={ - shouldShowBlockBreakcrumbs && ( + shouldShowBlockBreadcrumbs && ( <BlockBreadcrumb - rootLabelText={ __( 'Template' ) } + rootLabelText={ + hasPageContentFocus + ? __( 'Page' ) + : __( 'Template' ) + } /> ) } diff --git a/packages/edit-site/src/components/editor/style.scss b/packages/edit-site/src/components/editor/style.scss index 70a12694cc3f7e..26403858ff22e0 100644 --- a/packages/edit-site/src/components/editor/style.scss +++ b/packages/edit-site/src/components/editor/style.scss @@ -6,6 +6,10 @@ &.is-loading { opacity: 0; } + + .interface-interface-skeleton__header { + border: 0; + } } .edit-site-editor__toggle-save-panel { @@ -19,16 +23,11 @@ } // Adjust the position of the notices -.edit-site { - .components-editor-notices__snackbar { - position: fixed; - right: 0; - bottom: 0; - padding: 16px; - } - .is-edit-mode .components-editor-notices__snackbar { - bottom: 24px; - } +.edit-site .components-editor-notices__snackbar { + position: absolute; + right: 0; + bottom: 40px; + padding-left: 16px; + padding-right: 16px; } - @include editor-left(".edit-site .components-editor-notices__snackbar") diff --git a/packages/edit-site/src/components/global-styles-renderer/index.js b/packages/edit-site/src/components/global-styles-renderer/index.js index 14fc4adf25f7e2..eca6d9b2662e8f 100644 --- a/packages/edit-site/src/components/global-styles-renderer/index.js +++ b/packages/edit-site/src/components/global-styles-renderer/index.js @@ -9,7 +9,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalStylesOutput } = unlock( blockEditorPrivateApis ); diff --git a/packages/edit-site/src/components/global-styles/block-preview-panel.js b/packages/edit-site/src/components/global-styles/block-preview-panel.js index 584df056ddee06..24bd2e666eab3e 100644 --- a/packages/edit-site/src/components/global-styles/block-preview-panel.js +++ b/packages/edit-site/src/components/global-styles/block-preview-panel.js @@ -4,26 +4,37 @@ import { BlockPreview } from '@wordpress/block-editor'; import { getBlockType, getBlockFromExample } from '@wordpress/blocks'; import { __experimentalSpacer as Spacer } from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; const BlockPreviewPanel = ( { name, variation = '' } ) => { const blockExample = getBlockType( name )?.example; - const blockExampleWithVariation = { - ...blockExample, - attributes: { - ...blockExample?.attributes, - className: 'is-style-' + variation, - }, - }; - const blocks = - blockExample && - getBlockFromExample( - name, - variation ? blockExampleWithVariation : blockExample - ); - const viewportWidth = blockExample?.viewportWidth || null; + const blocks = useMemo( () => { + if ( ! blockExample ) { + return null; + } + + let example = blockExample; + if ( variation ) { + example = { + ...example, + attributes: { + ...example.attributes, + className: 'is-style-' + variation, + }, + }; + } + + return getBlockFromExample( name, example ); + }, [ name, blockExample, variation ] ); + + const viewportWidth = blockExample?.viewportWidth ?? null; const previewHeight = '150px'; - return ! blockExample ? null : ( + if ( ! blockExample ) { + return null; + } + + return ( <Spacer marginX={ 4 } marginBottom={ 4 }> <div className="edit-site-global-styles__block-preview-panel" diff --git a/packages/edit-site/src/components/global-styles/color-palette-panel.js b/packages/edit-site/src/components/global-styles/color-palette-panel.js index 185a46fdba4761..03401cab9f80b5 100644 --- a/packages/edit-site/src/components/global-styles/color-palette-panel.js +++ b/packages/edit-site/src/components/global-styles/color-palette-panel.js @@ -12,7 +12,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalSetting } = unlock( blockEditorPrivateApis ); const mobilePopoverProps = { placement: 'bottom-start', offset: 8 }; diff --git a/packages/edit-site/src/components/global-styles/dimensions-panel.js b/packages/edit-site/src/components/global-styles/dimensions-panel.js index 11b44cd20092f2..461ed7ab8dce8c 100644 --- a/packages/edit-site/src/components/global-styles/dimensions-panel.js +++ b/packages/edit-site/src/components/global-styles/dimensions-panel.js @@ -7,7 +7,7 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalStyle, diff --git a/packages/edit-site/src/components/global-styles/global-styles-provider.js b/packages/edit-site/src/components/global-styles/global-styles-provider.js index 7ddd518020569a..250cca0ebfc6df 100644 --- a/packages/edit-site/src/components/global-styles/global-styles-provider.js +++ b/packages/edit-site/src/components/global-styles/global-styles-provider.js @@ -1,7 +1,8 @@ /** * External dependencies */ -import { mergeWith } from 'lodash'; +import deepmerge from 'deepmerge'; +import { isPlainObject } from 'is-plain-object'; /** * WordPress dependencies @@ -14,28 +15,23 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import CanvasSpinner from '../canvas-spinner'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { GlobalStylesContext, cleanEmptyObject } = unlock( blockEditorPrivateApis ); -function mergeTreesCustomizer( _, srcValue ) { - // We only pass as arrays the presets, - // in which case we want the new array of values - // to override the old array (no merging). - if ( Array.isArray( srcValue ) ) { - return srcValue; - } -} - export function mergeBaseAndUserConfigs( base, user ) { - return mergeWith( {}, base, user, mergeTreesCustomizer ); + return deepmerge( base, user, { + // We only pass as arrays the presets, + // in which case we want the new array of values + // to override the old array (no merging). + isMergeableObject: isPlainObject, + } ); } function useGlobalStylesUserConfig() { - const { globalStylesId, isReady, settings, styles } = useSelect( + const { globalStylesId, isReady, settings, styles, behaviors } = useSelect( ( select ) => { const { getEditedEntityRecord, hasFinishedResolution } = select( coreStore ); @@ -69,6 +65,7 @@ function useGlobalStylesUserConfig() { isReady: hasResolved, settings: record?.settings, styles: record?.styles, + behaviors: record?.behaviors, }; }, [] @@ -80,8 +77,9 @@ function useGlobalStylesUserConfig() { return { settings: settings ?? {}, styles: styles ?? {}, + behaviors: behaviors ?? {}, }; - }, [ settings, styles ] ); + }, [ settings, styles, behaviors ] ); const setConfig = useCallback( ( callback, options = {} ) => { @@ -93,6 +91,7 @@ function useGlobalStylesUserConfig() { const currentConfig = { styles: record?.styles ?? {}, settings: record?.settings ?? {}, + behaviors: record?.behaviors ?? {}, }; const updatedConfig = callback( currentConfig ); editEntityRecord( @@ -102,6 +101,8 @@ function useGlobalStylesUserConfig() { { styles: cleanEmptyObject( updatedConfig.styles ) || {}, settings: cleanEmptyObject( updatedConfig.settings ) || {}, + behaviors: + cleanEmptyObject( updatedConfig.behaviors ) || {}, }, options ); @@ -155,7 +156,7 @@ function useGlobalStylesContext() { export function GlobalStylesProvider( { children } ) { const context = useGlobalStylesContext(); if ( ! context.isReady ) { - return <CanvasSpinner />; + return null; } return ( diff --git a/packages/edit-site/src/components/global-styles/gradients-palette-panel.js b/packages/edit-site/src/components/global-styles/gradients-palette-panel.js index 4c791b8e6b0117..384e033c5dbbcd 100644 --- a/packages/edit-site/src/components/global-styles/gradients-palette-panel.js +++ b/packages/edit-site/src/components/global-styles/gradients-palette-panel.js @@ -15,7 +15,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; * Internal dependencies */ import Subtitle from './subtitle'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalSetting } = unlock( blockEditorPrivateApis ); const mobilePopoverProps = { placement: 'bottom-start', offset: 8 }; diff --git a/packages/edit-site/src/components/global-styles/hooks.js b/packages/edit-site/src/components/global-styles/hooks.js index 163ae58df8dcfa..123bda74973209 100644 --- a/packages/edit-site/src/components/global-styles/hooks.js +++ b/packages/edit-site/src/components/global-styles/hooks.js @@ -13,10 +13,10 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { useSelect } from '@wordpress/data'; -const { useGlobalSetting } = unlock( blockEditorPrivateApis ); +const { useGlobalSetting, useGlobalStyle } = unlock( blockEditorPrivateApis ); // Enable colord's a11y plugin. extend( [ a11yPlugin ] ); @@ -52,6 +52,32 @@ export function useColorRandomizer( name ) { : []; } +export function useStylesPreviewColors() { + const [ textColor = 'black' ] = useGlobalStyle( 'color.text' ); + const [ backgroundColor = 'white' ] = useGlobalStyle( 'color.background' ); + const [ headingColor = textColor ] = useGlobalStyle( + 'elements.h1.color.text' + ); + const [ coreColors ] = useGlobalSetting( 'color.palette.core' ); + const [ themeColors ] = useGlobalSetting( 'color.palette.theme' ); + const [ customColors ] = useGlobalSetting( 'color.palette.custom' ); + + const paletteColors = ( themeColors ?? [] ) + .concat( customColors ?? [] ) + .concat( coreColors ?? [] ); + const highlightedColors = paletteColors + .filter( + // we exclude these two colors because they are already visible in the preview. + ( { color } ) => color !== backgroundColor && color !== headingColor + ) + .slice( 0, 2 ); + + return { + paletteColors, + highlightedColors, + }; +} + export function useSupportedStyles( name, element ) { const { supportedPanels } = useSelect( ( select ) => { diff --git a/packages/edit-site/src/components/global-styles/palette.js b/packages/edit-site/src/components/global-styles/palette.js index c5f71cc987f043..dc73f54a1701a8 100644 --- a/packages/edit-site/src/components/global-styles/palette.js +++ b/packages/edit-site/src/components/global-styles/palette.js @@ -22,7 +22,7 @@ import Subtitle from './subtitle'; import { NavigationButtonAsItem } from './navigation-button'; import { useColorRandomizer } from './hooks'; import ColorIndicatorWrapper from './color-indicator-wrapper'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalSetting } = unlock( blockEditorPrivateApis ); @@ -91,15 +91,16 @@ function Palette( { name } ) { </HStack> </NavigationButtonAsItem> </ItemGroup> - { randomizeThemeColors && ( - <Button - variant="secondary" - icon={ shuffle } - onClick={ randomizeThemeColors } - > - { __( 'Randomize colors' ) } - </Button> - ) } + { window.__experimentalEnableColorRandomizer && + themeColors?.length > 0 && ( + <Button + variant="secondary" + icon={ shuffle } + onClick={ randomizeThemeColors } + > + { __( 'Randomize colors' ) } + </Button> + ) } </VStack> ); } diff --git a/packages/edit-site/src/components/global-styles/preview.js b/packages/edit-site/src/components/global-styles/preview.js index 1cc77c5b86132f..2cb9ca49bc7cef 100644 --- a/packages/edit-site/src/components/global-styles/preview.js +++ b/packages/edit-site/src/components/global-styles/preview.js @@ -17,9 +17,10 @@ import { useState, useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; +import { useStylesPreviewColors } from './hooks'; -const { useGlobalSetting, useGlobalStyle, useGlobalStylesOutput } = unlock( +const { useGlobalStyle, useGlobalStylesOutput } = unlock( blockEditorPrivateApis ); @@ -76,22 +77,11 @@ const StylesPreview = ( { label, isFocused, withHoverView } ) => { const [ gradientValue ] = useGlobalStyle( 'color.gradient' ); const [ styles ] = useGlobalStylesOutput(); const disableMotion = useReducedMotion(); - const [ coreColors ] = useGlobalSetting( 'color.palette.core' ); - const [ themeColors ] = useGlobalSetting( 'color.palette.theme' ); - const [ customColors ] = useGlobalSetting( 'color.palette.custom' ); const [ isHovered, setIsHovered ] = useState( false ); const [ containerResizeListener, { width } ] = useResizeObserver(); const ratio = width ? width / normalizedWidth : 1; - const paletteColors = ( themeColors ?? [] ) - .concat( customColors ?? [] ) - .concat( coreColors ?? [] ); - const highlightedColors = paletteColors - .filter( - // we exclude these two colors because they are already visible in the preview. - ( { color } ) => color !== backgroundColor && color !== headingColor - ) - .slice( 0, 2 ); + const { paletteColors, highlightedColors } = useStylesPreviewColors(); // Reset leaked styles from WP common.css and remove main content layout padding and border. const editorStyles = useMemo( () => { diff --git a/packages/edit-site/src/components/global-styles/root-menu.js b/packages/edit-site/src/components/global-styles/root-menu.js index 14c5c344e5f64c..9edfd064acbf73 100644 --- a/packages/edit-site/src/components/global-styles/root-menu.js +++ b/packages/edit-site/src/components/global-styles/root-menu.js @@ -10,7 +10,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; * Internal dependencies */ import { NavigationButtonAsItem } from './navigation-button'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useHasDimensionsPanel, diff --git a/packages/edit-site/src/components/global-styles/screen-block-list.js b/packages/edit-site/src/components/global-styles/screen-block-list.js index 88c826c42e0d07..1db81377ff9a3b 100644 --- a/packages/edit-site/src/components/global-styles/screen-block-list.js +++ b/packages/edit-site/src/components/global-styles/screen-block-list.js @@ -23,7 +23,7 @@ import { speak } from '@wordpress/a11y'; import { useBlockVariations } from './variations-panel'; import ScreenHeader from './header'; import { NavigationButtonAsItem } from './navigation-button'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useHasDimensionsPanel, diff --git a/packages/edit-site/src/components/global-styles/screen-block.js b/packages/edit-site/src/components/global-styles/screen-block.js index fe6221dfb5a659..87dae3c8f2f36a 100644 --- a/packages/edit-site/src/components/global-styles/screen-block.js +++ b/packages/edit-site/src/components/global-styles/screen-block.js @@ -18,7 +18,7 @@ import { __, sprintf } from '@wordpress/i18n'; */ import ScreenHeader from './header'; import BlockPreviewPanel from './block-preview-panel'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import Subtitle from './subtitle'; import { useBlockVariations, VariationsPanel } from './variations-panel'; @@ -61,12 +61,15 @@ const { useHasDimensionsPanel, useHasTypographyPanel, useHasBorderPanel, + __experimentalUseHasBehaviorsPanel: useHasBehaviorsPanel, useGlobalSetting, useSettingsForBlockElement, useHasColorPanel, useHasEffectsPanel, useHasFiltersPanel, useGlobalStyle, + __experimentalUseGlobalBehaviors: useGlobalBehaviors, + __experimentalBehaviorsPanel: StylesBehaviorsPanel, BorderPanel: StylesBorderPanel, ColorPanel: StylesColorPanel, TypographyPanel: StylesTypographyPanel, @@ -91,10 +94,28 @@ function ScreenBlock( { name, variation } ) { } ); const [ rawSettings, setSettings ] = useGlobalSetting( '', name ); const settings = useSettingsForBlockElement( rawSettings, name ); + const { inheritedBehaviors, setBehavior } = useGlobalBehaviors( name ); + const { behavior } = useGlobalBehaviors( name, 'user' ); + const blockType = getBlockType( name ); + + // Only allow `blockGap` support if serialization has not been skipped, to be sure global spacing can be applied. + if ( + settings?.spacing?.blockGap && + blockType?.supports?.spacing?.blockGap && + ( blockType?.supports?.spacing?.__experimentalSkipSerialization === + true || + blockType?.supports?.spacing?.__experimentalSkipSerialization?.some?.( + ( spacingType ) => spacingType === 'blockGap' + ) ) + ) { + settings.spacing.blockGap = false; + } + const blockVariations = useBlockVariations( name ); const hasTypographyPanel = useHasTypographyPanel( settings ); const hasColorPanel = useHasColorPanel( settings ); + const hasBehaviorsPanel = useHasBehaviorsPanel( rawSettings, name ); const hasBorderPanel = useHasBorderPanel( settings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); const hasEffectsPanel = useHasEffectsPanel( settings ); @@ -267,6 +288,14 @@ function ScreenBlock( { name, variation } ) { onChange={ setStyle } inheritedValue={ inheritedStyle } /> + { hasBehaviorsPanel && ( + <StylesBehaviorsPanel + value={ behavior } + onChange={ setBehavior } + behaviors={ inheritedBehaviors } + blockName={ name } + ></StylesBehaviorsPanel> + ) } </PanelBody> ) } </> diff --git a/packages/edit-site/src/components/global-styles/screen-colors.js b/packages/edit-site/src/components/global-styles/screen-colors.js index dc56bbc074b8b8..a19bd16c8839f2 100644 --- a/packages/edit-site/src/components/global-styles/screen-colors.js +++ b/packages/edit-site/src/components/global-styles/screen-colors.js @@ -10,8 +10,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; */ import ScreenHeader from './header'; import Palette from './palette'; -import BlockPreviewPanel from './block-preview-panel'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalStyle, @@ -38,9 +37,6 @@ function ScreenColors() { 'Manage palettes and the default color of different global elements on the site.' ) } /> - - <BlockPreviewPanel /> - <div className="edit-site-global-styles-screen-colors"> <VStack spacing={ 10 }> <Palette /> diff --git a/packages/edit-site/src/components/global-styles/screen-css.js b/packages/edit-site/src/components/global-styles/screen-css.js index 50aae836ffa1c3..71d47a52a50274 100644 --- a/packages/edit-site/src/components/global-styles/screen-css.js +++ b/packages/edit-site/src/components/global-styles/screen-css.js @@ -8,7 +8,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import ScreenHeader from './header'; const { useGlobalStyle, AdvancedPanel: StylesAdvancedPanel } = unlock( diff --git a/packages/edit-site/src/components/global-styles/screen-layout.js b/packages/edit-site/src/components/global-styles/screen-layout.js index 66626fa82e7058..e7794985b7984f 100644 --- a/packages/edit-site/src/components/global-styles/screen-layout.js +++ b/packages/edit-site/src/components/global-styles/screen-layout.js @@ -9,8 +9,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; */ import DimensionsPanel from './dimensions-panel'; import ScreenHeader from './header'; -import BlockPreviewPanel from './block-preview-panel'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useHasDimensionsPanel, useGlobalSetting, useSettingsForBlockElement } = unlock( blockEditorPrivateApis ); @@ -22,7 +21,6 @@ function ScreenLayout() { return ( <> <ScreenHeader title={ __( 'Layout' ) } /> - <BlockPreviewPanel /> { hasDimensionsPanel && <DimensionsPanel /> } </> ); diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js index 8a3e84e0ee41eb..7af3d40ec2c754 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -7,6 +7,7 @@ import { __experimentalUseNavigator as useNavigator, __experimentalConfirmDialog as ConfirmDialog, Spinner, + __experimentalSpacer as Spacer, } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { useContext, useState, useEffect } from '@wordpress/element'; @@ -19,7 +20,7 @@ import { * Internal dependencies */ import ScreenHeader from '../header'; -import { unlock } from '../../../private-apis'; +import { unlock } from '../../../lock-unlock'; import Revisions from '../../revisions'; import SidebarFixedBottom from '../../sidebar-edit-mode/sidebar-fixed-bottom'; import { store as editSiteStore } from '../../../store'; @@ -70,6 +71,7 @@ function ScreenRevisions() { setUserConfig( () => ( { styles: revision?.styles, settings: revision?.settings, + behaviors: revision?.behaviors, } ) ); setIsLoadingRevisionWithUnsavedChanges( false ); onCloseRevisions(); @@ -77,8 +79,9 @@ function ScreenRevisions() { const selectRevision = ( revision ) => { setGlobalStylesRevision( { - styles: revision?.styles, - settings: revision?.settings, + styles: revision?.styles || {}, + settings: revision?.settings || {}, + behaviors: revision?.behaviors || {}, id: revision?.id, } ); setSelectedRevisionId( revision?.id ); @@ -87,6 +90,7 @@ function ScreenRevisions() { const isLoadButtonEnabled = !! globalStylesRevision?.id && ! areGlobalStyleConfigsEqual( globalStylesRevision, userConfig ); + const shouldShowRevisions = ! isLoading && revisions.length; return ( <> @@ -99,68 +103,72 @@ function ScreenRevisions() { { isLoading && ( <Spinner className="edit-site-global-styles-screen-revisions__loading" /> ) } - { ! isLoading && ( - <Revisions - blocks={ blocks } - userConfig={ globalStylesRevision } - onClose={ onCloseRevisions } - /> - ) } - <div className="edit-site-global-styles-screen-revisions"> - <RevisionsButtons - onChange={ selectRevision } - selectedRevisionId={ selectedRevisionId } - userRevisions={ revisions } - /> - { isLoadButtonEnabled && ( - <SidebarFixedBottom> - <Button - variant="primary" - className="edit-site-global-styles-screen-revisions__button" - disabled={ - ! globalStylesRevision?.id || - globalStylesRevision?.id === 'unsaved' + { shouldShowRevisions ? ( + <> + <Revisions + blocks={ blocks } + userConfig={ globalStylesRevision } + onClose={ onCloseRevisions } + /> + <div className="edit-site-global-styles-screen-revisions"> + <RevisionsButtons + onChange={ selectRevision } + selectedRevisionId={ selectedRevisionId } + userRevisions={ revisions } + /> + { isLoadButtonEnabled && ( + <SidebarFixedBottom> + <Button + variant="primary" + className="edit-site-global-styles-screen-revisions__button" + disabled={ + ! globalStylesRevision?.id || + globalStylesRevision?.id === 'unsaved' + } + onClick={ () => { + if ( hasUnsavedChanges ) { + setIsLoadingRevisionWithUnsavedChanges( + true + ); + } else { + restoreRevision( + globalStylesRevision + ); + } + } } + > + { globalStylesRevision?.id === 'parent' + ? __( 'Reset to defaults' ) + : __( 'Apply' ) } + </Button> + </SidebarFixedBottom> + ) } + </div> + { isLoadingRevisionWithUnsavedChanges && ( + <ConfirmDialog + isOpen={ isLoadingRevisionWithUnsavedChanges } + confirmButtonText={ __( 'Apply' ) } + onConfirm={ () => + restoreRevision( globalStylesRevision ) + } + onCancel={ () => + setIsLoadingRevisionWithUnsavedChanges( false ) } - onClick={ () => { - if ( hasUnsavedChanges ) { - setIsLoadingRevisionWithUnsavedChanges( - true - ); - } else { - restoreRevision( globalStylesRevision ); - } - } } > - { __( 'Apply' ) } - </Button> - </SidebarFixedBottom> - ) } - </div> - { isLoadingRevisionWithUnsavedChanges && ( - <ConfirmDialog - title={ __( - 'Loading this revision will discard all unsaved changes.' - ) } - isOpen={ isLoadingRevisionWithUnsavedChanges } - confirmButtonText={ __( ' Discard unsaved changes' ) } - onConfirm={ () => restoreRevision( globalStylesRevision ) } - onCancel={ () => - setIsLoadingRevisionWithUnsavedChanges( false ) - } - > - <> - <h2> - { __( - 'Loading this revision will discard all unsaved changes.' - ) } - </h2> - <p> { __( - 'Do you want to replace your unsaved changes in the editor?' + 'Any unsaved changes will be lost when you apply this revision.' ) } - </p> - </> - </ConfirmDialog> + </ConfirmDialog> + ) } + </> + ) : ( + <Spacer marginX={ 4 } data-testid="global-styles-no-revisions"> + { + // Adding an existing translation here in case these changes are shipped to WordPress 6.3. + // Later we could update to something better, e.g., "There are currently no style revisions.". + __( 'No results found.' ) + } + </Spacer> ) } </> ); diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js b/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js index f32441f6a41b06..feec0f25ac8823 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js @@ -9,6 +9,8 @@ import classnames from 'classnames'; import { __, sprintf } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import { dateI18n, getDate, humanTimeDiff, getSettings } from '@wordpress/date'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; /** * Returns a button label for the revision. @@ -19,15 +21,18 @@ import { dateI18n, getDate, humanTimeDiff, getSettings } from '@wordpress/date'; function getRevisionLabel( revision ) { const authorDisplayName = revision?.author?.name || __( 'User' ); + if ( 'parent' === revision?.id ) { + return __( 'Reset the styles to the theme defaults' ); + } + if ( 'unsaved' === revision?.id ) { return sprintf( - /* translators: %(name)s author display name */ - __( 'Unsaved changes by %(name)s' ), - { - name: authorDisplayName, - } + /* translators: %s author display name */ + __( 'Unsaved changes by %s' ), + authorDisplayName ); } + const formattedDate = dateI18n( getSettings().formats.datetimeAbbreviated, getDate( revision?.modified ) @@ -35,20 +40,16 @@ function getRevisionLabel( revision ) { return revision?.isLatest ? sprintf( - /* translators: %(name)s author display name, %(date)s: revision creation date */ - __( 'Changes saved by %(name)s on %(date)s (current)' ), - { - name: authorDisplayName, - date: formattedDate, - } + /* translators: %1$s author display name, %2$s: revision creation date */ + __( 'Changes saved by %1$s on %2$s (current)' ), + authorDisplayName, + formattedDate ) : sprintf( - /* translators: %(name)s author display name, %(date)s: revision creation date */ - __( 'Changes saved by %(name)s on %(date)s' ), - { - name: authorDisplayName, - date: formattedDate, - } + /* translators: %1$s author display name, %2$s: revision creation date */ + __( 'Changes saved by %1$s on %2$s' ), + authorDisplayName, + formattedDate ); } @@ -64,6 +65,10 @@ function getRevisionLabel( revision ) { * @return {JSX.Element} The modal component. */ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { + const currentTheme = useSelect( + ( select ) => select( coreStore ).getCurrentTheme(), + [] + ); return ( <ol className="edit-site-global-styles-screen-revisions__revisions-list" @@ -78,6 +83,7 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { const isSelected = selectedRevisionId ? selectedRevisionId === revision?.id : index === 0; + const isReset = 'parent' === revision?.id; return ( <li @@ -85,6 +91,7 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { 'edit-site-global-styles-screen-revisions__revision-item', { 'is-selected': isSelected, + 'is-reset': isReset, } ) } key={ id } @@ -97,37 +104,41 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { } } label={ getRevisionLabel( revision ) } > - <span className="edit-site-global-styles-screen-revisions__description"> - <time dateTime={ modified }> - { humanTimeDiff( modified ) } - </time> - <span className="edit-site-global-styles-screen-revisions__meta"> - { isUnsaved - ? sprintf( - /* translators: %(name)s author display name */ - __( - 'Unsaved changes by %(name)s' - ), - { - name: authorDisplayName, - } - ) - : sprintf( - /* translators: %(name)s author display name */ - __( - 'Changes saved by %(name)s' - ), - { - name: authorDisplayName, - } - ) } + { isReset ? ( + <span className="edit-site-global-styles-screen-revisions__description"> + { __( 'Default styles' ) } + <span className="edit-site-global-styles-screen-revisions__meta"> + { currentTheme?.name?.rendered || + currentTheme?.stylesheet } + </span> + </span> + ) : ( + <span className="edit-site-global-styles-screen-revisions__description"> + <time dateTime={ modified }> + { humanTimeDiff( modified ) } + </time> + <span className="edit-site-global-styles-screen-revisions__meta"> + { isUnsaved + ? sprintf( + /* translators: %s author display name */ + __( + 'Unsaved changes by %s' + ), + authorDisplayName + ) + : sprintf( + /* translators: %s author display name */ + __( 'Changes saved by %s' ), + authorDisplayName + ) } - <img - alt={ author?.name } - src={ authorAvatar } - /> + <img + alt={ author?.name } + src={ authorAvatar } + /> + </span> </span> - </span> + ) } </Button> </li> ); diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js index 0b7d086c1120fb..4b03e38fe34d27 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js @@ -49,6 +49,7 @@ describe( 'useGlobalStylesRevisions', () => { styles: {}, }, ], + isLoadingGlobalStylesRevisions: false, }; it( 'returns loaded revisions with no unsaved changes', () => { @@ -70,6 +71,11 @@ describe( 'useGlobalStylesRevisions', () => { settings: {}, styles: {}, }, + { + id: 'parent', + settings: {}, + styles: {}, + }, ] ); } ); @@ -105,6 +111,11 @@ describe( 'useGlobalStylesRevisions', () => { settings: {}, styles: {}, }, + { + id: 'parent', + settings: {}, + styles: {}, + }, ] ); } ); @@ -117,11 +128,23 @@ describe( 'useGlobalStylesRevisions', () => { const { result } = renderHook( () => useGlobalStylesRevisions() ); const { revisions, isLoading, hasUnsavedChanges } = result.current; - expect( isLoading ).toBe( true ); + expect( isLoading ).toBe( false ); expect( hasUnsavedChanges ).toBe( false ); expect( revisions ).toEqual( [] ); } ); + it( 'returns loading status when resolving global revisions', () => { + useSelect.mockImplementation( () => ( { + ...selectValue, + isLoadingGlobalStylesRevisions: true, + } ) ); + + const { result } = renderHook( () => useGlobalStylesRevisions() ); + const { isLoading } = result.current; + + expect( isLoading ).toBe( true ); + } ); + it( 'returns empty revisions when authors are not yet available', () => { useSelect.mockImplementation( () => ( { ...selectValue, diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js index 94d9296989eee1..02e4a63154ec6b 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js @@ -5,14 +5,11 @@ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { useContext, useMemo } from '@wordpress/element'; import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; -/** - * External dependencies - */ -import { isEmpty } from 'lodash'; + /** * Internal dependencies */ -import { unlock } from '../../../private-apis'; +import { unlock } from '../../../lock-unlock'; const SITE_EDITOR_AUTHORS_QUERY = { per_page: -1, @@ -24,34 +21,40 @@ const EMPTY_ARRAY = []; const { GlobalStylesContext } = unlock( blockEditorPrivateApis ); export default function useGlobalStylesRevisions() { const { user: userConfig } = useContext( GlobalStylesContext ); - const { authors, currentUser, isDirty, revisions } = useSelect( - ( select ) => { - const { - __experimentalGetDirtyEntityRecords, - getCurrentUser, - getUsers, - getCurrentThemeGlobalStylesRevisions, - } = select( coreStore ); - const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); - const _currentUser = getCurrentUser(); - const _isDirty = dirtyEntityRecords.length > 0; - const globalStylesRevisions = - getCurrentThemeGlobalStylesRevisions() || EMPTY_ARRAY; - const _authors = - getUsers( SITE_EDITOR_AUTHORS_QUERY ) || EMPTY_ARRAY; + const { + authors, + currentUser, + isDirty, + revisions, + isLoadingGlobalStylesRevisions, + } = useSelect( ( select ) => { + const { + __experimentalGetDirtyEntityRecords, + getCurrentUser, + getUsers, + getCurrentThemeGlobalStylesRevisions, + isResolving, + } = select( coreStore ); + const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); + const _currentUser = getCurrentUser(); + const _isDirty = dirtyEntityRecords.length > 0; + const globalStylesRevisions = + getCurrentThemeGlobalStylesRevisions() || EMPTY_ARRAY; + const _authors = getUsers( SITE_EDITOR_AUTHORS_QUERY ) || EMPTY_ARRAY; - return { - authors: _authors, - currentUser: _currentUser, - isDirty: _isDirty, - revisions: globalStylesRevisions, - }; - }, - [] - ); + return { + authors: _authors, + currentUser: _currentUser, + isDirty: _isDirty, + revisions: globalStylesRevisions, + isLoadingGlobalStylesRevisions: isResolving( + 'getCurrentThemeGlobalStylesRevisions' + ), + }; + }, [] ); return useMemo( () => { let _modifiedRevisions = []; - if ( ! authors.length || ! revisions.length ) { + if ( ! authors.length || isLoadingGlobalStylesRevisions ) { return { revisions: _modifiedRevisions, hasUnsavedChanges: isDirty, @@ -69,25 +72,39 @@ export default function useGlobalStylesRevisions() { }; } ); - // Flags the most current saved revision. - if ( _modifiedRevisions[ 0 ].id !== 'unsaved' ) { - _modifiedRevisions[ 0 ].isLatest = true; - } + if ( _modifiedRevisions.length ) { + // Flags the most current saved revision. + if ( _modifiedRevisions[ 0 ].id !== 'unsaved' ) { + _modifiedRevisions[ 0 ].isLatest = true; + } - // Adds an item for unsaved changes. - if ( isDirty && ! isEmpty( userConfig ) && currentUser ) { - const unsavedRevision = { - id: 'unsaved', - styles: userConfig?.styles, - settings: userConfig?.settings, - author: { - name: currentUser?.name, - avatar_urls: currentUser?.avatar_urls, - }, - modified: new Date(), - }; + // Adds an item for unsaved changes. + if ( + isDirty && + userConfig && + Object.keys( userConfig ).length > 0 && + currentUser + ) { + const unsavedRevision = { + id: 'unsaved', + styles: userConfig?.styles, + settings: userConfig?.settings, + behaviors: userConfig?.behaviors, + author: { + name: currentUser?.name, + avatar_urls: currentUser?.avatar_urls, + }, + modified: new Date(), + }; + + _modifiedRevisions.unshift( unsavedRevision ); + } - _modifiedRevisions.unshift( unsavedRevision ); + _modifiedRevisions.push( { + id: 'parent', + styles: {}, + settings: {}, + } ); } return { @@ -95,5 +112,12 @@ export default function useGlobalStylesRevisions() { hasUnsavedChanges: isDirty, isLoading: false, }; - }, [ isDirty, revisions, currentUser, authors, userConfig ] ); + }, [ + isDirty, + revisions, + currentUser, + authors, + userConfig, + isLoadingGlobalStylesRevisions, + ] ); } diff --git a/packages/edit-site/src/components/global-styles/screen-root.js b/packages/edit-site/src/components/global-styles/screen-root.js index 1d9be8b11b3f33..c2a7d1fc5909c7 100644 --- a/packages/edit-site/src/components/global-styles/screen-root.js +++ b/packages/edit-site/src/components/global-styles/screen-root.js @@ -25,7 +25,7 @@ import { IconWithCurrentColor } from './icon-with-current-color'; import { NavigationButtonAsItem } from './navigation-button'; import RootMenu from './root-menu'; import StylesPreview from './preview'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; function ScreenRoot() { const { useGlobalStyle } = unlock( blockEditorPrivateApis ); diff --git a/packages/edit-site/src/components/global-styles/screen-typography.js b/packages/edit-site/src/components/global-styles/screen-typography.js index bfbff7f180ff35..0c2ed31fae17a5 100644 --- a/packages/edit-site/src/components/global-styles/screen-typography.js +++ b/packages/edit-site/src/components/global-styles/screen-typography.js @@ -16,8 +16,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; import ScreenHeader from './header'; import { NavigationButtonAsItem } from './navigation-button'; import Subtitle from './subtitle'; -import BlockPreviewPanel from './block-preview-panel'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalStyle } = unlock( blockEditorPrivateApis ); @@ -83,9 +82,6 @@ function ScreenTypography() { 'Manage the typography settings for different elements.' ) } /> - - <BlockPreviewPanel /> - <div className="edit-site-global-styles-screen-typography"> <VStack spacing={ 3 }> <Subtitle level={ 3 }>{ __( 'Elements' ) }</Subtitle> diff --git a/packages/edit-site/src/components/global-styles/stories/index.js b/packages/edit-site/src/components/global-styles/stories/index.js deleted file mode 100644 index f915794e5ecd06..00000000000000 --- a/packages/edit-site/src/components/global-styles/stories/index.js +++ /dev/null @@ -1,425 +0,0 @@ -/** - * WordPress dependencies - */ -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; -import { useMemo, useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { mergeBaseAndUserConfigs } from '../global-styles-provider'; -import { default as GlobalStylesUIComponent } from '../ui'; -import { unlock } from '../../../private-apis'; - -const { GlobalStylesContext, ExperimentalBlockEditorProvider } = unlock( - blockEditorPrivateApis -); - -export default { title: 'EditSite/GlobalStylesUI' }; - -const BASE_SETTINGS = { - settings: { - appearanceTools: false, - useRootPaddingAwareAlignments: true, - border: { - color: true, - radius: true, - style: true, - width: true, - }, - color: { - background: true, - custom: true, - customDuotone: true, - customGradient: true, - defaultDuotone: true, - defaultGradients: true, - defaultPalette: true, - duotone: { - default: [ - { - name: 'Dark grayscale', - colors: [ '#000000', '#7f7f7f' ], - slug: 'dark-grayscale', - }, - { - name: 'Grayscale', - colors: [ '#000000', '#ffffff' ], - slug: 'grayscale', - }, - { - name: 'Purple and yellow', - colors: [ '#8c00b7', '#fcff41' ], - slug: 'purple-yellow', - }, - { - name: 'Blue and red', - colors: [ '#000097', '#ff4747' ], - slug: 'blue-red', - }, - { - name: 'Midnight', - colors: [ '#000000', '#00a5ff' ], - slug: 'midnight', - }, - { - name: 'Magenta and yellow', - colors: [ '#c7005a', '#fff278' ], - slug: 'magenta-yellow', - }, - { - name: 'Purple and green', - colors: [ '#a60072', '#67ff66' ], - slug: 'purple-green', - }, - { - name: 'Blue and orange', - colors: [ '#1900d8', '#ffa96b' ], - slug: 'blue-orange', - }, - ], - }, - gradients: { - default: [ - { - name: 'Vivid cyan blue to vivid purple', - gradient: - 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - slug: 'vivid-cyan-blue-to-vivid-purple', - }, - { - name: 'Light green cyan to vivid green cyan', - gradient: - 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', - slug: 'light-green-cyan-to-vivid-green-cyan', - }, - { - name: 'Luminous vivid amber to luminous vivid orange', - gradient: - 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', - slug: 'luminous-vivid-amber-to-luminous-vivid-orange', - }, - { - name: 'Luminous vivid orange to vivid red', - gradient: - 'linear-gradient(135deg,rgba(255,105,0,1) 0%,rgb(207,46,46) 100%)', - slug: 'luminous-vivid-orange-to-vivid-red', - }, - { - name: 'Very light gray to cyan bluish gray', - gradient: - 'linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%)', - slug: 'very-light-gray-to-cyan-bluish-gray', - }, - { - name: 'Cool to warm spectrum', - gradient: - 'linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%)', - slug: 'cool-to-warm-spectrum', - }, - { - name: 'Blush light purple', - gradient: - 'linear-gradient(135deg,rgb(255,206,236) 0%,rgb(152,150,240) 100%)', - slug: 'blush-light-purple', - }, - { - name: 'Blush bordeaux', - gradient: - 'linear-gradient(135deg,rgb(254,205,165) 0%,rgb(254,45,45) 50%,rgb(107,0,62) 100%)', - slug: 'blush-bordeaux', - }, - { - name: 'Luminous dusk', - gradient: - 'linear-gradient(135deg,rgb(255,203,112) 0%,rgb(199,81,192) 50%,rgb(65,88,208) 100%)', - slug: 'luminous-dusk', - }, - { - name: 'Pale ocean', - gradient: - 'linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%)', - slug: 'pale-ocean', - }, - { - name: 'Electric grass', - gradient: - 'linear-gradient(135deg,rgb(202,248,128) 0%,rgb(113,206,126) 100%)', - slug: 'electric-grass', - }, - { - name: 'Midnight', - gradient: - 'linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%)', - slug: 'midnight', - }, - ], - }, - link: true, - palette: { - default: [ - { - name: 'Black', - slug: 'black', - color: '#000000', - }, - { - name: 'Cyan bluish gray', - slug: 'cyan-bluish-gray', - color: '#abb8c3', - }, - { - name: 'White', - slug: 'white', - color: '#ffffff', - }, - { - name: 'Pale pink', - slug: 'pale-pink', - color: '#f78da7', - }, - { - name: 'Vivid red', - slug: 'vivid-red', - color: '#cf2e2e', - }, - { - name: 'Luminous vivid orange', - slug: 'luminous-vivid-orange', - color: '#ff6900', - }, - { - name: 'Luminous vivid amber', - slug: 'luminous-vivid-amber', - color: '#fcb900', - }, - { - name: 'Light green cyan', - slug: 'light-green-cyan', - color: '#7bdcb5', - }, - { - name: 'Vivid green cyan', - slug: 'vivid-green-cyan', - color: '#00d084', - }, - { - name: 'Pale cyan blue', - slug: 'pale-cyan-blue', - color: '#8ed1fc', - }, - { - name: 'Vivid cyan blue', - slug: 'vivid-cyan-blue', - color: '#0693e3', - }, - { - name: 'Vivid purple', - slug: 'vivid-purple', - color: '#9b51e0', - }, - ], - theme: [ - { - color: '#ffffff', - name: 'Base', - slug: 'base', - }, - { - color: '#000000', - name: 'Contrast', - slug: 'contrast', - }, - { - color: '#9DFF20', - name: 'Primary', - slug: 'primary', - }, - { - color: '#345C00', - name: 'Secondary', - slug: 'secondary', - }, - { - color: '#F6F6F6', - name: 'Tertiary', - slug: 'tertiary', - }, - ], - }, - text: true, - }, - shadow: { - defaultPresets: true, - presets: { - default: [ - { - name: 'Natural', - slug: 'natural', - shadow: '6px 6px 9px rgba(0, 0, 0, 0.2)', - }, - { - name: 'Deep', - slug: 'deep', - shadow: '12px 12px 50px rgba(0, 0, 0, 0.4)', - }, - { - name: 'Sharp', - slug: 'sharp', - shadow: '6px 6px 0px rgba(0, 0, 0, 0.2)', - }, - { - name: 'Outlined', - slug: 'outlined', - shadow: '6px 6px 0px -3px rgba(255, 255, 255, 1), 6px 6px rgba(0, 0, 0, 1)', - }, - { - name: 'Crisp', - slug: 'crisp', - shadow: '6px 6px 0px rgba(0, 0, 0, 1)', - }, - ], - }, - }, - layout: { - contentSize: '650px', - wideSize: '1200px', - }, - spacing: { - blockGap: true, - margin: true, - padding: true, - customSpacingSize: true, - units: [ '%', 'px', 'em', 'rem', 'vh', 'vw' ], - spacingScale: { - operator: '*', - increment: 1.5, - steps: 0, - mediumStep: 1.5, - unit: 'rem', - }, - spacingSizes: { - theme: [ - { - size: 'clamp(1.5rem, 5vw, 2rem)', - slug: '30', - name: '1', - }, - { - size: 'clamp(1.8rem, 1.8rem + ((1vw - 0.48rem) * 2.885), 3rem)', - slug: '40', - name: '2', - }, - { - size: 'clamp(2.5rem, 8vw, 4.5rem)', - slug: '50', - name: '3', - }, - { - size: 'clamp(3.75rem, 10vw, 7rem)', - slug: '60', - name: '4', - }, - { - size: 'clamp(5rem, 5.25rem + ((1vw - 0.48rem) * 9.096), 8rem)', - slug: '70', - name: '5', - }, - { - size: 'clamp(7rem, 14vw, 11rem)', - slug: '80', - name: '6', - }, - ], - }, - }, - typography: { - customFontSize: true, - dropCap: false, - fontSizes: { - default: [ - { - name: 'Small', - slug: 'small', - size: '13px', - }, - { - name: 'Medium', - slug: 'medium', - size: '20px', - }, - { - name: 'Large', - slug: 'large', - size: '36px', - }, - { - name: 'Extra Large', - slug: 'x-large', - size: '42px', - }, - ], - }, - fontStyle: true, - fontWeight: true, - letterSpacing: true, - lineHeight: true, - textColumns: false, - textDecoration: true, - textTransform: true, - fluid: true, - fontFamilies: { - theme: [ - { - fontFamily: - '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', - name: 'System Font', - slug: 'system-font', - }, - ], - }, - }, - dimensions: { - minHeight: true, - }, - position: { - fixed: true, - sticky: true, - }, - }, - styles: { - blocks: {}, - elements: {}, - }, -}; - -export const GlobalStylesUI = () => { - const [ userGlobalStyles, setUserStyles ] = useState( { - settings: {}, - styles: {}, - } ); - const context = useMemo( () => { - return { - isReady: true, - user: userGlobalStyles, - base: BASE_SETTINGS, - merged: mergeBaseAndUserConfigs( BASE_SETTINGS, userGlobalStyles ), - setUserConfig: setUserStyles, - }; - }, [ userGlobalStyles, setUserStyles ] ); - const wrapperStyle = { - width: 280, - }; - return ( - <ExperimentalBlockEditorProvider> - <GlobalStylesContext.Provider value={ context }> - <div style={ wrapperStyle }> - <GlobalStylesUIComponent - isStyleBookOpened={ false } - onCloseStyleBook={ () => {} } - /> - </div> - </GlobalStylesContext.Provider> - </ExperimentalBlockEditorProvider> - ); -}; diff --git a/packages/edit-site/src/components/global-styles/stories/index.story.js b/packages/edit-site/src/components/global-styles/stories/index.story.js new file mode 100644 index 00000000000000..f04387295c458a --- /dev/null +++ b/packages/edit-site/src/components/global-styles/stories/index.story.js @@ -0,0 +1,426 @@ +/** + * WordPress dependencies + */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { useMemo, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { mergeBaseAndUserConfigs } from '../global-styles-provider'; +import { default as GlobalStylesUIComponent } from '../ui'; +import { unlock } from '../../../lock-unlock'; + +const { GlobalStylesContext, ExperimentalBlockEditorProvider } = unlock( + blockEditorPrivateApis +); + +export default { title: 'EditSite/GlobalStylesUI' }; + +const BASE_SETTINGS = { + settings: { + appearanceTools: false, + useRootPaddingAwareAlignments: true, + border: { + color: true, + radius: true, + style: true, + width: true, + }, + color: { + background: true, + custom: true, + customDuotone: true, + customGradient: true, + defaultDuotone: true, + defaultGradients: true, + defaultPalette: true, + duotone: { + default: [ + { + name: 'Dark grayscale', + colors: [ '#000000', '#7f7f7f' ], + slug: 'dark-grayscale', + }, + { + name: 'Grayscale', + colors: [ '#000000', '#ffffff' ], + slug: 'grayscale', + }, + { + name: 'Purple and yellow', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'purple-yellow', + }, + { + name: 'Blue and red', + colors: [ '#000097', '#ff4747' ], + slug: 'blue-red', + }, + { + name: 'Midnight', + colors: [ '#000000', '#00a5ff' ], + slug: 'midnight', + }, + { + name: 'Magenta and yellow', + colors: [ '#c7005a', '#fff278' ], + slug: 'magenta-yellow', + }, + { + name: 'Purple and green', + colors: [ '#a60072', '#67ff66' ], + slug: 'purple-green', + }, + { + name: 'Blue and orange', + colors: [ '#1900d8', '#ffa96b' ], + slug: 'blue-orange', + }, + ], + }, + gradients: { + default: [ + { + name: 'Vivid cyan blue to vivid purple', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'vivid-cyan-blue-to-vivid-purple', + }, + { + name: 'Light green cyan to vivid green cyan', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'light-green-cyan-to-vivid-green-cyan', + }, + { + name: 'Luminous vivid amber to luminous vivid orange', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'luminous-vivid-amber-to-luminous-vivid-orange', + }, + { + name: 'Luminous vivid orange to vivid red', + gradient: + 'linear-gradient(135deg,rgba(255,105,0,1) 0%,rgb(207,46,46) 100%)', + slug: 'luminous-vivid-orange-to-vivid-red', + }, + { + name: 'Very light gray to cyan bluish gray', + gradient: + 'linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%)', + slug: 'very-light-gray-to-cyan-bluish-gray', + }, + { + name: 'Cool to warm spectrum', + gradient: + 'linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%)', + slug: 'cool-to-warm-spectrum', + }, + { + name: 'Blush light purple', + gradient: + 'linear-gradient(135deg,rgb(255,206,236) 0%,rgb(152,150,240) 100%)', + slug: 'blush-light-purple', + }, + { + name: 'Blush bordeaux', + gradient: + 'linear-gradient(135deg,rgb(254,205,165) 0%,rgb(254,45,45) 50%,rgb(107,0,62) 100%)', + slug: 'blush-bordeaux', + }, + { + name: 'Luminous dusk', + gradient: + 'linear-gradient(135deg,rgb(255,203,112) 0%,rgb(199,81,192) 50%,rgb(65,88,208) 100%)', + slug: 'luminous-dusk', + }, + { + name: 'Pale ocean', + gradient: + 'linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%)', + slug: 'pale-ocean', + }, + { + name: 'Electric grass', + gradient: + 'linear-gradient(135deg,rgb(202,248,128) 0%,rgb(113,206,126) 100%)', + slug: 'electric-grass', + }, + { + name: 'Midnight', + gradient: + 'linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%)', + slug: 'midnight', + }, + ], + }, + link: true, + palette: { + default: [ + { + name: 'Black', + slug: 'black', + color: '#000000', + }, + { + name: 'Cyan bluish gray', + slug: 'cyan-bluish-gray', + color: '#abb8c3', + }, + { + name: 'White', + slug: 'white', + color: '#ffffff', + }, + { + name: 'Pale pink', + slug: 'pale-pink', + color: '#f78da7', + }, + { + name: 'Vivid red', + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: 'Luminous vivid orange', + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: 'Luminous vivid amber', + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + { + name: 'Light green cyan', + slug: 'light-green-cyan', + color: '#7bdcb5', + }, + { + name: 'Vivid green cyan', + slug: 'vivid-green-cyan', + color: '#00d084', + }, + { + name: 'Pale cyan blue', + slug: 'pale-cyan-blue', + color: '#8ed1fc', + }, + { + name: 'Vivid cyan blue', + slug: 'vivid-cyan-blue', + color: '#0693e3', + }, + { + name: 'Vivid purple', + slug: 'vivid-purple', + color: '#9b51e0', + }, + ], + theme: [ + { + color: '#ffffff', + name: 'Base', + slug: 'base', + }, + { + color: '#000000', + name: 'Contrast', + slug: 'contrast', + }, + { + color: '#9DFF20', + name: 'Primary', + slug: 'primary', + }, + { + color: '#345C00', + name: 'Secondary', + slug: 'secondary', + }, + { + color: '#F6F6F6', + name: 'Tertiary', + slug: 'tertiary', + }, + ], + }, + text: true, + }, + shadow: { + defaultPresets: true, + presets: { + default: [ + { + name: 'Natural', + slug: 'natural', + shadow: '6px 6px 9px rgba(0, 0, 0, 0.2)', + }, + { + name: 'Deep', + slug: 'deep', + shadow: '12px 12px 50px rgba(0, 0, 0, 0.4)', + }, + { + name: 'Sharp', + slug: 'sharp', + shadow: '6px 6px 0px rgba(0, 0, 0, 0.2)', + }, + { + name: 'Outlined', + slug: 'outlined', + shadow: '6px 6px 0px -3px rgba(255, 255, 255, 1), 6px 6px rgba(0, 0, 0, 1)', + }, + { + name: 'Crisp', + slug: 'crisp', + shadow: '6px 6px 0px rgba(0, 0, 0, 1)', + }, + ], + }, + }, + layout: { + contentSize: '650px', + wideSize: '1200px', + }, + spacing: { + blockGap: true, + margin: true, + padding: true, + customSpacingSize: true, + units: [ '%', 'px', 'em', 'rem', 'vh', 'vw' ], + spacingScale: { + operator: '*', + increment: 1.5, + steps: 0, + mediumStep: 1.5, + unit: 'rem', + }, + spacingSizes: { + theme: [ + { + size: 'clamp(1.5rem, 5vw, 2rem)', + slug: '30', + name: '1', + }, + { + size: 'clamp(1.8rem, 1.8rem + ((1vw - 0.48rem) * 2.885), 3rem)', + slug: '40', + name: '2', + }, + { + size: 'clamp(2.5rem, 8vw, 4.5rem)', + slug: '50', + name: '3', + }, + { + size: 'clamp(3.75rem, 10vw, 7rem)', + slug: '60', + name: '4', + }, + { + size: 'clamp(5rem, 5.25rem + ((1vw - 0.48rem) * 9.096), 8rem)', + slug: '70', + name: '5', + }, + { + size: 'clamp(7rem, 14vw, 11rem)', + slug: '80', + name: '6', + }, + ], + }, + }, + typography: { + customFontSize: true, + dropCap: false, + fontSizes: { + default: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + { + name: 'Extra Large', + slug: 'x-large', + size: '42px', + }, + ], + }, + fontStyle: true, + fontWeight: true, + letterSpacing: true, + lineHeight: true, + textColumns: false, + textDecoration: true, + textTransform: true, + writingMode: false, + fluid: true, + fontFamilies: { + theme: [ + { + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', + name: 'System Font', + slug: 'system-font', + }, + ], + }, + }, + dimensions: { + minHeight: true, + }, + position: { + fixed: true, + sticky: true, + }, + }, + styles: { + blocks: {}, + elements: {}, + }, +}; + +export const GlobalStylesUI = () => { + const [ userGlobalStyles, setUserStyles ] = useState( { + settings: {}, + styles: {}, + } ); + const context = useMemo( () => { + return { + isReady: true, + user: userGlobalStyles, + base: BASE_SETTINGS, + merged: mergeBaseAndUserConfigs( BASE_SETTINGS, userGlobalStyles ), + setUserConfig: setUserStyles, + }; + }, [ userGlobalStyles, setUserStyles ] ); + const wrapperStyle = { + width: 280, + }; + return ( + <ExperimentalBlockEditorProvider> + <GlobalStylesContext.Provider value={ context }> + <div style={ wrapperStyle }> + <GlobalStylesUIComponent + isStyleBookOpened={ false } + onCloseStyleBook={ () => {} } + /> + </div> + </GlobalStylesContext.Provider> + </ExperimentalBlockEditorProvider> + ); +}; diff --git a/packages/edit-site/src/components/global-styles/style-variations-container.js b/packages/edit-site/src/components/global-styles/style-variations-container.js index 9ab54f6e070caf..69a66a707d2528 100644 --- a/packages/edit-site/src/components/global-styles/style-variations-container.js +++ b/packages/edit-site/src/components/global-styles/style-variations-container.js @@ -11,7 +11,7 @@ import { useSelect } from '@wordpress/data'; import { useMemo, useContext, useState } from '@wordpress/element'; import { ENTER } from '@wordpress/keycodes'; import { __experimentalGrid as Grid } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** @@ -19,7 +19,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; */ import { mergeBaseAndUserConfigs } from './global-styles-provider'; import StylesPreview from './preview'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { GlobalStylesContext, areGlobalStyleConfigsEqual } = unlock( blockEditorPrivateApis @@ -60,6 +60,16 @@ function Variation( { variation } ) { return areGlobalStyleConfigsEqual( user, variation ); }, [ user, variation ] ); + let label = variation?.title; + if ( variation?.description ) { + label = sprintf( + /* translators: %1$s: variation title. %2$s variation description. */ + __( '%1$s (%2$s)' ), + variation?.title, + variation?.description + ); + } + return ( <GlobalStylesContext.Provider value={ context }> <div @@ -73,7 +83,7 @@ function Variation( { variation } ) { onClick={ selectVariation } onKeyDown={ selectOnEnter } tabIndex="0" - aria-label={ variation?.title } + aria-label={ label } aria-current={ isActive } onFocus={ () => setIsFocused( true ) } onBlur={ () => setIsFocused( false ) } @@ -103,11 +113,13 @@ export default function StyleVariationsContainer() { title: __( 'Default' ), settings: {}, styles: {}, + behaviors: {}, }, ...( variations ?? [] ).map( ( variation ) => ( { ...variation, settings: variation.settings ?? {}, styles: variation.styles ?? {}, + behaviors: variation.behaviors ?? {}, } ) ), ]; }, [ variations ] ); diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss index 8bc5efab2a4acc..07f3c7a95c800d 100644 --- a/packages/edit-site/src/components/global-styles/style.scss +++ b/packages/edit-site/src/components/global-styles/style.scss @@ -179,3 +179,14 @@ .edit-site-global-styles-sidebar__panel .block-editor-block-icon svg { fill: currentColor; } + +[class][class].edit-site-global-styles-sidebar__revisions-count-badge { + align-items: center; + background: $gray-800; + border-radius: 2px; + color: $white; + display: inline-flex; + justify-content: center; + min-height: $icon-size; + min-width: $icon-size; +} diff --git a/packages/edit-site/src/components/global-styles/typography-panel.js b/packages/edit-site/src/components/global-styles/typography-panel.js index ce5aa95b7246e8..795a101bed839f 100644 --- a/packages/edit-site/src/components/global-styles/typography-panel.js +++ b/packages/edit-site/src/components/global-styles/typography-panel.js @@ -6,7 +6,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalStyle, diff --git a/packages/edit-site/src/components/global-styles/typography-preview.js b/packages/edit-site/src/components/global-styles/typography-preview.js index 34a64e23fc1c79..91891bb661ca8e 100644 --- a/packages/edit-site/src/components/global-styles/typography-preview.js +++ b/packages/edit-site/src/components/global-styles/typography-preview.js @@ -6,7 +6,7 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useGlobalStyle } = unlock( blockEditorPrivateApis ); diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index f15fcff11aa560..2ac94a78609847 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -6,7 +11,10 @@ import { __experimentalNavigatorScreen as NavigatorScreen, __experimentalUseNavigator as useNavigator, createSlotFill, + Button, DropdownMenu, + MenuGroup, + MenuItem, } from '@wordpress/components'; import { getBlockTypes, store as blocksStore } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -14,9 +22,9 @@ import { privateApis as blockEditorPrivateApis, store as blockEditorStore, } from '@wordpress/block-editor'; -import { __, sprintf, _n } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { store as preferencesStore } from '@wordpress/preferences'; -import { moreVertical } from '@wordpress/icons'; +import { backup, moreVertical } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { useEffect } from '@wordpress/element'; @@ -38,7 +46,7 @@ import ScreenStyleVariations from './screen-style-variations'; import StyleBook from '../style-book'; import ScreenCSS from './screen-css'; import ScreenRevisions from './screen-revisions'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; const SLOT_FILL_NAME = 'GlobalStylesMenu'; @@ -47,7 +55,7 @@ const { Slot: GlobalStylesMenuSlot, Fill: GlobalStylesMenuFill } = function GlobalStylesActionMenu() { const { toggle } = useDispatch( preferencesStore ); - const { canEditCSS, revisionsCount } = useSelect( ( select ) => { + const { canEditCSS } = useSelect( ( select ) => { const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = select( coreStore ); @@ -59,6 +67,63 @@ function GlobalStylesActionMenu() { return { canEditCSS: !! globalStyles?._links?.[ 'wp:action-edit-css' ] ?? false, + }; + }, [] ); + const { goTo } = useNavigator(); + const loadCustomCSS = () => goTo( '/css' ); + + return ( + <GlobalStylesMenuFill> + <DropdownMenu icon={ moreVertical } label={ __( 'More' ) }> + { ( { onClose } ) => ( + <MenuGroup> + { canEditCSS && ( + <MenuItem onClick={ loadCustomCSS }> + { __( 'Additional CSS' ) } + </MenuItem> + ) } + <MenuItem + onClick={ () => { + toggle( + 'core/edit-site', + 'welcomeGuideStyles' + ); + onClose(); + } } + > + { __( 'Welcome Guide' ) } + </MenuItem> + </MenuGroup> + ) } + </DropdownMenu> + </GlobalStylesMenuFill> + ); +} + +function RevisionsCountBadge( { className, children } ) { + return ( + <span + className={ classnames( + className, + 'edit-site-global-styles-sidebar__revisions-count-badge' + ) } + > + { children } + </span> + ); +} +function GlobalStylesRevisionsMenu() { + const { setIsListViewOpened } = useDispatch( editSiteStore ); + const { revisionsCount } = useSelect( ( select ) => { + const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = + select( coreStore ); + + const globalStylesId = __experimentalGetCurrentGlobalStylesId(); + const globalStyles = globalStylesId + ? getEntityRecord( 'root', 'globalStyles', globalStylesId ) + : undefined; + + return { revisionsCount: globalStyles?._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0, }; @@ -69,55 +134,51 @@ function GlobalStylesActionMenu() { const { setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) ); - const loadCustomCSS = () => goTo( '/css' ); const loadRevisions = () => { + setIsListViewOpened( false ); goTo( '/revisions' ); setEditorCanvasContainerView( 'global-styles-revisions' ); }; - const hasRevisions = revisionsCount >= 2; + const hasRevisions = revisionsCount > 0; return ( <GlobalStylesMenuFill> - <DropdownMenu - icon={ moreVertical } - label={ __( 'Styles actions' ) } - controls={ [ - { - title: __( 'Reset to defaults' ), - onClick: onReset, - isDisabled: ! canReset, - }, - { - title: __( 'Welcome Guide' ), - onClick: () => - toggle( 'core/edit-site', 'welcomeGuideStyles' ), - }, - ...( canEditCSS - ? [ - { - title: __( 'Additional CSS' ), - onClick: loadCustomCSS, - }, - ] - : [] ), - ...( hasRevisions - ? [ - { - title: sprintf( - /* translators: %d: number of revisions */ - _n( - '%d Revision', - '%d Revisions', - revisionsCount - ), - revisionsCount - ), - onClick: loadRevisions, - }, - ] - : [] ), - ] } - /> + { canReset || hasRevisions ? ( + <DropdownMenu icon={ backup } label={ __( 'Revisions' ) }> + { ( { onClose } ) => ( + <MenuGroup> + { hasRevisions && ( + <MenuItem + onClick={ loadRevisions } + icon={ + <RevisionsCountBadge> + { revisionsCount } + </RevisionsCountBadge> + } + > + { __( 'Revision history' ) } + </MenuItem> + ) } + <MenuItem + onClick={ () => { + onReset(); + onClose(); + } } + disabled={ ! canReset } + > + { __( 'Reset to defaults' ) } + </MenuItem> + </MenuGroup> + ) } + </DropdownMenu> + ) : ( + <Button + label={ __( 'Revisions' ) } + icon={ backup } + disabled + __experimentalIsFocusable + /> + ) } </GlobalStylesMenuFill> ); } @@ -237,6 +298,40 @@ function GlobalStylesBlockLink() { }, [ selectedBlockClientId, selectedBlockName, blockHasGlobalStyles ] ); } +function GlobalStylesEditorCanvasContainerLink() { + const { goTo, location } = useNavigator(); + const editorCanvasContainerView = useSelect( + ( select ) => + unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), + [] + ); + + // If the user switches the editor canvas container view, redirect + // to the appropriate screen. This effectively allows deep linking to the + // desired screens from outside the global styles navigation provider. + useEffect( () => { + if ( editorCanvasContainerView === 'global-styles-revisions' ) { + // Switching to the revisions container view should + // redirect to the revisions screen. + goTo( '/revisions' ); + } else if ( + !! editorCanvasContainerView && + location?.path === '/revisions' + ) { + // Switching to any container other than revisions should + // redirect from the revisions screen to the root global styles screen. + goTo( '/' ); + } else if ( editorCanvasContainerView === 'global-styles-css' ) { + goTo( '/css' ); + } + + // location?.path is not a dependency because we don't want to track it. + // Doing so will cause an infinite loop. We could abstract logic to avoid + // having to disable the check later. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ editorCanvasContainerView, goTo ] ); +} + function GlobalStylesUI() { const blocks = getBlockTypes(); const editorCanvasContainerView = useSelect( @@ -324,8 +419,10 @@ function GlobalStylesUI() { <GlobalStylesStyleBook /> ) } + <GlobalStylesRevisionsMenu /> <GlobalStylesActionMenu /> <GlobalStylesBlockLink /> + <GlobalStylesEditorCanvasContainerLink /> </NavigatorProvider> ); } diff --git a/packages/edit-site/src/components/header-edit-mode/document-actions/index.js b/packages/edit-site/src/components/header-edit-mode/document-actions/index.js index 94f8358fda993f..058dd1d054aed5 100644 --- a/packages/edit-site/src/components/header-edit-mode/document-actions/index.js +++ b/packages/edit-site/src/components/header-edit-mode/document-actions/index.js @@ -1,8 +1,13 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ -import { sprintf, __ } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; +import { __, isRTL } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; import { Button, VisuallyHidden, @@ -10,27 +15,111 @@ import { __experimentalHStack as HStack, } from '@wordpress/components'; import { BlockIcon } from '@wordpress/block-editor'; -import { privateApis as commandsPrivateApis } from '@wordpress/commands'; +import { store as commandsStore } from '@wordpress/commands'; +import { + chevronLeftSmall, + chevronRightSmall, + page as pageIcon, + navigation as navigationIcon, + symbol, +} from '@wordpress/icons'; import { displayShortcut } from '@wordpress/keycodes'; +import { useState, useEffect, useRef } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ import useEditedEntityRecord from '../../use-edited-entity-record'; -import { unlock } from '../../../private-apis'; +import { store as editSiteStore } from '../../../store'; -const { store: commandsStore } = unlock( commandsPrivateApis ); +const typeLabels = { + wp_block: __( 'Editing pattern:' ), + wp_navigation: __( 'Editing navigation menu:' ), + wp_template: __( 'Editing template:' ), + wp_template_part: __( 'Editing template part:' ), +}; export default function DocumentActions() { - const { open: openCommandCenter } = useDispatch( commandsStore ); + const isPage = useSelect( + ( select ) => select( editSiteStore ).isPage(), + [] + ); + return isPage ? <PageDocumentActions /> : <TemplateDocumentActions />; +} + +function PageDocumentActions() { + const { hasPageContentFocus, hasResolved, isFound, title } = useSelect( + ( select ) => { + const { + hasPageContentFocus: _hasPageContentFocus, + getEditedPostContext, + } = select( editSiteStore ); + const { getEditedEntityRecord, hasFinishedResolution } = + select( coreStore ); + const context = getEditedPostContext(); + const queryArgs = [ 'postType', context.postType, context.postId ]; + const page = getEditedEntityRecord( ...queryArgs ); + return { + hasPageContentFocus: _hasPageContentFocus(), + hasResolved: hasFinishedResolution( + 'getEditedEntityRecord', + queryArgs + ), + isFound: !! page, + title: page?.title, + }; + }, + [] + ); + + const { setHasPageContentFocus } = useDispatch( editSiteStore ); + + const [ hasEditedTemplate, setHasEditedTemplate ] = useState( false ); + const prevHasPageContentFocus = useRef( false ); + useEffect( () => { + if ( prevHasPageContentFocus.current && ! hasPageContentFocus ) { + setHasEditedTemplate( true ); + } + prevHasPageContentFocus.current = hasPageContentFocus; + }, [ hasPageContentFocus ] ); + + if ( ! hasResolved ) { + return null; + } + + if ( ! isFound ) { + return ( + <div className="edit-site-document-actions"> + { __( 'Document not found' ) } + </div> + ); + } + + return hasPageContentFocus ? ( + <BaseDocumentActions + className={ classnames( 'is-page', { + 'is-animated': hasEditedTemplate, + } ) } + icon={ pageIcon } + > + { title } + </BaseDocumentActions> + ) : ( + <TemplateDocumentActions + className="is-animated" + onBack={ () => setHasPageContentFocus( true ) } + /> + ); +} + +function TemplateDocumentActions( { className, onBack } ) { const { isLoaded, record, getTitle, icon } = useEditedEntityRecord(); - // Return a simple loading indicator until we have information to show. if ( ! isLoaded ) { return null; } - // Return feedback that the template does not seem to exist. if ( ! record ) { return ( <div className="edit-site-document-actions"> @@ -39,37 +128,66 @@ export default function DocumentActions() { ); } - const entityLabel = - record.type === 'wp_template_part' - ? __( 'template part' ) - : __( 'template' ); + let typeIcon = icon; + if ( record.type === 'wp_navigation' ) { + typeIcon = navigationIcon; + } else if ( record.type === 'wp_block' ) { + typeIcon = symbol; + } + + return ( + <BaseDocumentActions + className={ classnames( className, { + 'is-synced-entity': + record.wp_pattern_sync_status !== 'unsynced', + } ) } + icon={ typeIcon } + onBack={ onBack } + > + <VisuallyHidden as="span"> + { typeLabels[ record.type ] ?? typeLabels.wp_template } + </VisuallyHidden> + { getTitle() } + </BaseDocumentActions> + ); +} +function BaseDocumentActions( { className, icon, children, onBack } ) { + const { open: openCommandCenter } = useDispatch( commandsStore ); return ( - <Button - className="edit-site-document-actions" - onClick={ () => openCommandCenter() } + <div + className={ classnames( 'edit-site-document-actions', className ) } > - <span className="edit-site-document-actions__left"></span> - <HStack - spacing={ 1 } - justify="center" - className="edit-site-document-actions__title" + { onBack && ( + <Button + className="edit-site-document-actions__back" + icon={ isRTL() ? chevronRightSmall : chevronLeftSmall } + onClick={ ( event ) => { + event.stopPropagation(); + onBack(); + } } + > + { __( 'Back' ) } + </Button> + ) } + <Button + className="edit-site-document-actions__command" + onClick={ () => openCommandCenter() } > - <BlockIcon icon={ icon } /> - <Text size="body" as="h1"> - <VisuallyHidden as="span"> - { sprintf( - /* translators: %s: the entity being edited, like "template"*/ - __( 'Editing %s: ' ), - entityLabel - ) } - </VisuallyHidden> - { getTitle() } - </Text> - </HStack> - <span className="edit-site-document-actions__shortcut"> - { displayShortcut.primary( 'k' ) } - </span> - </Button> + <HStack + className="edit-site-document-actions__title" + spacing={ 1 } + justify="center" + > + <BlockIcon icon={ icon } /> + <Text size="body" as="h1"> + { children } + </Text> + </HStack> + <span className="edit-site-document-actions__shortcut"> + { displayShortcut.primary( 'k' ) } + </span> + </Button> + </div> ); } diff --git a/packages/edit-site/src/components/header-edit-mode/document-actions/style.scss b/packages/edit-site/src/components/header-edit-mode/document-actions/style.scss index 247b901975fd8e..be34a9696a3fde 100644 --- a/packages/edit-site/src/components/header-edit-mode/document-actions/style.scss +++ b/packages/edit-site/src/components/header-edit-mode/document-actions/style.scss @@ -1,9 +1,7 @@ .edit-site-document-actions { display: flex; align-items: center; - gap: $grid-unit; height: $button-size; - padding: $grid-unit; justify-content: space-between; // Flex items will, by default, refuse to shrink below a minimum // intrinsic width. In order to shrink this flexbox item, and @@ -11,38 +9,137 @@ // See https://dev.w3.org/csswg/css-flexbox/#min-size-auto min-width: 0; background: $gray-100; - border-radius: 4px; + border-radius: $grid-unit-05; width: min(100%, 450px); + // Make the document title shorter in top-toolbar mode, as it has to be covered. + .has-fixed-toolbar & { + width: min(100%, 380px); + } + &:hover { - color: currentColor; - background: $gray-200; + background-color: $gray-200; + } + + .components-button { + border-radius: $grid-unit-05; + + &:hover { + color: var(--wp-block-synced-color); + background: $gray-200; + } + } + + @include break-large() { + width: min(100%, 450px); + } + + &.is-synced-entity { + .edit-site-document-actions__title { + color: var(--wp-block-synced-color); + h1 { + color: var(--wp-block-synced-color); + } + } } } -.edit-site-document-actions__title { +.edit-site-document-actions__command { flex-grow: 1; color: var(--wp-block-synced-color); overflow: hidden; +} + +.edit-site-document-actions__title { + flex-grow: 1; + overflow: hidden; - h1 { + // Offset the layout based on the width of the ⌘K label. This ensures the title is centrally aligned. + @include break-small() { + padding-left: $grid-unit-40; + } + + &:hover { color: var(--wp-block-synced-color); + } + + .block-editor-block-icon { + min-width: $grid-unit-30; + flex-shrink: 0; + } + + h1 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 50%; + } + + .edit-site-document-actions.is-page & { + color: $gray-800; + + h1 { + color: $gray-800; + } + } + + .edit-site-document-actions.is-animated & { + animation: edit-site-document-actions__slide-in-left 0.3s; + @include reduce-motion("animation"); + } + + .edit-site-document-actions.is-animated.is-page & { + animation: edit-site-document-actions__slide-in-right 0.3s; + @include reduce-motion("animation"); } } .edit-site-document-actions__shortcut { + color: $gray-800; + min-width: $grid-unit-40; + display: none; + @include break-small() { + display: initial; + } +} + +.edit-site-document-actions__back.components-button.has-icon.has-text { + min-width: $button-size; flex-shrink: 0; color: $gray-700; - width: #{$grid-unit * 4.5}; + gap: 0; + z-index: 1; + position: absolute; + &:hover { - color: $gray-700; + color: currentColor; + background-color: transparent; + } + + .edit-site-document-actions.is-animated & { + animation: edit-site-document-actions__slide-in-left 0.3s; + @include reduce-motion("animation"); } } -.edit-site-document-actions__left { - min-width: $button-size; - flex-shrink: 0; +@keyframes edit-site-document-actions__slide-in-right { + from { + transform: translateX(-15%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes edit-site-document-actions__slide-in-left { + from { + transform: translateX(15%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } } diff --git a/packages/edit-site/src/components/header-edit-mode/index.js b/packages/edit-site/src/components/header-edit-mode/index.js index 0878cb4faae2d2..da682e995defd3 100644 --- a/packages/edit-site/src/components/header-edit-mode/index.js +++ b/packages/edit-site/src/components/header-edit-mode/index.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { useCallback, useRef } from '@wordpress/element'; -import { useViewportMatch } from '@wordpress/compose'; +import { useViewportMatch, useReducedMotion } from '@wordpress/compose'; import { store as coreStore } from '@wordpress/core-data'; import { ToolSelector, @@ -21,6 +21,7 @@ import { PinnedItems } from '@wordpress/interface'; import { _x, __ } from '@wordpress/i18n'; import { listView, plus, external, chevronUpDown } from '@wordpress/icons'; import { + __unstableMotion as motion, Button, ToolbarItem, MenuGroup, @@ -43,7 +44,8 @@ import { getEditorCanvasContainerTitle, useHasEditorCanvasContainer, } from '../editor-canvas-container'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; +import { FOCUSABLE_ENTITIES } from '../../utils/constants'; const { useShouldContextualToolbarShow } = unlock( blockEditorPrivateApis ); @@ -60,10 +62,12 @@ export default function HeaderEditMode() { isListViewOpen, listViewShortcut, isVisualMode, + isDistractionFree, blockEditorMode, homeUrl, showIconLabels, editorCanvasView, + hasFixedToolbar, } = useSelect( ( select ) => { const { __experimentalGetPreviewDeviceType, @@ -81,6 +85,8 @@ export default function HeaderEditMode() { getUnstableBase, // Site index. } = select( coreStore ); + const { get: getPreference } = select( preferencesStore ); + return { deviceType: __experimentalGetPreviewDeviceType(), templateType: postType, @@ -92,13 +98,21 @@ export default function HeaderEditMode() { isVisualMode: getEditorMode() === 'visual', blockEditorMode: __unstableGetEditorMode(), homeUrl: getUnstableBase()?.home, - showIconLabels: select( preferencesStore ).get( - 'core/edit-site', + showIconLabels: getPreference( + editSiteStore.name, 'showIconLabels' ), editorCanvasView: unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), + isDistractionFree: getPreference( + editSiteStore.name, + 'distractionFree' + ), + hasFixedToolbar: getPreference( + editSiteStore.name, + 'fixedToolbar' + ), }; }, [] ); @@ -108,6 +122,7 @@ export default function HeaderEditMode() { setIsListViewOpened, } = useDispatch( editSiteStore ); const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); + const disableMotion = useReducedMotion(); const isLargeViewport = useViewportMatch( 'medium' ); @@ -142,7 +157,7 @@ export default function HeaderEditMode() { const hasDefaultEditorCanvasView = ! useHasEditorCanvasContainer(); - const isFocusMode = templateType === 'wp_template_part'; + const isFocusMode = FOCUSABLE_ENTITIES.includes( templateType ); /* translators: button label text should, if possible, be under 16 characters. */ const longLabel = _x( @@ -155,6 +170,19 @@ export default function HeaderEditMode() { window?.__experimentalEnableZoomedOutView && isVisualMode; const isZoomedOutView = blockEditorMode === 'zoom-out'; + const toolbarVariants = { + isDistractionFree: { y: '-50px' }, + isDistractionFreeHovering: { y: 0 }, + view: { y: 0 }, + edit: { y: 0 }, + }; + + const toolbarTransition = { + type: 'tween', + duration: disableMotion ? 0 : 0.2, + ease: 'easeOut', + }; + return ( <div className={ classnames( 'edit-site-header-edit-mode', { @@ -163,36 +191,48 @@ export default function HeaderEditMode() { > { hasDefaultEditorCanvasView && ( <NavigableToolbar + as={ motion.div } className="edit-site-header-edit-mode__start" aria-label={ __( 'Document tools' ) } shouldUseKeyboardFocusShortcut={ ! blockToolbarCanBeFocused } + variants={ toolbarVariants } + transition={ toolbarTransition } > <div className="edit-site-header-edit-mode__toolbar"> - <ToolbarItem - ref={ inserterButton } - as={ Button } - className="edit-site-header-edit-mode__inserter-toggle" - variant="primary" - isPressed={ isInserterOpen } - onMouseDown={ preventDefault } - onClick={ toggleInserter } - disabled={ ! isVisualMode } - icon={ plus } - label={ showIconLabels ? shortLabel : longLabel } - showTooltip={ ! showIconLabels } - /> + { ! isDistractionFree && ( + <ToolbarItem + ref={ inserterButton } + as={ Button } + className="edit-site-header-edit-mode__inserter-toggle" + variant="primary" + isPressed={ isInserterOpen } + onMouseDown={ preventDefault } + onClick={ toggleInserter } + disabled={ ! isVisualMode } + icon={ plus } + label={ + showIconLabels ? shortLabel : longLabel + } + showTooltip={ ! showIconLabels } + aria-expanded={ isInserterOpen } + /> + ) } { isLargeViewport && ( <> - <ToolbarItem - as={ ToolSelector } - showTooltip={ ! showIconLabels } - variant={ - showIconLabels ? 'tertiary' : undefined - } - disabled={ ! isVisualMode } - /> + { ! hasFixedToolbar && ( + <ToolbarItem + as={ ToolSelector } + showTooltip={ ! showIconLabels } + variant={ + showIconLabels + ? 'tertiary' + : undefined + } + disabled={ ! isVisualMode } + /> + ) } <ToolbarItem as={ UndoButton } showTooltip={ ! showIconLabels } @@ -207,75 +247,93 @@ export default function HeaderEditMode() { showIconLabels ? 'tertiary' : undefined } /> - <ToolbarItem - as={ Button } - className="edit-site-header-edit-mode__list-view-toggle" - disabled={ - ! isVisualMode || isZoomedOutView - } - icon={ listView } - isPressed={ isListViewOpen } - /* translators: button label text should, if possible, be under 16 characters. */ - label={ __( 'List View' ) } - onClick={ toggleListView } - shortcut={ listViewShortcut } - showTooltip={ ! showIconLabels } - variant={ - showIconLabels ? 'tertiary' : undefined - } - /> - { isZoomedOutViewExperimentEnabled && ( + { ! isDistractionFree && ( <ToolbarItem as={ Button } - className="edit-site-header-edit-mode__zoom-out-view-toggle" - icon={ chevronUpDown } - isPressed={ isZoomedOutView } + className="edit-site-header-edit-mode__list-view-toggle" + disabled={ + ! isVisualMode || isZoomedOutView + } + icon={ listView } + isPressed={ isListViewOpen } /* translators: button label text should, if possible, be under 16 characters. */ - label={ __( 'Zoom-out View' ) } - onClick={ () => { - setPreviewDeviceType( 'desktop' ); - __unstableSetEditorMode( - isZoomedOutView - ? 'edit' - : 'zoom-out' - ); - } } + label={ __( 'List View' ) } + onClick={ toggleListView } + shortcut={ listViewShortcut } + showTooltip={ ! showIconLabels } + variant={ + showIconLabels + ? 'tertiary' + : undefined + } + aria-expanded={ isListViewOpen } /> ) } + { isZoomedOutViewExperimentEnabled && + ! isDistractionFree && + ! hasFixedToolbar && ( + <ToolbarItem + as={ Button } + className="edit-site-header-edit-mode__zoom-out-view-toggle" + icon={ chevronUpDown } + isPressed={ isZoomedOutView } + /* translators: button label text should, if possible, be under 16 characters. */ + label={ __( 'Zoom-out View' ) } + onClick={ () => { + setPreviewDeviceType( + 'Desktop' + ); + __unstableSetEditorMode( + isZoomedOutView + ? 'edit' + : 'zoom-out' + ); + } } + /> + ) } </> ) } </div> </NavigableToolbar> ) } - <div className="edit-site-header-edit-mode__center"> - { ! hasDefaultEditorCanvasView ? ( - getEditorCanvasContainerTitle( editorCanvasView ) - ) : ( - <DocumentActions /> - ) } - </div> + { ! isDistractionFree && ( + <div className="edit-site-header-edit-mode__center"> + { ! hasDefaultEditorCanvasView ? ( + getEditorCanvasContainerTitle( editorCanvasView ) + ) : ( + <DocumentActions /> + ) } + </div> + ) } <div className="edit-site-header-edit-mode__end"> - <div className="edit-site-header-edit-mode__actions"> - { ! isFocusMode && hasDefaultEditorCanvasView && ( - <div - className={ classnames( - 'edit-site-header-edit-mode__preview-options', - { 'is-zoomed-out': isZoomedOutView } - ) } + <motion.div + className="edit-site-header-edit-mode__actions" + variants={ toolbarVariants } + transition={ toolbarTransition } + > + <div + className={ classnames( + 'edit-site-header-edit-mode__preview-options', + { 'is-zoomed-out': isZoomedOutView } + ) } + > + <PreviewOptions + deviceType={ deviceType } + setDeviceType={ setPreviewDeviceType } + label={ __( 'View' ) } + isEnabled={ + ! isFocusMode && hasDefaultEditorCanvasView + } > - <PreviewOptions - deviceType={ deviceType } - setDeviceType={ setPreviewDeviceType } - /* translators: button label text should, if possible, be under 16 characters. */ - viewLabel={ __( 'View' ) } - > + { ( { onClose } ) => ( <MenuGroup> <MenuItem href={ homeUrl } target="_blank" icon={ external } + onClick={ onClose } > { __( 'View site' ) } <VisuallyHidden as="span"> @@ -286,13 +344,15 @@ export default function HeaderEditMode() { </VisuallyHidden> </MenuItem> </MenuGroup> - </PreviewOptions> - </div> - ) } + ) } + </PreviewOptions> + </div> <SaveButton /> - <PinnedItems.Slot scope="core/edit-site" /> + { ! isDistractionFree && ( + <PinnedItems.Slot scope="core/edit-site" /> + ) } <MoreMenu showIconLabels={ showIconLabels } /> - </div> + </motion.div> </div> </div> ); diff --git a/packages/edit-site/src/components/header-edit-mode/more-menu/copy-content-menu-item.js b/packages/edit-site/src/components/header-edit-mode/more-menu/copy-content-menu-item.js index 87bef35a36cd66..887817692239c9 100644 --- a/packages/edit-site/src/components/header-edit-mode/more-menu/copy-content-menu-item.js +++ b/packages/edit-site/src/components/header-edit-mode/more-menu/copy-content-menu-item.js @@ -16,28 +16,27 @@ import { store as editSiteStore } from '../../../store'; export default function CopyContentMenuItem() { const { createNotice } = useDispatch( noticesStore ); - const getText = useSelect( ( select ) => { - return () => { - const { getEditedPostId, getEditedPostType } = - select( editSiteStore ); - const { getEditedEntityRecord } = select( coreStore ); - const record = getEditedEntityRecord( - 'postType', - getEditedPostType(), - getEditedPostId() - ); - if ( record ) { - if ( typeof record.content === 'function' ) { - return record.content( record ); - } else if ( record.blocks ) { - return __unstableSerializeAndClean( record.blocks ); - } else if ( record.content ) { - return record.content; - } - } + const { getEditedPostId, getEditedPostType } = useSelect( editSiteStore ); + const { getEditedEntityRecord } = useSelect( coreStore ); + + function getText() { + const record = getEditedEntityRecord( + 'postType', + getEditedPostType(), + getEditedPostId() + ); + if ( ! record ) { return ''; - }; - }, [] ); + } + + if ( typeof record.content === 'function' ) { + return record.content( record ); + } else if ( record.blocks ) { + return __unstableSerializeAndClean( record.blocks ); + } else if ( record.content ) { + return record.content; + } + } function onSuccess() { createNotice( 'info', __( 'All content copied.' ), { diff --git a/packages/edit-site/src/components/header-edit-mode/more-menu/index.js b/packages/edit-site/src/components/header-edit-mode/more-menu/index.js index a7c9ea977db176..4b9d6c40fc6eec 100644 --- a/packages/edit-site/src/components/header-edit-mode/more-menu/index.js +++ b/packages/edit-site/src/components/header-edit-mode/more-menu/index.js @@ -2,37 +2,62 @@ * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; -import { useReducer } from '@wordpress/element'; -import { useShortcut } from '@wordpress/keyboard-shortcuts'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; import { displayShortcut } from '@wordpress/keycodes'; import { external } from '@wordpress/icons'; import { MenuGroup, MenuItem, VisuallyHidden } from '@wordpress/components'; -import { ActionItem, MoreMenuDropdown } from '@wordpress/interface'; -import { PreferenceToggleMenuItem } from '@wordpress/preferences'; +import { + ActionItem, + MoreMenuDropdown, + store as interfaceStore, +} from '@wordpress/interface'; +import { + PreferenceToggleMenuItem, + store as preferencesStore, +} from '@wordpress/preferences'; /** * Internal dependencies */ -import KeyboardShortcutHelpModal from '../../keyboard-shortcut-help-modal'; -import EditSitePreferencesModal from '../../preferences-modal'; +import { + KEYBOARD_SHORTCUT_HELP_MODAL_NAME, + default as KeyboardShortcutHelpModal, +} from '../../keyboard-shortcut-help-modal'; +import { + PREFERENCES_MODAL_NAME, + default as EditSitePreferencesModal, +} from '../../preferences-modal'; import ToolsMoreMenuGroup from '../tools-more-menu-group'; import SiteExport from './site-export'; import WelcomeGuideMenuItem from './welcome-guide-menu-item'; import CopyContentMenuItem from './copy-content-menu-item'; import ModeSwitcher from '../mode-switcher'; +import { store as siteEditorStore } from '../../../store'; export default function MoreMenu( { showIconLabels } ) { - const [ isModalActive, toggleModal ] = useReducer( - ( isActive ) => ! isActive, - false + const registry = useRegistry(); + const isDistractionFree = useSelect( + ( select ) => + select( preferencesStore ).get( + 'core/edit-site', + 'distractionFree' + ), + [] ); - const [ isPreferencesModalActive, togglePreferencesModal ] = useReducer( - ( isActive ) => ! isActive, - false - ); + const { setIsInserterOpened, setIsListViewOpened, closeGeneralSidebar } = + useDispatch( siteEditorStore ); + const { openModal } = useDispatch( interfaceStore ); + const { set: setPreference } = useDispatch( preferencesStore ); - useShortcut( 'core/edit-site/keyboard-shortcuts', toggleModal ); + const toggleDistractionFree = () => { + registry.batch( () => { + setPreference( 'core/edit-site', 'fixedToolbar', false ); + setIsInserterOpened( false ); + setIsListViewOpened( false ); + closeGeneralSidebar(); + } ); + }; return ( <> @@ -48,6 +73,7 @@ export default function MoreMenu( { showIconLabels } ) { <PreferenceToggleMenuItem scope="core/edit-site" name="fixedToolbar" + disabled={ isDistractionFree } label={ __( 'Top toolbar' ) } info={ __( 'Access all block and document tools in a single place' @@ -71,18 +97,38 @@ export default function MoreMenu( { showIconLabels } ) { 'Spotlight mode deactivated' ) } /> - <ModeSwitcher /> - <ActionItem.Slot - name="core/edit-site/plugin-more-menu" - label={ __( 'Plugins' ) } - as={ MenuGroup } - fillProps={ { onClick: onClose } } + <PreferenceToggleMenuItem + scope="core/edit-site" + name="distractionFree" + onToggle={ toggleDistractionFree } + label={ __( 'Distraction free' ) } + info={ __( 'Write with calmness' ) } + messageActivated={ __( + 'Distraction free mode activated' + ) } + messageDeactivated={ __( + 'Distraction free mode deactivated' + ) } + shortcut={ displayShortcut.primaryShift( + '\\' + ) } /> </MenuGroup> + <ModeSwitcher /> + <ActionItem.Slot + name="core/edit-site/plugin-more-menu" + label={ __( 'Plugins' ) } + as={ MenuGroup } + fillProps={ { onClick: onClose } } + /> <MenuGroup label={ __( 'Tools' ) }> <SiteExport /> <MenuItem - onClick={ toggleModal } + onClick={ () => + openModal( + KEYBOARD_SHORTCUT_HELP_MODAL_NAME + ) + } shortcut={ displayShortcut.access( 'h' ) } > { __( 'Keyboard shortcuts' ) } @@ -111,21 +157,19 @@ export default function MoreMenu( { showIconLabels } ) { /> </MenuGroup> <MenuGroup> - <MenuItem onClick={ togglePreferencesModal }> + <MenuItem + onClick={ () => + openModal( PREFERENCES_MODAL_NAME ) + } + > { __( 'Preferences' ) } </MenuItem> </MenuGroup> </> ) } </MoreMenuDropdown> - <KeyboardShortcutHelpModal - isModalActive={ isModalActive } - toggleModal={ toggleModal } - /> - <EditSitePreferencesModal - isModalActive={ isPreferencesModalActive } - toggleModal={ togglePreferencesModal } - /> + <KeyboardShortcutHelpModal /> + <EditSitePreferencesModal /> </> ); } diff --git a/packages/edit-site/src/components/header-edit-mode/style.scss b/packages/edit-site/src/components/header-edit-mode/style.scss index bbaf896076a099..26b1716a28b865 100644 --- a/packages/edit-site/src/components/header-edit-mode/style.scss +++ b/packages/edit-site/src/components/header-edit-mode/style.scss @@ -88,15 +88,6 @@ $header-toolbar-min-width: 335px; @include break-small() { gap: $grid-unit-10; } - - // Pinned items. - .interface-pinned-items { - display: none; - - @include break-medium() { - display: inline-flex; - } - } } .edit-site-header-edit-mode__preview-options { diff --git a/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js b/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js index d0f05487716b7e..8babbdd0c3dc71 100644 --- a/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js +++ b/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { isEmpty } from 'lodash'; - /** * WordPress dependencies */ @@ -14,7 +9,7 @@ const { Fill: ToolsMoreMenuGroup, Slot } = createSlotFill( ToolsMoreMenuGroup.Slot = ( { fillProps } ) => ( <Slot fillProps={ fillProps }> - { ( fills ) => ! isEmpty( fills ) && fills } + { ( fills ) => fills && fills.length > 0 } </Slot> ); diff --git a/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js b/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js index aac95775cf1d0d..1da14a7cccbe77 100644 --- a/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js +++ b/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js @@ -8,8 +8,12 @@ import classnames from 'classnames'; */ import { Modal } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; -import { useSelect } from '@wordpress/data'; +import { + useShortcut, + store as keyboardShortcutsStore, +} from '@wordpress/keyboard-shortcuts'; +import { store as interfaceStore } from '@wordpress/interface'; +import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies @@ -18,6 +22,9 @@ import { textFormattingShortcuts } from './config'; import Shortcut from './shortcut'; import DynamicShortcut from './dynamic-shortcut'; +export const KEYBOARD_SHORTCUT_HELP_MODAL_NAME = + 'edit-site/keyboard-shortcut-help'; + const ShortcutList = ( { shortcuts } ) => ( /* * Disable reason: The `list` ARIA role is redundant but @@ -82,14 +89,21 @@ const ShortcutCategorySection = ( { ); }; -export default function KeyboardShortcutHelpModal( { - isModalActive, - toggleModal, -} ) { +export default function KeyboardShortcutHelpModal() { + const isModalActive = useSelect( ( select ) => + select( interfaceStore ).isModalActive( + KEYBOARD_SHORTCUT_HELP_MODAL_NAME + ) + ); + const { closeModal, openModal } = useDispatch( interfaceStore ); + const toggleModal = () => + isModalActive + ? closeModal() + : openModal( KEYBOARD_SHORTCUT_HELP_MODAL_NAME ); + useShortcut( 'core/edit-site/keyboard-shortcuts', toggleModal ); if ( ! isModalActive ) { return null; } - return ( <Modal className="edit-site-keyboard-shortcut-help-modal" diff --git a/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js b/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js index 7d491d54932f53..1346041b6a94c1 100644 --- a/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js +++ b/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js @@ -29,7 +29,7 @@ function KeyboardShortcutsEditMode() { [] ); const { redo, undo } = useDispatch( coreStore ); - const { setIsListViewOpened, switchEditorMode } = + const { setIsListViewOpened, switchEditorMode, toggleDistractionFree } = useDispatch( editSiteStore ); const { enableComplementaryArea, disableComplementaryArea } = useDispatch( interfaceStore ); @@ -76,8 +76,12 @@ function KeyboardShortcutsEditMode() { event.preventDefault(); } ); + // Only opens the list view. Other functionality for this shortcut happens in the rendered sidebar. useShortcut( 'core/edit-site/toggle-list-view', () => { - setIsListViewOpened( ! isListViewOpen ); + if ( isListViewOpen ) { + return; + } + setIsListViewOpened( true ); } ); useShortcut( 'core/edit-site/toggle-block-settings-sidebar', ( event ) => { @@ -110,6 +114,10 @@ function KeyboardShortcutsEditMode() { ); } ); + useShortcut( 'core/edit-site/toggle-distraction-free', () => { + toggleDistractionFree(); + } ); + return null; } diff --git a/packages/edit-site/src/components/keyboard-shortcuts/index.js b/packages/edit-site/src/components/keyboard-shortcuts/index.js deleted file mode 100644 index fa4b1aa6833dc3..00000000000000 --- a/packages/edit-site/src/components/keyboard-shortcuts/index.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * WordPress dependencies - */ -import { useShortcut } from '@wordpress/keyboard-shortcuts'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { store as interfaceStore } from '@wordpress/interface'; -import { createBlock } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { store as editSiteStore } from '../../store'; -import { SIDEBAR_BLOCK } from '../sidebar-edit-mode/constants'; -import { STORE_NAME } from '../../store/constants'; - -function KeyboardShortcuts() { - const { __experimentalGetDirtyEntityRecords, isSavingEntityRecord } = - useSelect( coreStore ); - const { getEditorMode } = useSelect( editSiteStore ); - const isListViewOpen = useSelect( - ( select ) => select( editSiteStore ).isListViewOpened(), - [] - ); - const isBlockInspectorOpen = useSelect( - ( select ) => - select( interfaceStore ).getActiveComplementaryArea( - editSiteStore.name - ) === SIDEBAR_BLOCK, - [] - ); - const { redo, undo } = useDispatch( coreStore ); - const { setIsListViewOpened, switchEditorMode } = - useDispatch( editSiteStore ); - const { enableComplementaryArea, disableComplementaryArea } = - useDispatch( interfaceStore ); - const { setIsSaveViewOpened } = useDispatch( editSiteStore ); - - const { replaceBlocks } = useDispatch( blockEditorStore ); - const { getBlockName, getSelectedBlockClientId, getBlockAttributes } = - useSelect( blockEditorStore ); - - const handleTextLevelShortcut = ( event, level ) => { - event.preventDefault(); - const destinationBlockName = - level === 0 ? 'core/paragraph' : 'core/heading'; - const currentClientId = getSelectedBlockClientId(); - if ( currentClientId === null ) { - return; - } - const blockName = getBlockName( currentClientId ); - if ( blockName !== 'core/paragraph' && blockName !== 'core/heading' ) { - return; - } - const attributes = getBlockAttributes( currentClientId ); - const textAlign = - blockName === 'core/paragraph' ? 'align' : 'textAlign'; - const destinationTextAlign = - destinationBlockName === 'core/paragraph' ? 'align' : 'textAlign'; - - replaceBlocks( - currentClientId, - createBlock( destinationBlockName, { - level, - content: attributes.content, - ...{ [ destinationTextAlign ]: attributes[ textAlign ] }, - } ) - ); - }; - - useShortcut( 'core/edit-site/save', ( event ) => { - event.preventDefault(); - - const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); - const isDirty = !! dirtyEntityRecords.length; - const isSaving = dirtyEntityRecords.some( ( record ) => - isSavingEntityRecord( record.kind, record.name, record.key ) - ); - - if ( ! isSaving && isDirty ) { - setIsSaveViewOpened( true ); - } - } ); - - useShortcut( 'core/edit-site/undo', ( event ) => { - undo(); - event.preventDefault(); - } ); - - useShortcut( 'core/edit-site/redo', ( event ) => { - redo(); - event.preventDefault(); - } ); - - useShortcut( 'core/edit-site/toggle-list-view', () => { - setIsListViewOpened( ! isListViewOpen ); - } ); - - useShortcut( 'core/edit-site/toggle-block-settings-sidebar', ( event ) => { - // This shortcut has no known clashes, but use preventDefault to prevent any - // obscure shortcuts from triggering. - event.preventDefault(); - - if ( isBlockInspectorOpen ) { - disableComplementaryArea( STORE_NAME ); - } else { - enableComplementaryArea( STORE_NAME, SIDEBAR_BLOCK ); - } - } ); - - useShortcut( 'core/edit-site/toggle-mode', () => { - switchEditorMode( getEditorMode() === 'visual' ? 'text' : 'visual' ); - } ); - - useShortcut( 'core/edit-site/transform-heading-to-paragraph', ( event ) => - handleTextLevelShortcut( event, 0 ) - ); - - [ 1, 2, 3, 4, 5, 6 ].forEach( ( level ) => { - //the loop is based off on a constant therefore - //the hook will execute the same way every time - //eslint-disable-next-line react-hooks/rules-of-hooks - useShortcut( - `core/edit-site/transform-paragraph-to-heading-${ level }`, - ( event ) => handleTextLevelShortcut( event, level ) - ); - } ); - - return null; -} - -export default KeyboardShortcuts; diff --git a/packages/edit-site/src/components/keyboard-shortcuts/register.js b/packages/edit-site/src/components/keyboard-shortcuts/register.js index 0574125ba476b6..8dfd1e3e2a45bf 100644 --- a/packages/edit-site/src/components/keyboard-shortcuts/register.js +++ b/packages/edit-site/src/components/keyboard-shortcuts/register.js @@ -129,7 +129,7 @@ function KeyboardShortcutsRegister() { } ); registerShortcut( { - name: `core/edit-site/transform-heading-to-paragraph`, + name: 'core/edit-site/transform-heading-to-paragraph', category: 'block-library', description: __( 'Transform heading to paragraph.' ), keyCombination: { @@ -149,6 +149,16 @@ function KeyboardShortcutsRegister() { }, } ); } ); + + registerShortcut( { + name: 'core/edit-site/toggle-distraction-free', + category: 'global', + description: __( 'Toggle distraction free mode.' ), + keyCombination: { + modifier: 'primaryShift', + character: '\\', + }, + } ); }, [ registerShortcut ] ); return null; diff --git a/packages/edit-site/src/components/layout/hooks.js b/packages/edit-site/src/components/layout/hooks.js index 7a89987cb7482c..a9bf1b982903a9 100644 --- a/packages/edit-site/src/components/layout/hooks.js +++ b/packages/edit-site/src/components/layout/hooks.js @@ -10,6 +10,8 @@ import { store as coreStore } from '@wordpress/core-data'; */ import useEditedEntityRecord from '../use-edited-entity-record'; +const MAX_LOADING_TIME = 10000; // 10 seconds + export function useIsSiteEditorLoading() { const { isLoaded: hasLoadedPost } = useEditedEntityRecord(); const [ loaded, setLoaded ] = useState( false ); @@ -22,6 +24,25 @@ export function useIsSiteEditorLoading() { [ loaded ] ); + /* + * If the maximum expected loading time has passed, we're marking the + * editor as loaded, in order to prevent any failed requests from blocking + * the editor canvas from appearing. + */ + useEffect( () => { + let timeout; + + if ( ! loaded ) { + timeout = setTimeout( () => { + setLoaded( true ); + }, MAX_LOADING_TIME ); + } + + return () => { + clearTimeout( timeout ); + }; + }, [ loaded ] ); + useEffect( () => { if ( inLoadingPause ) { /* diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js index 6b1430fdc66c5c..80950d130a0a1b 100644 --- a/packages/edit-site/src/components/layout/index.js +++ b/packages/edit-site/src/components/layout/index.js @@ -26,6 +26,10 @@ import { privateApis as commandsPrivateApis, } from '@wordpress/commands'; import { store as preferencesStore } from '@wordpress/preferences'; +import { + privateApis as blockEditorPrivateApis, + useBlockCommands, +} from '@wordpress/block-editor'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { privateApis as coreCommandsPrivateApis } from '@wordpress/core-commands'; @@ -34,7 +38,6 @@ import { privateApis as coreCommandsPrivateApis } from '@wordpress/core-commands */ import Sidebar from '../sidebar'; import Editor from '../editor'; -import ListPage from '../list'; import ErrorBoundary from '../error-boundary'; import { store as editSiteStore } from '../../store'; import getIsListPage from '../../utils/get-is-list-page'; @@ -43,16 +46,19 @@ import useInitEditedEntityFromURL from '../sync-state-with-url/use-init-edited-e import SiteHub from '../site-hub'; import ResizableFrame from '../resizable-frame'; import useSyncCanvasModeWithURL from '../sync-state-with-url/use-sync-canvas-mode-with-url'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import SavePanel from '../save-panel'; import KeyboardShortcutsRegister from '../keyboard-shortcuts/register'; import KeyboardShortcutsGlobal from '../keyboard-shortcuts/global'; +import { useCommonCommands } from '../../hooks/commands/use-common-commands'; import { useEditModeCommands } from '../../hooks/commands/use-edit-mode-commands'; +import PageMain from '../page-main'; import { useIsSiteEditorLoading } from './hooks'; const { useCommands } = unlock( coreCommandsPrivateApis ); const { useCommandContext } = unlock( commandsPrivateApis ); const { useLocation } = unlock( routerPrivateApis ); +const { useGlobalStyle } = unlock( blockEditorPrivateApis ); const ANIMATION_DURATION = 0.5; @@ -62,36 +68,50 @@ export default function Layout() { useSyncCanvasModeWithURL(); useCommands(); useEditModeCommands(); + useCommonCommands(); + useBlockCommands(); const hubRef = useRef(); const { params } = useLocation(); - const isListPage = getIsListPage( params ); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const isListPage = getIsListPage( params, isMobileViewport ); const isEditorPage = ! isListPage; - const { hasFixedToolbar, canvasMode, previousShortcut, nextShortcut } = - useSelect( ( select ) => { - const { getAllShortcutKeyCombinations } = select( - keyboardShortcutsStore - ); - const { getCanvasMode } = unlock( select( editSiteStore ) ); - return { - canvasMode: getCanvasMode(), - previousShortcut: getAllShortcutKeyCombinations( - 'core/edit-site/previous-region' - ), - nextShortcut: getAllShortcutKeyCombinations( - 'core/edit-site/next-region' - ), - hasFixedToolbar: - select( preferencesStore ).get( 'fixedToolbar' ), - }; - }, [] ); + + const { + isDistractionFree, + hasFixedToolbar, + canvasMode, + previousShortcut, + nextShortcut, + } = useSelect( ( select ) => { + const { getAllShortcutKeyCombinations } = select( + keyboardShortcutsStore + ); + const { getCanvasMode } = unlock( select( editSiteStore ) ); + return { + canvasMode: getCanvasMode(), + previousShortcut: getAllShortcutKeyCombinations( + 'core/edit-site/previous-region' + ), + nextShortcut: getAllShortcutKeyCombinations( + 'core/edit-site/next-region' + ), + hasFixedToolbar: select( preferencesStore ).get( + 'core/edit-site', + 'fixedToolbar' + ), + isDistractionFree: select( preferencesStore ).get( + 'core/edit-site', + 'distractionFree' + ), + }; + }, [] ); const isEditing = canvasMode === 'edit'; const navigateRegionsProps = useNavigateRegions( { previous: previousShortcut, next: nextShortcut, } ); const disableMotion = useReducedMotion(); - const isMobileViewport = useViewportMatch( 'medium', '<' ); const showSidebar = ( isMobileViewport && ! isListPage ) || ( ! isMobileViewport && ( canvasMode === 'view' || ! isEditorPage ) ); @@ -105,14 +125,40 @@ export default function Layout() { const [ fullResizer ] = useResizeObserver(); const [ isResizing ] = useState( false ); const isEditorLoading = useIsSiteEditorLoading(); + const [ isResizableFrameOversized, setIsResizableFrameOversized ] = + useState( false ); + + // This determines which animation variant should apply to the header. + // There is also a `isDistractionFreeHovering` state that gets priority + // when hovering the `edit-site-layout__header-container` in distraction + // free mode. It's set via framer and trickles down to all the children + // so they can use this variant state too. + // + // TODO: The issue with this is we want to have the hover state stick when hovering + // a popover opened via the header. We'll probably need to lift this state to + // handle it ourselves. Also, focusWithin the header needs to be handled. + let headerAnimationState; - // Sets the right context for the command center + if ( canvasMode === 'view' ) { + // We need 'view' to always take priority so 'isDistractionFree' + // doesn't bleed over into the view (sidebar) state + headerAnimationState = 'view'; + } else if ( isDistractionFree ) { + headerAnimationState = 'isDistractionFree'; + } else { + headerAnimationState = canvasMode; // edit, view, init + } + + // Sets the right context for the command palette const commandContext = canvasMode === 'edit' && isEditorPage ? 'site-editor-edit' : 'site-editor'; useCommandContext( commandContext ); + const [ backgroundColor ] = useGlobalStyle( 'color.background' ); + const [ gradientValue ] = useGlobalStyle( 'color.gradient' ); + // Synchronizing the URL with the store value of canvasMode happens in an effect // This condition ensures the component is only rendered after the synchronization happens // which prevents any animations due to potential canvasMode value change. @@ -133,137 +179,207 @@ export default function Layout() { 'edit-site-layout', navigateRegionsProps.className, { + 'is-distraction-free': isDistractionFree && isEditing, 'is-full-canvas': isFullCanvas, 'is-edit-mode': isEditing, 'has-fixed-toolbar': hasFixedToolbar, } ) } > - <SiteHub ref={ hubRef } className="edit-site-layout__hub" /> - - <AnimatePresence initial={ false }> - { isEditorPage && isEditing && ( - <NavigableRegion - className="edit-site-layout__header" - ariaLabel={ __( 'Editor top bar' ) } - as={ motion.div } - animate={ { - y: 0, - } } - initial={ { - y: '-100%', - } } - exit={ { - y: '-100%', - } } - transition={ { + <motion.div + className="edit-site-layout__header-container" + variants={ { + isDistractionFree: { + opacity: 0, + transition: { type: 'tween', - duration: disableMotion - ? 0 - : ANIMATION_DURATION, - ease: 'easeOut', - } } - > - { isEditing && <Header /> } - </NavigableRegion> - ) } - </AnimatePresence> + delay: 0.8, + delayChildren: 0.8, + }, // How long to wait before the header exits + }, + isDistractionFreeHovering: { + opacity: 1, + transition: { + type: 'tween', + delay: 0.2, + delayChildren: 0.2, + }, // How long to wait before the header shows + }, + view: { opacity: 1 }, + edit: { opacity: 1 }, + } } + whileHover={ + isDistractionFree + ? 'isDistractionFreeHovering' + : undefined + } + animate={ headerAnimationState } + > + <SiteHub + variants={ { + isDistractionFree: { x: '-100%' }, + isDistractionFreeHovering: { x: 0 }, + view: { x: 0 }, + edit: { x: 0 }, + } } + ref={ hubRef } + isTransparent={ isResizableFrameOversized } + className="edit-site-layout__hub" + /> - <div className="edit-site-layout__content"> <AnimatePresence initial={ false }> - { showSidebar && ( - <motion.div - initial={ { - opacity: 0, - } } - animate={ { - opacity: 1, + { isEditorPage && isEditing && ( + <NavigableRegion + key="header" + className="edit-site-layout__header" + ariaLabel={ __( 'Editor top bar' ) } + as={ motion.div } + variants={ { + isDistractionFree: { opacity: 0, y: 0 }, + isDistractionFreeHovering: { + opacity: 1, + y: 0, + }, + view: { opacity: 1, y: '-100%' }, + edit: { opacity: 1, y: 0 }, } } exit={ { - opacity: 0, + y: '-100%', + } } + initial={ { + opacity: isDistractionFree ? 1 : 0, + y: isDistractionFree ? 0 : '-100%', } } transition={ { type: 'tween', - duration: ANIMATION_DURATION, + duration: disableMotion ? 0 : 0.2, ease: 'easeOut', } } - className="edit-site-layout__sidebar" > - <NavigableRegion - ariaLabel={ __( 'Navigation' ) } - > - <Sidebar /> - </NavigableRegion> - </motion.div> + <Header /> + </NavigableRegion> ) } </AnimatePresence> + </motion.div> + + <div className="edit-site-layout__content"> + { /* + The NavigableRegion must always be rendered and not use + `inert` otherwise `useNavigateRegions` will fail. + */ } + <NavigableRegion + ariaLabel={ __( 'Navigation' ) } + className="edit-site-layout__sidebar-region" + > + <motion.div + // The sidebar is needed for routing on mobile + // (https://github.com/WordPress/gutenberg/pull/51558/files#r1231763003), + // so we can't remove the element entirely. Using `inert` will make + // it inaccessible to screen readers and keyboard navigation. + inert={ showSidebar ? undefined : 'inert' } + animate={ { opacity: showSidebar ? 1 : 0 } } + transition={ { + type: 'tween', + duration: + // Disable transition in mobile to emulate a full page transition. + disableMotion || isMobileViewport + ? 0 + : ANIMATION_DURATION, + ease: 'easeOut', + } } + className="edit-site-layout__sidebar" + > + <Sidebar /> + </motion.div> + </NavigableRegion> <SavePanel /> { showCanvas && ( - <div - className={ classnames( - 'edit-site-layout__canvas-container', - { - 'is-resizing': isResizing, - } - ) } - > - { canvasResizer } - { !! canvasSize.width && ( - <motion.div - whileHover={ - isEditorPage && canvasMode === 'view' - ? { - scale: 1.006, - transition: { - duration: - disableMotion || - isResizing - ? 0 - : 0.5, - ease: 'easeOut', - }, - } - : {} - } - // Setting a transform property (in this case scale) on an element makes it act as a containing block for its descendants. - // This means that the snackbar notices inside this component are repositioned to be relative to this element. - // To avoid the snackbars jumping about we need to ensure that a transform property is always set. - // Setting a scale of 1 is interpred by framer as no change, so once the animation completes - // the transform property of this element is set to none, thus removing its role as a container block. - // Instead we set the initial scale of this element to 1.0001 so that there is always a transform property set. - // If we set the initial scale to less than 1.001 then JavaScript rounds it to 1 and the problem reoccurs. - initial={ { scale: 1.001 } } - className="edit-site-layout__canvas" - transition={ { - type: 'tween', - duration: - disableMotion || isResizing - ? 0 - : ANIMATION_DURATION, - ease: 'easeOut', - } } + <> + { isListPage && <PageMain /> } + { isEditorPage && ( + <div + className={ classnames( + 'edit-site-layout__canvas-container', + { + 'is-resizing': isResizing, + } + ) } > - <ErrorBoundary> - { isEditorPage && ( - <ResizableFrame - isReady={ ! isEditorLoading } - isFullWidth={ isEditing } - oversizedClassName="edit-site-layout__resizable-frame-oversized" - > - <Editor - isLoading={ - isEditorLoading + { canvasResizer } + { !! canvasSize.width && ( + <motion.div + whileHover={ + isEditorPage && + canvasMode === 'view' + ? { + scale: 1.005, + transition: { + duration: + disableMotion || + isResizing + ? 0 + : 0.5, + ease: 'easeOut', + }, + } + : {} + } + initial={ false } + layout="position" + className={ classnames( + 'edit-site-layout__canvas', + { + 'is-right-aligned': + isResizableFrameOversized, + } + ) } + transition={ { + type: 'tween', + duration: + disableMotion || isResizing + ? 0 + : ANIMATION_DURATION, + ease: 'easeOut', + } } + > + <ErrorBoundary> + <ResizableFrame + isReady={ + ! isEditorLoading + } + isFullWidth={ isEditing } + defaultSize={ { + width: + canvasSize.width - + 24 /* $canvas-padding */, + height: canvasSize.height, + } } + isOversized={ + isResizableFrameOversized + } + setIsOversized={ + setIsResizableFrameOversized } - /> - </ResizableFrame> - ) } - { isListPage && <ListPage /> } - </ErrorBoundary> - </motion.div> + innerContentStyle={ { + background: + gradientValue ?? + backgroundColor, + } } + > + <Editor + isLoading={ + isEditorLoading + } + /> + </ResizableFrame> + </ErrorBoundary> + </motion.div> + ) } + </div> ) } - </div> + </> ) } </div> </div> diff --git a/packages/edit-site/src/components/layout/style.scss b/packages/edit-site/src/components/layout/style.scss index ecb15aac8fe1e0..11c7bdeeaf2a19 100644 --- a/packages/edit-site/src/components/layout/style.scss +++ b/packages/edit-site/src/components/layout/style.scss @@ -1,7 +1,7 @@ .edit-site-layout { height: 100%; background: $gray-900; - color: $white; + color: $gray-400; display: flex; flex-direction: column; } @@ -36,6 +36,10 @@ } } +.edit-site-layout__header-container { + z-index: z-index(".edit-site-layout__header-container"); +} + .edit-site-layout__header { height: $header-height; display: flex; @@ -54,9 +58,10 @@ display: flex; } -.edit-site-layout__sidebar { +.edit-site-layout__sidebar-region { z-index: z-index(".edit-site-layout__sidebar"); width: 100vw; + flex-shrink: 0; @include break-medium { width: $nav-sidebar-width; @@ -70,7 +75,7 @@ top: 0; } - > div { + .edit-site-layout__sidebar { display: flex; flex-direction: column; height: 100%; @@ -81,6 +86,13 @@ } } +.edit-site-layout__main { + flex-grow: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + .edit-site-layout__canvas-container { position: relative; flex-grow: 1; @@ -109,7 +121,7 @@ justify-content: center; align-items: center; - &:has(.edit-site-layout__resizable-frame-oversized) { + &.is-right-aligned { justify-content: flex-end; } @@ -242,6 +254,9 @@ // so the fixed toolbar can be positioned on top of it // but only on desktop @include break-medium() { + .edit-site-layout__canvas-container { + z-index: 5; + } .edit-site-site-hub { z-index: 4; } @@ -251,3 +266,42 @@ } } +.is-edit-mode.is-distraction-free { + + .edit-site-layout__header-container { + height: $header-height; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: z-index(".edit-site-layout__header-container"); + width: 100%; + + // We need ! important because we override inline styles + // set by the motion component. + &:focus-within { + opacity: 1 !important; + div { + transform: translateX(0) translateY(0) translateZ(0) !important; + } + + .edit-site-layout__header { + opacity: 1 !important; + } + } + } + + .edit-site-site-hub, + .edit-site-layout__header { + position: absolute; + top: 0; + z-index: z-index(".edit-site-layout__header"); + } + .edit-site-site-hub { + z-index: z-index(".edit-site-layout__hub"); + } + .edit-site-layout__header { + width: 100%; + } + +} diff --git a/packages/edit-site/src/components/list/actions/index.js b/packages/edit-site/src/components/list/actions/index.js deleted file mode 100644 index 652cbe0f74e15a..00000000000000 --- a/packages/edit-site/src/components/list/actions/index.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * WordPress dependencies - */ -import { useDispatch } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; -import { __, sprintf } from '@wordpress/i18n'; -import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; -import { moreVertical } from '@wordpress/icons'; -import { store as noticesStore } from '@wordpress/notices'; - -/** - * Internal dependencies - */ -import { store as editSiteStore } from '../../../store'; -import isTemplateRemovable from '../../../utils/is-template-removable'; -import isTemplateRevertable from '../../../utils/is-template-revertable'; -import RenameMenuItem from './rename-menu-item'; - -export default function Actions( { template } ) { - const { removeTemplate, revertTemplate } = useDispatch( editSiteStore ); - const { saveEditedEntityRecord } = useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - const isRemovable = isTemplateRemovable( template ); - const isRevertable = isTemplateRevertable( template ); - - if ( ! isRemovable && ! isRevertable ) { - return null; - } - - async function revertAndSaveTemplate() { - try { - await revertTemplate( template, { allowUndo: false } ); - await saveEditedEntityRecord( - 'postType', - template.type, - template.id - ); - - createSuccessNotice( - sprintf( - /* translators: The template/part's name. */ - __( '"%s" reverted.' ), - template.title.rendered - ), - { - type: 'snackbar', - id: 'edit-site-template-reverted', - } - ); - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( 'An error occurred while reverting the entity.' ); - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - } - - return ( - <DropdownMenu - icon={ moreVertical } - label={ __( 'Actions' ) } - className="edit-site-list-table__actions" - > - { ( { onClose } ) => ( - <MenuGroup> - { isRemovable && ( - <> - <RenameMenuItem - template={ template } - onClose={ onClose } - /> - <MenuItem - isDestructive - isTertiary - onClick={ () => { - removeTemplate( template ); - onClose(); - } } - > - { __( 'Delete' ) } - </MenuItem> - </> - ) } - { isRevertable && ( - <MenuItem - info={ __( - 'Use the template as supplied by the theme.' - ) } - onClick={ () => { - revertAndSaveTemplate(); - onClose(); - } } - > - { __( 'Clear customizations' ) } - </MenuItem> - ) } - </MenuGroup> - ) } - </DropdownMenu> - ); -} diff --git a/packages/edit-site/src/components/list/actions/rename-menu-item.js b/packages/edit-site/src/components/list/actions/rename-menu-item.js deleted file mode 100644 index dec7f0bc8bf7dd..00000000000000 --- a/packages/edit-site/src/components/list/actions/rename-menu-item.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; -import { - Button, - MenuItem, - Modal, - TextControl, - __experimentalHStack as HStack, - __experimentalVStack as VStack, -} from '@wordpress/components'; -import { store as coreStore } from '@wordpress/core-data'; -import { store as noticesStore } from '@wordpress/notices'; - -export default function RenameMenuItem( { template, onClose } ) { - const [ title, setTitle ] = useState( () => template.title.rendered ); - const [ isModalOpen, setIsModalOpen ] = useState( false ); - - const { editEntityRecord, saveEditedEntityRecord } = - useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - - if ( template.type === 'wp_template' && ! template.is_custom ) { - return null; - } - - async function onTemplateRename( event ) { - event.preventDefault(); - - try { - await editEntityRecord( 'postType', template.type, template.id, { - title, - } ); - - // Update state before saving rerenders the list. - setTitle( '' ); - setIsModalOpen( false ); - onClose(); - - // Persist edited entity. - await saveEditedEntityRecord( - 'postType', - template.type, - template.id, - { throwOnError: true } - ); - - createSuccessNotice( __( 'Entity renamed.' ), { - type: 'snackbar', - } ); - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( 'An error occurred while renaming the entity.' ); - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - } - - return ( - <> - <MenuItem - onClick={ () => { - setIsModalOpen( true ); - setTitle( template.title.rendered ); - } } - > - { __( 'Rename' ) } - </MenuItem> - { isModalOpen && ( - <Modal - title={ __( 'Rename' ) } - onRequestClose={ () => { - setIsModalOpen( false ); - } } - overlayClassName="edit-site-list__rename-modal" - > - <form onSubmit={ onTemplateRename }> - <VStack spacing="5"> - <TextControl - __nextHasNoMarginBottom - label={ __( 'Name' ) } - value={ title } - onChange={ setTitle } - required - /> - - <HStack justify="right"> - <Button - variant="tertiary" - onClick={ () => { - setIsModalOpen( false ); - } } - > - { __( 'Cancel' ) } - </Button> - - <Button variant="primary" type="submit"> - { __( 'Save' ) } - </Button> - </HStack> - </VStack> - </form> - </Modal> - ) } - </> - ); -} diff --git a/packages/edit-site/src/components/list/index.js b/packages/edit-site/src/components/list/index.js index 0ffb67b3810e3a..cc01ad6ece7ff5 100644 --- a/packages/edit-site/src/components/list/index.js +++ b/packages/edit-site/src/components/list/index.js @@ -16,7 +16,7 @@ import useRegisterShortcuts from './use-register-shortcuts'; import Header from './header'; import Table from './table'; import useTitle from '../routes/use-title'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); diff --git a/packages/edit-site/src/components/list/table.js b/packages/edit-site/src/components/list/table.js index 59ff5a9187ab09..18198cb600ddae 100644 --- a/packages/edit-site/src/components/list/table.js +++ b/packages/edit-site/src/components/list/table.js @@ -13,8 +13,8 @@ import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies */ +import TemplateActions from '../template-actions'; import Link from '../routes/link'; -import Actions from './actions'; import AddedBy from './added-by'; export default function Table( { templateType } ) { @@ -126,7 +126,11 @@ export default function Table( { templateType } ) { ) : null } </td> <td className="edit-site-list-table-column" role="cell"> - <Actions template={ template } /> + <TemplateActions + postType={ template.type } + postId={ template.id } + className="edit-site-list-table__actions" + /> </td> </tr> ) ) } diff --git a/packages/edit-site/src/components/page-actions/index.js b/packages/edit-site/src/components/page-actions/index.js new file mode 100644 index 00000000000000..f6f0119a164544 --- /dev/null +++ b/packages/edit-site/src/components/page-actions/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { DropdownMenu, MenuGroup } from '@wordpress/components'; +import { moreVertical } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import TrashPageMenuItem from './trash-page-menu-item'; + +export default function PageActions( { + postId, + className, + toggleProps, + onRemove, +} ) { + return ( + <DropdownMenu + icon={ moreVertical } + label={ __( 'Actions' ) } + className={ className } + toggleProps={ toggleProps } + > + { () => ( + <MenuGroup> + <TrashPageMenuItem + postId={ postId } + onRemove={ onRemove } + /> + </MenuGroup> + ) } + </DropdownMenu> + ); +} diff --git a/packages/edit-site/src/components/page-actions/trash-page-menu-item.js b/packages/edit-site/src/components/page-actions/trash-page-menu-item.js new file mode 100644 index 00000000000000..6005465c8fa5f0 --- /dev/null +++ b/packages/edit-site/src/components/page-actions/trash-page-menu-item.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; +import { MenuItem } from '@wordpress/components'; +import { store as noticesStore } from '@wordpress/notices'; + +export default function TrashPageMenuItem( { postId, onRemove } ) { + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const { deleteEntityRecord } = useDispatch( coreStore ); + const page = useSelect( + ( select ) => + select( coreStore ).getEntityRecord( 'postType', 'page', postId ), + [ postId ] + ); + async function removePage() { + try { + await deleteEntityRecord( + 'postType', + 'page', + postId, + {}, + { throwOnError: true } + ); + createSuccessNotice( + sprintf( + /* translators: The page's title. */ + __( '"%s" moved to the Trash.' ), + decodeEntities( page.title.rendered ) + ), + { + type: 'snackbar', + id: 'edit-site-page-trashed', + } + ); + onRemove?.(); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( + 'An error occurred while moving the page to the trash.' + ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + return ( + <> + <MenuItem + onClick={ () => removePage() } + isDestructive + variant="secondary" + > + { __( 'Move to Trash' ) } + </MenuItem> + </> + ); +} diff --git a/packages/edit-site/src/components/page-content-focus-manager/back-to-page-notification.js b/packages/edit-site/src/components/page-content-focus-manager/back-to-page-notification.js new file mode 100644 index 00000000000000..a2990c56a673cf --- /dev/null +++ b/packages/edit-site/src/components/page-content-focus-manager/back-to-page-notification.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect, useRef } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; + +/** + * Component that displays a 'You are editing a template' notification when the + * user switches from focusing on editing page content to editing a template. + */ +export default function BackToPageNotification() { + useBackToPageNotification(); + return null; +} + +/** + * Hook that displays a 'You are editing a template' notification when the user + * switches from focusing on editing page content to editing a template. + */ +export function useBackToPageNotification() { + const { isPage, hasPageContentFocus } = useSelect( + ( select ) => ( { + isPage: select( editSiteStore ).isPage(), + hasPageContentFocus: select( editSiteStore ).hasPageContentFocus(), + } ), + [] + ); + + const alreadySeen = useRef( false ); + const prevHasPageContentFocus = useRef( false ); + + const { createInfoNotice } = useDispatch( noticesStore ); + const { setHasPageContentFocus } = useDispatch( editSiteStore ); + + useEffect( () => { + if ( + ! alreadySeen.current && + isPage && + prevHasPageContentFocus.current && + ! hasPageContentFocus + ) { + createInfoNotice( __( 'You are editing a template.' ), { + isDismissible: true, + type: 'snackbar', + actions: [ + { + label: __( 'Back to page' ), + onClick: () => setHasPageContentFocus( true ), + }, + ], + } ); + alreadySeen.current = true; + } + prevHasPageContentFocus.current = hasPageContentFocus; + }, [ + alreadySeen, + isPage, + prevHasPageContentFocus, + hasPageContentFocus, + createInfoNotice, + setHasPageContentFocus, + ] ); +} diff --git a/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js b/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js new file mode 100644 index 00000000000000..9d28c01164f29b --- /dev/null +++ b/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter, removeFilter } from '@wordpress/hooks'; +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ + +const PAGE_CONTENT_BLOCK_TYPES = [ + 'core/post-title', + 'core/post-featured-image', + 'core/post-content', +]; + +/** + * Component that when rendered, makes it so that the site editor allows only + * page content to be edited. + */ +export default function DisableNonPageContentBlocks() { + useDisableNonPageContentBlocks(); + return null; +} + +/** + * Disables non-content blocks using the `useBlockEditingMode` hook. + */ +export function useDisableNonPageContentBlocks() { + useBlockEditingMode( 'disabled' ); + useEffect( () => { + addFilter( + 'editor.BlockEdit', + 'core/edit-site/disable-non-content-blocks', + withDisableNonPageContentBlocks + ); + return () => + removeFilter( + 'editor.BlockEdit', + 'core/edit-site/disable-non-content-blocks' + ); + }, [] ); +} + +const withDisableNonPageContentBlocks = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const isDescendentOfQueryLoop = props.context.queryId !== undefined; + const isPageContent = + PAGE_CONTENT_BLOCK_TYPES.includes( props.name ) && + ! isDescendentOfQueryLoop; + const mode = isPageContent ? 'contentOnly' : undefined; + useBlockEditingMode( mode ); + return <BlockEdit { ...props } />; + }, + 'withDisableNonPageContentBlocks' +); diff --git a/packages/edit-site/src/components/page-content-focus-manager/edit-template-notification.js b/packages/edit-site/src/components/page-content-focus-manager/edit-template-notification.js new file mode 100644 index 00000000000000..3518bc8c3c51dc --- /dev/null +++ b/packages/edit-site/src/components/page-content-focus-manager/edit-template-notification.js @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect, useState, useRef } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import { __ } from '@wordpress/i18n'; +import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; + +/** + * Component that: + * + * - Displays a 'Edit your template to edit this block' notification when the + * user is focusing on editing page content and clicks on a disabled template + * block. + * - Displays a 'Edit your template to edit this block' dialog when the user + * is focusing on editing page conetnt and double clicks on a disabled + * template block. + * + * @param {Object} props + * @param {import('react').RefObject<HTMLElement>} props.contentRef Ref to the block + * editor iframe canvas. + */ +export default function EditTemplateNotification( { contentRef } ) { + const hasPageContentFocus = useSelect( + ( select ) => select( editSiteStore ).hasPageContentFocus(), + [] + ); + const { getNotices } = useSelect( noticesStore ); + + const { createInfoNotice, removeNotice } = useDispatch( noticesStore ); + const { setHasPageContentFocus } = useDispatch( editSiteStore ); + + const [ isDialogOpen, setIsDialogOpen ] = useState( false ); + + const lastNoticeId = useRef( 0 ); + + useEffect( () => { + const handleClick = async ( event ) => { + if ( ! hasPageContentFocus ) { + return; + } + if ( ! event.target.classList.contains( 'is-root-container' ) ) { + return; + } + const isNoticeAlreadyShowing = getNotices().some( + ( notice ) => notice.id === lastNoticeId.current + ); + if ( isNoticeAlreadyShowing ) { + return; + } + const { notice } = await createInfoNotice( + __( 'Edit your template to edit this block.' ), + { + isDismissible: true, + type: 'snackbar', + actions: [ + { + label: __( 'Edit template' ), + onClick: () => setHasPageContentFocus( false ), + }, + ], + } + ); + lastNoticeId.current = notice.id; + }; + + const handleDblClick = ( event ) => { + if ( ! hasPageContentFocus ) { + return; + } + if ( ! event.target.classList.contains( 'is-root-container' ) ) { + return; + } + if ( lastNoticeId.current ) { + removeNotice( lastNoticeId.current ); + } + setIsDialogOpen( true ); + }; + + const canvas = contentRef.current; + canvas?.addEventListener( 'click', handleClick ); + canvas?.addEventListener( 'dblclick', handleDblClick ); + return () => { + canvas?.removeEventListener( 'click', handleClick ); + canvas?.removeEventListener( 'dblclick', handleDblClick ); + }; + }, [ lastNoticeId, hasPageContentFocus, contentRef.current ] ); + + return ( + <ConfirmDialog + isOpen={ isDialogOpen } + confirmButtonText={ __( 'Edit template' ) } + onConfirm={ () => { + setIsDialogOpen( false ); + setHasPageContentFocus( false ); + } } + onCancel={ () => setIsDialogOpen( false ) } + > + { __( 'Edit your template to edit this block.' ) } + </ConfirmDialog> + ); +} diff --git a/packages/edit-site/src/components/page-content-focus-manager/index.js b/packages/edit-site/src/components/page-content-focus-manager/index.js new file mode 100644 index 00000000000000..935ba1c96248cf --- /dev/null +++ b/packages/edit-site/src/components/page-content-focus-manager/index.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; +import DisableNonPageContentBlocks from './disable-non-page-content-blocks'; +import EditTemplateNotification from './edit-template-notification'; +import BackToPageNotification from './back-to-page-notification'; + +export default function PageContentFocusManager( { contentRef } ) { + const hasPageContentFocus = useSelect( + ( select ) => select( editSiteStore ).hasPageContentFocus(), + [] + ); + return ( + <> + { hasPageContentFocus && <DisableNonPageContentBlocks /> } + <EditTemplateNotification contentRef={ contentRef } /> + <BackToPageNotification /> + </> + ); +} diff --git a/packages/edit-site/src/components/page-main/index.js b/packages/edit-site/src/components/page-main/index.js new file mode 100644 index 00000000000000..af017a8db9700a --- /dev/null +++ b/packages/edit-site/src/components/page-main/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import PagePatterns from '../page-patterns'; +import PageTemplateParts from '../page-template-parts'; +import PageTemplates from '../page-templates'; +import { unlock } from '../../lock-unlock'; + +const { useLocation } = unlock( routerPrivateApis ); + +export default function PageMain() { + const { + params: { path }, + } = useLocation(); + + if ( path === '/wp_template/all' ) { + return <PageTemplates />; + } else if ( path === '/wp_template_part/all' ) { + return <PageTemplateParts />; + } else if ( path === '/patterns' ) { + return <PagePatterns />; + } + + return null; +} diff --git a/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js new file mode 100644 index 00000000000000..994ed168cd1861 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js @@ -0,0 +1,175 @@ +/** + * WordPress dependencies + */ +import { MenuItem } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import { + TEMPLATE_PARTS, + PATTERNS, + SYNC_TYPES, + USER_PATTERNS, + USER_PATTERN_CATEGORY, +} from './utils'; +import { + useExistingTemplateParts, + getUniqueTemplatePartTitle, + getCleanTemplatePartSlug, +} from '../../utils/template-part-create'; +import { unlock } from '../../lock-unlock'; + +const { useHistory } = unlock( routerPrivateApis ); + +function getPatternMeta( item ) { + if ( item.type === PATTERNS ) { + return { wp_pattern_sync_status: SYNC_TYPES.unsynced }; + } + + const syncStatus = item.reusableBlock.wp_pattern_sync_status; + const isUnsynced = syncStatus === SYNC_TYPES.unsynced; + + return { + ...item.reusableBlock.meta, + wp_pattern_sync_status: isUnsynced ? syncStatus : undefined, + }; +} + +export default function DuplicateMenuItem( { + categoryId, + item, + label = __( 'Duplicate' ), + onClose, +} ) { + const { saveEntityRecord } = useDispatch( coreStore ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( noticesStore ); + + const history = useHistory(); + const existingTemplateParts = useExistingTemplateParts(); + + async function createTemplatePart() { + try { + const copiedTitle = sprintf( + /* translators: %s: Existing template part title */ + __( '%s (Copy)' ), + item.title + ); + const title = getUniqueTemplatePartTitle( + copiedTitle, + existingTemplateParts + ); + const slug = getCleanTemplatePartSlug( title ); + const { area, content } = item.templatePart; + + const result = await saveEntityRecord( + 'postType', + 'wp_template_part', + { slug, title, content, area }, + { throwOnError: true } + ); + + createSuccessNotice( + sprintf( + // translators: %s: The new template part's title e.g. 'Call to action (copy)'. + __( '"%s" duplicated.' ), + item.title + ), + { + type: 'snackbar', + id: 'edit-site-patterns-success', + } + ); + + history.push( { + postType: TEMPLATE_PARTS, + postId: result?.id, + categoryType: TEMPLATE_PARTS, + categoryId, + } ); + + onClose(); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( + 'An error occurred while creating the template part.' + ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + id: 'edit-site-patterns-error', + } ); + onClose(); + } + } + + async function createPattern() { + try { + const isThemePattern = item.type === PATTERNS; + const title = sprintf( + /* translators: %s: Existing pattern title */ + __( '%s (Copy)' ), + item.title + ); + + const result = await saveEntityRecord( + 'postType', + 'wp_block', + { + content: isThemePattern + ? item.content + : item.reusableBlock.content, + meta: getPatternMeta( item ), + status: 'publish', + title, + }, + { throwOnError: true } + ); + + createSuccessNotice( + sprintf( + // translators: %s: The new pattern's title e.g. 'Call to action (copy)'. + __( '"%s" duplicated.' ), + item.title + ), + { + type: 'snackbar', + id: 'edit-site-patterns-success', + } + ); + + history.push( { + categoryType: USER_PATTERNS, + categoryId: USER_PATTERN_CATEGORY, + postType: USER_PATTERNS, + postId: result?.id, + } ); + + onClose(); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while creating the pattern.' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + id: 'edit-site-patterns-error', + } ); + onClose(); + } + } + + const createItem = + item.type === TEMPLATE_PARTS ? createTemplatePart : createPattern; + + return <MenuItem onClick={ createItem }>{ label }</MenuItem>; +} diff --git a/packages/edit-site/src/components/page-patterns/grid-item.js b/packages/edit-site/src/components/page-patterns/grid-item.js new file mode 100644 index 00000000000000..f8a2337bf43294 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/grid-item.js @@ -0,0 +1,284 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { BlockPreview } from '@wordpress/block-editor'; +import { + Button, + __experimentalConfirmDialog as ConfirmDialog, + DropdownMenu, + MenuGroup, + MenuItem, + __experimentalHeading as Heading, + __experimentalHStack as HStack, + Tooltip, + Flex, +} from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { useState, useId, memo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + Icon, + header, + footer, + symbolFilled as uncategorized, + symbol, + moreVertical, + lockSmall, +} from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; +import { store as reusableBlocksStore } from '@wordpress/reusable-blocks'; + +/** + * Internal dependencies + */ +import RenameMenuItem from './rename-menu-item'; +import DuplicateMenuItem from './duplicate-menu-item'; +import { PATTERNS, TEMPLATE_PARTS, USER_PATTERNS, SYNC_TYPES } from './utils'; +import { store as editSiteStore } from '../../store'; +import { useLink } from '../routes/link'; + +const templatePartIcons = { header, footer, uncategorized }; + +function GridItem( { categoryId, item, ...props } ) { + const descriptionId = useId(); + const [ isDeleteDialogOpen, setIsDeleteDialogOpen ] = useState( false ); + + const { removeTemplate } = useDispatch( editSiteStore ); + const { __experimentalDeleteReusableBlock } = + useDispatch( reusableBlocksStore ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( noticesStore ); + + const isUserPattern = item.type === USER_PATTERNS; + const isNonUserPattern = item.type === PATTERNS; + const isTemplatePart = item.type === TEMPLATE_PARTS; + + const { onClick } = useLink( { + postType: item.type, + postId: isUserPattern ? item.id : item.name, + categoryId, + categoryType: item.type, + } ); + + const isEmpty = ! item.blocks?.length; + const patternClassNames = classnames( 'edit-site-patterns__pattern', { + 'is-placeholder': isEmpty, + } ); + const previewClassNames = classnames( 'edit-site-patterns__preview', { + 'is-inactive': isNonUserPattern, + } ); + + const deletePattern = async () => { + try { + await __experimentalDeleteReusableBlock( item.id ); + createSuccessNotice( + sprintf( + // translators: %s: The pattern's title e.g. 'Call to action'. + __( '"%s" deleted.' ), + item.title + ), + { type: 'snackbar', id: 'edit-site-patterns-success' } + ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while deleting the pattern.' ); + createErrorNotice( errorMessage, { + type: 'snackbar', + id: 'edit-site-patterns-error', + } ); + } + }; + const deleteItem = () => + isTemplatePart ? removeTemplate( item ) : deletePattern(); + + // Only custom patterns or custom template parts can be renamed or deleted. + const isCustomPattern = + isUserPattern || ( isTemplatePart && item.isCustom ); + const hasThemeFile = isTemplatePart && item.templatePart.has_theme_file; + const ariaDescriptions = []; + + if ( isCustomPattern ) { + // User patterns don't have descriptions, but can be edited and deleted, so include some help text. + ariaDescriptions.push( + __( 'Press Enter to edit, or Delete to delete the pattern.' ) + ); + } else if ( item.description ) { + ariaDescriptions.push( item.description ); + } + + if ( isNonUserPattern ) { + ariaDescriptions.push( + __( 'Theme & plugin patterns cannot be edited.' ) + ); + } + + const itemIcon = + templatePartIcons[ categoryId ] || + ( item.syncStatus === SYNC_TYPES.full ? symbol : undefined ); + + const confirmButtonText = hasThemeFile ? __( 'Clear' ) : __( 'Delete' ); + const confirmPrompt = hasThemeFile + ? __( 'Are you sure you want to clear these customizations?' ) + : sprintf( + // translators: %s: The pattern or template part's title e.g. 'Call to action'. + __( 'Are you sure you want to delete "%s"?' ), + item.title + ); + + return ( + <li className={ patternClassNames }> + <button + className={ previewClassNames } + // Even though still incomplete, passing ids helps performance. + // @see https://reakit.io/docs/composite/#performance. + id={ `edit-site-patterns-${ item.name }` } + { ...props } + onClick={ item.type !== PATTERNS ? onClick : undefined } + aria-disabled={ item.type !== PATTERNS ? 'false' : 'true' } + aria-label={ item.title } + aria-describedby={ + ariaDescriptions.length + ? ariaDescriptions + .map( + ( _, index ) => + `${ descriptionId }-${ index }` + ) + .join( ' ' ) + : undefined + } + > + { isEmpty && __( 'Empty pattern' ) } + { ! isEmpty && <BlockPreview blocks={ item.blocks } /> } + </button> + { ariaDescriptions.map( ( ariaDescription, index ) => ( + <div + key={ index } + hidden + id={ `${ descriptionId }-${ index }` } + > + { ariaDescription } + </div> + ) ) } + <HStack + className="edit-site-patterns__footer" + justify="space-between" + > + <HStack + alignment="center" + justify="left" + spacing={ 3 } + className="edit-site-patterns__pattern-title" + > + { itemIcon && ! isNonUserPattern && ( + <Tooltip + position="top center" + text={ __( + 'Editing this pattern will also update anywhere it is used' + ) } + > + <span> + <Icon + className="edit-site-patterns__pattern-icon" + icon={ itemIcon } + /> + </span> + </Tooltip> + ) } + <Flex as="span" gap={ 0 } justify="left"> + { item.type === PATTERNS ? ( + item.title + ) : ( + <Heading level={ 5 }> + <Button + variant="link" + onClick={ onClick } + // Required for the grid's roving tab index system. + // See https://github.com/WordPress/gutenberg/pull/51898#discussion_r1243399243. + tabIndex="-1" + > + { item.title } + </Button> + </Heading> + ) } + { item.type === PATTERNS && ( + <Tooltip + position="top center" + text={ __( 'This pattern cannot be edited.' ) } + > + <span className="edit-site-patterns__pattern-lock-icon"> + <Icon icon={ lockSmall } size={ 24 } /> + </span> + </Tooltip> + ) } + </Flex> + </HStack> + <DropdownMenu + icon={ moreVertical } + label={ __( 'Actions' ) } + className="edit-site-patterns__dropdown" + popoverProps={ { placement: 'bottom-end' } } + toggleProps={ { + className: 'edit-site-patterns__button', + describedBy: sprintf( + /* translators: %s: pattern name */ + __( 'Action menu for %s pattern' ), + item.title + ), + } } + > + { ( { onClose } ) => ( + <MenuGroup> + { isCustomPattern && ! hasThemeFile && ( + <RenameMenuItem + item={ item } + onClose={ onClose } + /> + ) } + <DuplicateMenuItem + categoryId={ categoryId } + item={ item } + onClose={ onClose } + label={ + isNonUserPattern + ? __( 'Copy to My patterns' ) + : __( 'Duplicate' ) + } + /> + { isCustomPattern && ( + <MenuItem + isDestructive={ ! hasThemeFile } + onClick={ () => + setIsDeleteDialogOpen( true ) + } + > + { hasThemeFile + ? __( 'Clear customizations' ) + : __( 'Delete' ) } + </MenuItem> + ) } + </MenuGroup> + ) } + </DropdownMenu> + </HStack> + + { isDeleteDialogOpen && ( + <ConfirmDialog + confirmButtonText={ confirmButtonText } + onConfirm={ deleteItem } + onCancel={ () => setIsDeleteDialogOpen( false ) } + > + { confirmPrompt } + </ConfirmDialog> + ) } + </li> + ); +} + +export default memo( GridItem ); diff --git a/packages/edit-site/src/components/page-patterns/grid.js b/packages/edit-site/src/components/page-patterns/grid.js new file mode 100644 index 00000000000000..32fa6ebe55b39d --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/grid.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import GridItem from './grid-item'; + +export default function Grid( { categoryId, items, ...props } ) { + if ( ! items?.length ) { + return null; + } + + return ( + <ul role="listbox" className="edit-site-patterns__grid" { ...props }> + { items.map( ( item ) => ( + <GridItem + key={ item.name } + item={ item } + categoryId={ categoryId } + /> + ) ) } + </ul> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/header.js b/packages/edit-site/src/components/page-patterns/header.js new file mode 100644 index 00000000000000..1237b85d6c9787 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/header.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHeading as Heading, + __experimentalText as Text, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { store as editorStore } from '@wordpress/editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import usePatternCategories from '../sidebar-navigation-screen-patterns/use-pattern-categories'; +import { + USER_PATTERN_CATEGORY, + USER_PATTERNS, + TEMPLATE_PARTS, + PATTERNS, +} from './utils'; + +export default function PatternsHeader( { + categoryId, + type, + titleId, + descriptionId, +} ) { + const { patternCategories } = usePatternCategories(); + const templatePartAreas = useSelect( + ( select ) => + select( editorStore ).__experimentalGetDefaultTemplatePartAreas(), + [] + ); + + let title, description; + if ( categoryId === USER_PATTERN_CATEGORY && type === USER_PATTERNS ) { + title = __( 'My Patterns' ); + description = ''; + } else if ( type === TEMPLATE_PARTS ) { + const templatePartArea = templatePartAreas.find( + ( area ) => area.area === categoryId + ); + title = templatePartArea?.label; + description = templatePartArea?.description; + } else if ( type === PATTERNS ) { + const patternCategory = patternCategories.find( + ( category ) => category.name === categoryId + ); + title = patternCategory?.label; + description = patternCategory?.description; + } + + if ( ! title ) return null; + + return ( + <VStack className="edit-site-patterns__section-header"> + <Heading as="h2" level={ 4 } id={ titleId }> + { title } + </Heading> + { description ? ( + <Text variant="muted" as="p" id={ descriptionId }> + { description } + </Text> + ) : null } + </VStack> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/index.js b/packages/edit-site/src/components/page-patterns/index.js new file mode 100644 index 00000000000000..d90fc748442444 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/index.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { getQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { DEFAULT_CATEGORY, DEFAULT_TYPE } from './utils'; +import Page from '../page'; +import PatternsList from './patterns-list'; +import usePatternSettings from './use-pattern-settings'; +import { unlock } from '../../lock-unlock'; + +const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); + +export default function PagePatterns() { + const { categoryType, categoryId } = getQueryArgs( window.location.href ); + const type = categoryType || DEFAULT_TYPE; + const category = categoryId || DEFAULT_CATEGORY; + const settings = usePatternSettings(); + + // Wrap everything in a block editor provider. + // This ensures 'styles' that are needed for the previews are synced + // from the site editor store to the block editor store. + return ( + <ExperimentalBlockEditorProvider settings={ settings }> + <Page + className="edit-site-patterns" + title={ __( 'Patterns content' ) } + hideTitleFromUI + > + <PatternsList + // Reset the states when switching between categories and types. + key={ `${ type }-${ category }` } + type={ type } + categoryId={ category } + /> + </Page> + </ExperimentalBlockEditorProvider> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/no-patterns.js b/packages/edit-site/src/components/page-patterns/no-patterns.js new file mode 100644 index 00000000000000..b4805f57018c7c --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/no-patterns.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export default function NoPatterns() { + return ( + <div className="edit-site-patterns__no-results"> + { __( 'No patterns found.' ) } + </div> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/pagination.js b/packages/edit-site/src/components/page-patterns/pagination.js new file mode 100644 index 00000000000000..702e24cd31b7f0 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/pagination.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalText as Text, + Button, +} from '@wordpress/components'; +import { __, _x, _n, sprintf } from '@wordpress/i18n'; + +export default function Pagination( { + currentPage, + numPages, + changePage, + totalItems, +} ) { + return ( + <HStack + expanded={ false } + spacing={ 3 } + justify="flex-start" + className="edit-site-patterns__grid-pagination" + > + <Text variant="muted"> + { + // translators: %s: Total number of patterns. + sprintf( + // translators: %s: Total number of patterns. + _n( '%s item', '%s items', totalItems ), + totalItems + ) + } + </Text> + <HStack expanded={ false } spacing={ 1 }> + <Button + variant="tertiary" + onClick={ () => changePage( 1 ) } + disabled={ currentPage === 1 } + aria-label={ __( 'First page' ) } + > + « + </Button> + <Button + variant="tertiary" + onClick={ () => changePage( currentPage - 1 ) } + disabled={ currentPage === 1 } + aria-label={ __( 'Previous page' ) } + > + ‹ + </Button> + </HStack> + <Text variant="muted"> + { sprintf( + // translators: %1$s: Current page number, %2$s: Total number of pages. + _x( '%1$s of %2$s', 'paging' ), + currentPage, + numPages + ) } + </Text> + <HStack expanded={ false } spacing={ 1 }> + <Button + variant="tertiary" + onClick={ () => changePage( currentPage + 1 ) } + disabled={ currentPage === numPages } + aria-label={ __( 'Next page' ) } + > + › + </Button> + <Button + variant="tertiary" + onClick={ () => changePage( numPages ) } + disabled={ currentPage === numPages } + aria-label={ __( 'Last page' ) } + > + » + </Button> + </HStack> + </HStack> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/patterns-list.js b/packages/edit-site/src/components/page-patterns/patterns-list.js new file mode 100644 index 00000000000000..01525fc5dccab7 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/patterns-list.js @@ -0,0 +1,219 @@ +/** + * WordPress dependencies + */ +import { useState, useDeferredValue, useId, useMemo } from '@wordpress/element'; +import { + SearchControl, + __experimentalVStack as VStack, + Flex, + FlexBlock, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + __experimentalHeading as Heading, + __experimentalText as Text, +} from '@wordpress/components'; +import { __, isRTL } from '@wordpress/i18n'; +import { chevronLeft, chevronRight } from '@wordpress/icons'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { useAsyncList, useViewportMatch } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import PatternsHeader from './header'; +import Grid from './grid'; +import NoPatterns from './no-patterns'; +import usePatterns from './use-patterns'; +import SidebarButton from '../sidebar-button'; +import useDebouncedInput from '../../utils/use-debounced-input'; +import { unlock } from '../../lock-unlock'; +import { SYNC_TYPES, USER_PATTERN_CATEGORY, PATTERNS } from './utils'; +import Pagination from './pagination'; + +const { useLocation, useHistory } = unlock( routerPrivateApis ); + +const SYNC_FILTERS = { + all: __( 'All' ), + [ SYNC_TYPES.full ]: __( 'Synced' ), + [ SYNC_TYPES.unsynced ]: __( 'Standard' ), +}; + +const SYNC_DESCRIPTIONS = { + all: '', + [ SYNC_TYPES.full ]: __( + 'Patterns that are kept in sync across the site.' + ), + [ SYNC_TYPES.unsynced ]: __( + 'Patterns that can be changed freely without affecting the site.' + ), +}; + +const PAGE_SIZE = 20; + +export default function PatternsList( { categoryId, type } ) { + const location = useLocation(); + const history = useHistory(); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const [ filterValue, setFilterValue, delayedFilterValue ] = + useDebouncedInput( '' ); + const deferredFilterValue = useDeferredValue( delayedFilterValue ); + + const [ syncFilter, setSyncFilter ] = useState( 'all' ); + const [ currentPage, setCurrentPage ] = useState( 1 ); + + const deferredSyncedFilter = useDeferredValue( syncFilter ); + + const isUncategorizedThemePatterns = + type === PATTERNS && categoryId === 'uncategorized'; + + const { patterns, isResolving } = usePatterns( + type, + isUncategorizedThemePatterns ? '' : categoryId, + { + search: deferredFilterValue, + syncStatus: + deferredSyncedFilter === 'all' + ? undefined + : deferredSyncedFilter, + } + ); + + const updateSearchFilter = ( value ) => { + setCurrentPage( 1 ); + setFilterValue( value ); + }; + + const updateSyncFilter = ( value ) => { + setCurrentPage( 1 ); + setSyncFilter( value ); + }; + + const id = useId(); + const titleId = `${ id }-title`; + const descriptionId = `${ id }-description`; + + const hasPatterns = patterns.length; + const title = SYNC_FILTERS[ syncFilter ]; + const description = SYNC_DESCRIPTIONS[ syncFilter ]; + + const totalItems = patterns.length; + const pageIndex = currentPage - 1; + const numPages = Math.ceil( patterns.length / PAGE_SIZE ); + + const list = useMemo( () => { + return patterns.slice( + pageIndex * PAGE_SIZE, + pageIndex * PAGE_SIZE + PAGE_SIZE + ); + }, [ pageIndex, patterns ] ); + + const asyncList = useAsyncList( list, { step: 10 } ); + + const changePage = ( page ) => { + const scrollContainer = document.querySelector( '.edit-site-patterns' ); + scrollContainer?.scrollTo( 0, 0 ); + + setCurrentPage( page ); + }; + + return ( + <> + <VStack className="edit-site-patterns__header" spacing={ 6 }> + <PatternsHeader + categoryId={ categoryId } + type={ type } + titleId={ titleId } + descriptionId={ descriptionId } + /> + <Flex alignment="stretch" wrap> + { isMobileViewport && ( + <SidebarButton + icon={ isRTL() ? chevronRight : chevronLeft } + label={ __( 'Back' ) } + onClick={ () => { + // Go back in history if we came from the Patterns page. + // Otherwise push a stack onto the history. + if ( + location.state?.backPath === '/patterns' + ) { + history.back(); + } else { + history.push( { path: '/patterns' } ); + } + } } + /> + ) } + <FlexBlock className="edit-site-patterns__search-block"> + <SearchControl + className="edit-site-patterns__search" + onChange={ ( value ) => + updateSearchFilter( value ) + } + placeholder={ __( 'Search patterns' ) } + label={ __( 'Search patterns' ) } + value={ filterValue } + __nextHasNoMarginBottom + /> + </FlexBlock> + { categoryId === USER_PATTERN_CATEGORY && ( + <ToggleGroupControl + className="edit-site-patterns__sync-status-filter" + hideLabelFromVision + label={ __( 'Filter by sync status' ) } + value={ syncFilter } + isBlock + onChange={ ( value ) => updateSyncFilter( value ) } + __nextHasNoMarginBottom + > + { Object.entries( SYNC_FILTERS ).map( + ( [ key, label ] ) => ( + <ToggleGroupControlOption + className="edit-site-patterns__sync-status-filter-option" + key={ key } + value={ key } + label={ label } + /> + ) + ) } + </ToggleGroupControl> + ) } + </Flex> + </VStack> + <VStack + className="edit-site-patterns__section" + justify="flex-start" + spacing={ 6 } + > + { syncFilter !== 'all' && ( + <VStack className="edit-site-patterns__section-header"> + <Heading as="h3" level={ 5 } id={ titleId }> + { title } + </Heading> + { description ? ( + <Text variant="muted" as="p" id={ descriptionId }> + { description } + </Text> + ) : null } + </VStack> + ) } + { hasPatterns && ( + <Grid + categoryId={ categoryId } + items={ asyncList } + aria-labelledby={ titleId } + aria-describedby={ descriptionId } + /> + ) } + { ! isResolving && ! hasPatterns && <NoPatterns /> } + </VStack> + { numPages > 1 && ( + <Pagination + currentPage={ currentPage } + numPages={ numPages } + changePage={ changePage } + totalItems={ totalItems } + /> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/rename-menu-item.js b/packages/edit-site/src/components/page-patterns/rename-menu-item.js new file mode 100644 index 00000000000000..938023a62cefd3 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/rename-menu-item.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { + Button, + MenuItem, + Modal, + TextControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { TEMPLATE_PARTS } from './utils'; + +export default function RenameMenuItem( { item, onClose } ) { + const [ title, setTitle ] = useState( () => item.title ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + const { editEntityRecord, saveEditedEntityRecord } = + useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + if ( item.type === TEMPLATE_PARTS && ! item.isCustom ) { + return null; + } + + async function onRename( event ) { + event.preventDefault(); + + try { + await editEntityRecord( 'postType', item.type, item.id, { title } ); + + // Update state before saving rerenders the list. + setTitle( '' ); + setIsModalOpen( false ); + onClose(); + + // Persist edited entity. + await saveEditedEntityRecord( 'postType', item.type, item.id, { + throwOnError: true, + } ); + + createSuccessNotice( __( 'Entity renamed.' ), { + type: 'snackbar', + } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while renaming the entity.' ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + + return ( + <> + <MenuItem + onClick={ () => { + setIsModalOpen( true ); + setTitle( item.title ); + } } + > + { __( 'Rename' ) } + </MenuItem> + { isModalOpen && ( + <Modal + title={ __( 'Rename' ) } + onRequestClose={ () => { + setIsModalOpen( false ); + onClose(); + } } + overlayClassName="edit-site-list__rename_modal" + > + <form onSubmit={ onRename }> + <VStack spacing="5"> + <TextControl + __nextHasNoMarginBottom + label={ __( 'Name' ) } + value={ title } + onChange={ setTitle } + required + /> + + <HStack justify="right"> + <Button + variant="tertiary" + onClick={ () => { + setIsModalOpen( false ); + onClose(); + } } + > + { __( 'Cancel' ) } + </Button> + + <Button variant="primary" type="submit"> + { __( 'Save' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/page-patterns/search-items.js b/packages/edit-site/src/components/page-patterns/search-items.js new file mode 100644 index 00000000000000..9026e7f39f4bf9 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/search-items.js @@ -0,0 +1,171 @@ +/** + * External dependencies + */ +import removeAccents from 'remove-accents'; +import { noCase } from 'change-case'; + +// Default search helpers. +const defaultGetName = ( item ) => item.name || ''; +const defaultGetTitle = ( item ) => item.title; +const defaultGetDescription = ( item ) => item.description || ''; +const defaultGetKeywords = ( item ) => item.keywords || []; +const defaultHasCategory = () => false; + +/** + * Extracts words from an input string. + * + * @param {string} input The input string. + * + * @return {Array} Words, extracted from the input string. + */ +function extractWords( input = '' ) { + return noCase( input, { + splitRegexp: [ + /([\p{Ll}\p{Lo}\p{N}])([\p{Lu}\p{Lt}])/gu, // One lowercase or digit, followed by one uppercase. + /([\p{Lu}\p{Lt}])([\p{Lu}\p{Lt}][\p{Ll}\p{Lo}])/gu, // One uppercase followed by one uppercase and one lowercase. + ], + stripRegexp: /(\p{C}|\p{P}|\p{S})+/giu, // Anything that's not a punctuation, symbol or control/format character. + } ) + .split( ' ' ) + .filter( Boolean ); +} + +/** + * Sanitizes the search input string. + * + * @param {string} input The search input to normalize. + * + * @return {string} The normalized search input. + */ +function normalizeSearchInput( input = '' ) { + // Disregard diacritics. + // Input: "média" + input = removeAccents( input ); + + // Accommodate leading slash, matching autocomplete expectations. + // Input: "/media" + input = input.replace( /^\//, '' ); + + // Lowercase. + // Input: "MEDIA" + input = input.toLowerCase(); + + return input; +} + +/** + * Converts the search term into a list of normalized terms. + * + * @param {string} input The search term to normalize. + * + * @return {string[]} The normalized list of search terms. + */ +export const getNormalizedSearchTerms = ( input = '' ) => { + return extractWords( normalizeSearchInput( input ) ); +}; + +const removeMatchingTerms = ( unmatchedTerms, unprocessedTerms ) => { + return unmatchedTerms.filter( + ( term ) => + ! getNormalizedSearchTerms( unprocessedTerms ).some( + ( unprocessedTerm ) => unprocessedTerm.includes( term ) + ) + ); +}; + +/** + * Filters an item list given a search term. + * + * @param {Array} items Item list + * @param {string} searchInput Search input. + * @param {Object} config Search Config. + * + * @return {Array} Filtered item list. + */ +export const searchItems = ( items = [], searchInput = '', config = {} ) => { + const normalizedSearchTerms = getNormalizedSearchTerms( searchInput ); + const onlyFilterByCategory = ! normalizedSearchTerms.length; + const searchRankConfig = { ...config, onlyFilterByCategory }; + + // If we aren't filtering on search terms, matching on category is satisfactory. + // If we are, then we need more than a category match. + const threshold = onlyFilterByCategory ? 0 : 1; + + const rankedItems = items + .map( ( item ) => { + return [ + item, + getItemSearchRank( item, searchInput, searchRankConfig ), + ]; + } ) + .filter( ( [ , rank ] ) => rank > threshold ); + + // If we didn't have terms to search on, there's no point sorting. + if ( normalizedSearchTerms.length === 0 ) { + return rankedItems.map( ( [ item ] ) => item ); + } + + rankedItems.sort( ( [ , rank1 ], [ , rank2 ] ) => rank2 - rank1 ); + return rankedItems.map( ( [ item ] ) => item ); +}; + +/** + * Get the search rank for a given item and a specific search term. + * The better the match, the higher the rank. + * If the rank equals 0, it should be excluded from the results. + * + * @param {Object} item Item to filter. + * @param {string} searchTerm Search term. + * @param {Object} config Search Config. + * + * @return {number} Search Rank. + */ +function getItemSearchRank( item, searchTerm, config ) { + const { + categoryId, + getName = defaultGetName, + getTitle = defaultGetTitle, + getDescription = defaultGetDescription, + getKeywords = defaultGetKeywords, + hasCategory = defaultHasCategory, + onlyFilterByCategory, + } = config; + + let rank = hasCategory( item, categoryId ) ? 1 : 0; + + // If an item doesn't belong to the current category or we don't have + // search terms to filter by, return the initial rank value. + if ( ! rank || onlyFilterByCategory ) { + return rank; + } + + const name = getName( item ); + const title = getTitle( item ); + const description = getDescription( item ); + const keywords = getKeywords( item ); + + const normalizedSearchInput = normalizeSearchInput( searchTerm ); + const normalizedTitle = normalizeSearchInput( title ); + + // Prefers exact matches + // Then prefers if the beginning of the title matches the search term + // name, keywords, description matches come later. + if ( normalizedSearchInput === normalizedTitle ) { + rank += 30; + } else if ( normalizedTitle.startsWith( normalizedSearchInput ) ) { + rank += 20; + } else { + const terms = [ name, title, description, ...keywords ].join( ' ' ); + const normalizedSearchTerms = extractWords( normalizedSearchInput ); + const unmatchedTerms = removeMatchingTerms( + normalizedSearchTerms, + terms + ); + + if ( unmatchedTerms.length === 0 ) { + rank += 10; + } + } + + return rank; +} diff --git a/packages/edit-site/src/components/page-patterns/style.scss b/packages/edit-site/src/components/page-patterns/style.scss new file mode 100644 index 00000000000000..f425e08c57878b --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/style.scss @@ -0,0 +1,224 @@ +.edit-site-patterns { + border-left: 1px solid $gray-800; + background: none; + margin: $header-height 0 0; + border-radius: 0; + padding: 0; + overflow-x: auto; + + .components-base-control { + width: 100%; + @include break-medium { + width: auto; + } + } + + .components-text { + color: $gray-600; + } + + .components-heading { + color: $gray-200; + } + + @include break-medium { + margin: 0; + } + + .edit-site-patterns__search-block { + min-width: fit-content; + flex-grow: 1; + } + + // The increased specificity here is to overcome component styles + // without relying on internal component class names. + .edit-site-patterns__search { + input[type="search"] { + height: $button-size-next-default-40px; + background: $gray-800; + color: $gray-200; + + &:focus { + background: $gray-800; + } + } + + svg { + fill: $gray-600; + } + } + + .edit-site-patterns__sync-status-filter { + background: $gray-800; + border: none; + height: $button-size-next-default-40px; + min-width: max-content; + width: 100%; + max-width: 100%; + + @include break-medium { + width: 300px; + } + } + .edit-site-patterns__sync-status-filter-option:not([aria-checked="true"]) { + color: $gray-600; + } + .edit-site-patterns__sync-status-filter-option:active { + background: $gray-700; + color: $gray-100; + } + + .edit-site-patterns__grid-pagination { + border-top: 1px solid $gray-800; + background: $gray-900; + padding: $grid-unit-30 $grid-unit-40; + position: sticky; + bottom: 0; + z-index: z-index(".edit-site-patterns__grid-pagination"); + + .components-button.is-tertiary { + width: $button-size-compact; + height: $button-size-compact; + color: $gray-100; + background-color: $gray-800; + justify-content: center; + + &:disabled { + color: $gray-600; + background: none; + } + + &:hover:not(:disabled) { + background-color: $gray-700; + } + } + } +} + +.edit-site-patterns__header { + position: sticky; + top: 0; + background: $gray-900; + padding: $grid-unit-40 $grid-unit-40 $grid-unit-20; + z-index: z-index(".edit-site-patterns__header"); +} + +.edit-site-patterns__section { + padding: $grid-unit-30 $grid-unit-40; + flex: 1; +} + +.edit-site-patterns__section-header { + .screen-reader-shortcut:focus { + top: 0; + } +} + +.edit-site-patterns__grid { + display: grid; + grid-template-columns: 1fr; + gap: $grid-unit-40; + margin-top: 0; + margin-bottom: 0; + @include break-large { + grid-template-columns: 1fr 1fr; + } + @include break-huge { + grid-template-columns: 1fr 1fr 1fr; + } + @include break-xhuge { + grid-template-columns: 1fr 1fr 1fr 1fr; + } + .edit-site-patterns__pattern { + break-inside: avoid-column; + display: flex; + flex-direction: column; + .edit-site-patterns__preview { + box-shadow: none; + border: none; + padding: 0; + background-color: unset; + box-sizing: border-box; + border-radius: 4px; + cursor: pointer; + overflow: hidden; + + &:focus { + box-shadow: inset 0 0 0 0 $white, 0 0 0 2px var(--wp-admin-theme-color); + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + } + + &.is-inactive { + cursor: default; + } + &.is-inactive:focus { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $gray-800; + opacity: 0.8; + } + } + + .edit-site-patterns__footer, + .edit-site-patterns__button { + color: $gray-600; + } + + .edit-site-patterns__dropdown { + flex-shrink: 0; + } + + &.is-placeholder .edit-site-patterns__preview { + min-height: $grid-unit-80; + color: $gray-600; + border: 1px dashed $gray-800; + display: flex; + align-items: center; + justify-content: center; + + &:focus { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + } + } + } + + .edit-site-patterns__preview { + flex: 0 1 auto; + margin-bottom: $grid-unit-15; + } +} + +.edit-site-patterns__load-more { + align-self: center; +} + +.edit-site-patterns__pattern-title { + color: $gray-200; + + .is-link { + text-decoration: none; + color: $gray-200; + + &:hover, + &:focus { + color: $white; + } + } + + .edit-site-patterns__pattern-icon { + border-radius: $grid-unit-05; + background: var(--wp-block-synced-color); + fill: $white; + } + + .edit-site-patterns__pattern-lock-icon { + display: inline-flex; + + svg { + fill: currentcolor; + } + } +} + +.edit-site-patterns__no-results { + color: $gray-600; +} diff --git a/packages/edit-site/src/components/page-patterns/use-pattern-settings.js b/packages/edit-site/src/components/page-patterns/use-pattern-settings.js new file mode 100644 index 00000000000000..28a16b1d7ed7db --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/use-pattern-settings.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import { filterOutDuplicatesByName } from './utils'; + +export default function usePatternSettings() { + const storedSettings = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + return getSettings(); + }, [] ); + + const settingsBlockPatterns = + storedSettings.__experimentalAdditionalBlockPatterns ?? // WP 6.0 + storedSettings.__experimentalBlockPatterns; // WP 5.9 + + const restBlockPatterns = useSelect( + ( select ) => select( coreStore ).getBlockPatterns(), + [] + ); + + const blockPatterns = useMemo( + () => + [ + ...( settingsBlockPatterns || [] ), + ...( restBlockPatterns || [] ), + ].filter( filterOutDuplicatesByName ), + [ settingsBlockPatterns, restBlockPatterns ] + ); + + const settings = useMemo( () => { + const { __experimentalAdditionalBlockPatterns, ...restStoredSettings } = + storedSettings; + + return { + ...restStoredSettings, + __experimentalBlockPatterns: blockPatterns, + __unstableIsPreviewMode: true, + }; + }, [ storedSettings, blockPatterns ] ); + + return settings; +} diff --git a/packages/edit-site/src/components/page-patterns/use-patterns.js b/packages/edit-site/src/components/page-patterns/use-patterns.js new file mode 100644 index 00000000000000..4aeb6527bca262 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/use-patterns.js @@ -0,0 +1,199 @@ +/** + * WordPress dependencies + */ +import { parse } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { + CORE_PATTERN_SOURCES, + PATTERNS, + SYNC_TYPES, + TEMPLATE_PARTS, + USER_PATTERNS, + filterOutDuplicatesByName, +} from './utils'; +import { unlock } from '../../lock-unlock'; +import { searchItems } from './search-items'; +import { store as editSiteStore } from '../../store'; + +const EMPTY_PATTERN_LIST = []; + +const createTemplatePartId = ( theme, slug ) => + theme && slug ? theme + '//' + slug : null; + +const templatePartToPattern = ( templatePart ) => ( { + blocks: parse( templatePart.content.raw, { + __unstableSkipMigrationLogs: true, + } ), + categories: [ templatePart.area ], + description: templatePart.description || '', + isCustom: templatePart.source === 'custom', + keywords: templatePart.keywords || [], + id: createTemplatePartId( templatePart.theme, templatePart.slug ), + name: createTemplatePartId( templatePart.theme, templatePart.slug ), + title: decodeEntities( templatePart.title.rendered ), + type: templatePart.type, + templatePart, +} ); + +const selectTemplatePartsAsPatterns = ( + select, + { categoryId, search = '' } = {} +) => { + const { getEntityRecords, getIsResolving } = select( coreStore ); + const { __experimentalGetDefaultTemplatePartAreas } = select( editorStore ); + const query = { per_page: -1 }; + const rawTemplateParts = + getEntityRecords( 'postType', TEMPLATE_PARTS, query ) ?? + EMPTY_PATTERN_LIST; + const templateParts = rawTemplateParts.map( ( templatePart ) => + templatePartToPattern( templatePart ) + ); + + // In the case where a custom template part area has been removed we need + // the current list of areas to cross check against so orphaned template + // parts can be treated as uncategorized. + const knownAreas = __experimentalGetDefaultTemplatePartAreas() || []; + const templatePartAreas = knownAreas.map( ( area ) => area.area ); + + const templatePartHasCategory = ( item, category ) => { + if ( category !== 'uncategorized' ) { + return item.templatePart.area === category; + } + + return ( + item.templatePart.area === category || + ! templatePartAreas.includes( item.templatePart.area ) + ); + }; + + const isResolving = getIsResolving( 'getEntityRecords', [ + 'postType', + 'wp_template_part', + query, + ] ); + + const patterns = searchItems( templateParts, search, { + categoryId, + hasCategory: templatePartHasCategory, + } ); + + return { patterns, isResolving }; +}; + +const selectThemePatterns = ( select, { categoryId, search = '' } = {} ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + const settings = getSettings(); + const blockPatterns = + settings.__experimentalAdditionalBlockPatterns ?? + settings.__experimentalBlockPatterns; + + const restBlockPatterns = select( coreStore ).getBlockPatterns(); + + let patterns = [ + ...( blockPatterns || [] ), + ...( restBlockPatterns || [] ), + ] + .filter( + ( pattern ) => ! CORE_PATTERN_SOURCES.includes( pattern.source ) + ) + .filter( filterOutDuplicatesByName ) + .filter( ( pattern ) => pattern.inserter !== false ) + .map( ( pattern ) => ( { + ...pattern, + keywords: pattern.keywords || [], + type: 'pattern', + blocks: parse( pattern.content, { + __unstableSkipMigrationLogs: true, + } ), + } ) ); + + if ( categoryId ) { + patterns = searchItems( patterns, search, { + categoryId, + hasCategory: ( item, currentCategory ) => + item.categories?.includes( currentCategory ), + } ); + } else { + patterns = searchItems( patterns, search, { + hasCategory: ( item ) => ! item.hasOwnProperty( 'categories' ), + } ); + } + + return { patterns, isResolving: false }; +}; + +const reusableBlockToPattern = ( reusableBlock ) => ( { + blocks: parse( reusableBlock.content.raw, { + __unstableSkipMigrationLogs: true, + } ), + categories: reusableBlock.wp_pattern, + id: reusableBlock.id, + name: reusableBlock.slug, + syncStatus: reusableBlock.wp_pattern_sync_status || SYNC_TYPES.full, + title: reusableBlock.title.raw, + type: reusableBlock.type, + reusableBlock, +} ); + +const selectUserPatterns = ( select, { search = '', syncStatus } = {} ) => { + const { getEntityRecords, getIsResolving } = select( coreStore ); + + const query = { per_page: -1 }; + const records = getEntityRecords( 'postType', USER_PATTERNS, query ); + + let patterns = records + ? records.map( ( record ) => reusableBlockToPattern( record ) ) + : EMPTY_PATTERN_LIST; + const isResolving = getIsResolving( 'getEntityRecords', [ + 'postType', + USER_PATTERNS, + query, + ] ); + + if ( syncStatus ) { + patterns = patterns.filter( + ( pattern ) => pattern.syncStatus === syncStatus + ); + } + + patterns = searchItems( patterns, search, { + // We exit user pattern retrieval early if we aren't in the + // catch-all category for user created patterns, so it has + // to be in the category. + hasCategory: () => true, + } ); + + return { patterns, isResolving }; +}; + +export const usePatterns = ( + categoryType, + categoryId, + { search = '', syncStatus } +) => { + return useSelect( + ( select ) => { + if ( categoryType === TEMPLATE_PARTS ) { + return selectTemplatePartsAsPatterns( select, { + categoryId, + search, + } ); + } else if ( categoryType === PATTERNS ) { + return selectThemePatterns( select, { categoryId, search } ); + } else if ( categoryType === USER_PATTERNS ) { + return selectUserPatterns( select, { search, syncStatus } ); + } + return { patterns: EMPTY_PATTERN_LIST, isResolving: false }; + }, + [ categoryId, categoryType, search, syncStatus ] + ); +}; + +export default usePatterns; diff --git a/packages/edit-site/src/components/page-patterns/utils.js b/packages/edit-site/src/components/page-patterns/utils.js new file mode 100644 index 00000000000000..bbdff872fe355a --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/utils.js @@ -0,0 +1,21 @@ +export const DEFAULT_CATEGORY = 'my-patterns'; +export const DEFAULT_TYPE = 'wp_block'; +export const PATTERNS = 'pattern'; +export const TEMPLATE_PARTS = 'wp_template_part'; +export const USER_PATTERNS = 'wp_block'; +export const USER_PATTERN_CATEGORY = 'my-patterns'; + +export const CORE_PATTERN_SOURCES = [ + 'core', + 'pattern-directory/core', + 'pattern-directory/featured', + 'pattern-directory/theme', +]; + +export const SYNC_TYPES = { + full: 'fully', + unsynced: 'unsynced', +}; + +export const filterOutDuplicatesByName = ( currentItem, index, items ) => + index === items.findIndex( ( item ) => currentItem.name === item.name ); diff --git a/packages/edit-site/src/components/page-template-parts/add-new-template-part.js b/packages/edit-site/src/components/page-template-parts/add-new-template-part.js new file mode 100644 index 00000000000000..d2b52a88701fda --- /dev/null +++ b/packages/edit-site/src/components/page-template-parts/add-new-template-part.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import CreateTemplatePartModal from '../create-template-part-modal'; + +const { useHistory } = unlock( routerPrivateApis ); + +export default function AddNewTemplatePart() { + const { canCreate, postType } = useSelect( ( select ) => { + const { supportsTemplatePartsMode } = + select( editSiteStore ).getSettings(); + return { + canCreate: ! supportsTemplatePartsMode, + postType: select( coreStore ).getPostType( 'wp_template_part' ), + }; + }, [] ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const history = useHistory(); + + if ( ! canCreate || ! postType ) { + return null; + } + + return ( + <> + <Button variant="primary" onClick={ () => setIsModalOpen( true ) }> + { postType.labels.add_new_item } + </Button> + { isModalOpen && ( + <CreateTemplatePartModal + closeModal={ () => setIsModalOpen( false ) } + blocks={ [] } + onCreate={ ( templatePart ) => { + setIsModalOpen( false ); + history.push( { + postId: templatePart.id, + postType: 'wp_template_part', + canvas: 'edit', + } ); + } } + onError={ () => setIsModalOpen( false ) } + /> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/page-template-parts/index.js b/packages/edit-site/src/components/page-template-parts/index.js new file mode 100644 index 00000000000000..e03d726a575254 --- /dev/null +++ b/packages/edit-site/src/components/page-template-parts/index.js @@ -0,0 +1,85 @@ +/** + * WordPress dependencies + */ +import { + VisuallyHidden, + __experimentalHeading as Heading, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecords } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import Page from '../page'; +import Table from '../table'; +import Link from '../routes/link'; +import AddedBy from '../list/added-by'; +import TemplateActions from '../template-actions'; +import AddNewTemplatePart from './add-new-template-part'; + +export default function PageTemplateParts() { + const { records: templateParts } = useEntityRecords( + 'postType', + 'wp_template_part', + { + per_page: -1, + } + ); + + const columns = [ + { + header: __( 'Template Part' ), + cell: ( templatePart ) => ( + <VStack> + <Heading as="h3" level={ 5 }> + <Link + params={ { + postId: templatePart.id, + postType: templatePart.type, + } } + state={ { backPath: '/wp_template_part/all' } } + > + { decodeEntities( + templatePart.title?.rendered || + templatePart.slug + ) } + </Link> + </Heading> + </VStack> + ), + maxWidth: 400, + }, + { + header: __( 'Added by' ), + cell: ( templatePart ) => ( + <AddedBy + postType={ templatePart.type } + postId={ templatePart.id } + /> + ), + }, + { + header: <VisuallyHidden>{ __( 'Actions' ) }</VisuallyHidden>, + cell: ( templatePart ) => ( + <TemplateActions + postType={ templatePart.type } + postId={ templatePart.id } + /> + ), + }, + ]; + + return ( + <Page + title={ __( 'Template Parts' ) } + actions={ <AddNewTemplatePart /> } + > + { templateParts && ( + <Table data={ templateParts } columns={ columns } /> + ) } + </Page> + ); +} diff --git a/packages/edit-site/src/components/page-templates/index.js b/packages/edit-site/src/components/page-templates/index.js new file mode 100644 index 00000000000000..5b0a306fa7fef3 --- /dev/null +++ b/packages/edit-site/src/components/page-templates/index.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { + VisuallyHidden, + __experimentalHeading as Heading, + __experimentalText as Text, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecords } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import Page from '../page'; +import Table from '../table'; +import Link from '../routes/link'; +import AddedBy from '../list/added-by'; +import TemplateActions from '../template-actions'; +import AddNewTemplate from '../add-new-template'; + +export default function PageTemplates() { + const { records: templates } = useEntityRecords( + 'postType', + 'wp_template', + { + per_page: -1, + } + ); + + const columns = [ + { + header: __( 'Template' ), + cell: ( template ) => ( + <VStack> + <Heading as="h3" level={ 5 }> + <Link + params={ { + postId: template.id, + postType: template.type, + canvas: 'edit', + } } + > + { decodeEntities( + template.title?.rendered || template.slug + ) } + </Link> + </Heading> + { template.description && ( + <Text variant="muted"> + { decodeEntities( template.description ) } + </Text> + ) } + </VStack> + ), + maxWidth: 400, + }, + { + header: __( 'Added by' ), + cell: ( template ) => ( + <AddedBy postType={ template.type } postId={ template.id } /> + ), + }, + { + header: <VisuallyHidden>{ __( 'Actions' ) }</VisuallyHidden>, + cell: ( template ) => ( + <TemplateActions + postType={ template.type } + postId={ template.id } + /> + ), + }, + ]; + + return ( + <Page + title={ __( 'Templates' ) } + actions={ + <AddNewTemplate + templateType={ 'wp_template' } + showIcon={ false } + toggleProps={ { variant: 'primary' } } + /> + } + > + { templates && <Table data={ templates } columns={ columns } /> } + </Page> + ); +} diff --git a/packages/edit-site/src/components/page/header.js b/packages/edit-site/src/components/page/header.js new file mode 100644 index 00000000000000..06de80c25685bd --- /dev/null +++ b/packages/edit-site/src/components/page/header.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHeading as Heading, + __experimentalText as Text, + __experimentalHStack as HStack, + FlexBlock, + FlexItem, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ + +export default function Header( { title, subTitle, actions } ) { + return ( + <HStack as="header" alignment="left" className="edit-site-page-header"> + <FlexBlock className="edit-site-page-header__page-title"> + <Heading + as="h2" + level={ 4 } + className="edit-site-page-header__title" + > + { title } + </Heading> + { subTitle && ( + <Text as="p" className="edit-site-page-header__sub-title"> + { subTitle } + </Text> + ) } + </FlexBlock> + <FlexItem className="edit-site-page-header__actions"> + { actions } + </FlexItem> + </HStack> + ); +} diff --git a/packages/edit-site/src/components/page/index.js b/packages/edit-site/src/components/page/index.js new file mode 100644 index 00000000000000..02d0bd2e746eec --- /dev/null +++ b/packages/edit-site/src/components/page/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { NavigableRegion } from '@wordpress/interface'; +import { EditorSnackbars } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import Header from './header'; + +export default function Page( { + title, + subTitle, + actions, + children, + className, + hideTitleFromUI = false, +} ) { + const classes = classnames( 'edit-site-page', className ); + + return ( + <NavigableRegion className={ classes } ariaLabel={ title }> + <div className="edit-site-page-content"> + { ! hideTitleFromUI && title && ( + <Header + title={ title } + subTitle={ subTitle } + actions={ actions } + /> + ) } + { children } + </div> + <EditorSnackbars /> + </NavigableRegion> + ); +} diff --git a/packages/edit-site/src/components/page/style.scss b/packages/edit-site/src/components/page/style.scss new file mode 100644 index 00000000000000..8da7df8e0385b8 --- /dev/null +++ b/packages/edit-site/src/components/page/style.scss @@ -0,0 +1,41 @@ +.edit-site-page { + color: $gray-800; + background: $white; + flex-grow: 1; + overflow: hidden; + margin: 0; + margin-top: $header-height; + @include break-medium() { + border-radius: 8px; + margin: $grid-unit-30 $grid-unit-30 $grid-unit-30 0; + } +} + +.edit-site-page-header { + padding: 0 $grid-unit-40; + min-height: $header-height; + border-bottom: 1px solid $gray-100; + background: $white; + position: sticky; + top: 0; + z-index: z-index(".edit-site-page-header"); + .components-text { + color: $gray-800; + } + .components-heading { + color: $gray-900; + } + .edit-site-page-header__sub-title { + margin-top: $grid-unit-10; + color: $gray-700; + } +} + +.edit-site-page-content { + height: 100%; + display: flex; + overflow: auto; + flex-flow: column; + position: relative; + z-index: z-index(".edit-site-page-content"); +} diff --git a/packages/edit-site/src/components/preferences-modal/enable-feature.js b/packages/edit-site/src/components/preferences-modal/enable-feature.js index ca4a8a1bb1a327..9cd2105ba69fff 100644 --- a/packages/edit-site/src/components/preferences-modal/enable-feature.js +++ b/packages/edit-site/src/components/preferences-modal/enable-feature.js @@ -6,14 +6,17 @@ import { ___unstablePreferencesModalBaseOption as BaseOption } from '@wordpress/ import { store as preferencesStore } from '@wordpress/preferences'; export default function EnableFeature( props ) { - const { featureName, ...remainingProps } = props; + const { featureName, onToggle = () => {}, ...remainingProps } = props; const isChecked = useSelect( ( select ) => !! select( preferencesStore ).get( 'core/edit-site', featureName ), [ featureName ] ); const { toggle } = useDispatch( preferencesStore ); - const onChange = () => toggle( 'core/edit-site', featureName ); + const onChange = () => { + onToggle(); + toggle( 'core/edit-site', featureName ); + }; return ( <BaseOption onChange={ onChange } diff --git a/packages/edit-site/src/components/preferences-modal/index.js b/packages/edit-site/src/components/preferences-modal/index.js index 1f2db8d221a2df..279115b643614f 100644 --- a/packages/edit-site/src/components/preferences-modal/index.js +++ b/packages/edit-site/src/components/preferences-modal/index.js @@ -5,19 +5,42 @@ import { PreferencesModal, PreferencesModalTabs, PreferencesModalSection, + store as interfaceStore, } from '@wordpress/interface'; import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ import EnableFeature from './enable-feature'; +import { store as editSiteStore } from '../../store'; + +export const PREFERENCES_MODAL_NAME = 'edit-site/preferences'; + +export default function EditSitePreferencesModal() { + const isModalActive = useSelect( ( select ) => + select( interfaceStore ).isModalActive( PREFERENCES_MODAL_NAME ) + ); + const { closeModal, openModal } = useDispatch( interfaceStore ); + const toggleModal = () => + isModalActive ? closeModal() : openModal( PREFERENCES_MODAL_NAME ); + const registry = useRegistry(); + const { closeGeneralSidebar, setIsListViewOpened, setIsInserterOpened } = + useDispatch( editSiteStore ); + + const { set: setPreference } = useDispatch( preferencesStore ); + const toggleDistractionFree = () => { + registry.batch( () => { + setPreference( 'core/edit-site', 'fixedToolbar', false ); + setIsInserterOpened( false ); + setIsListViewOpened( false ); + closeGeneralSidebar(); + } ); + }; -export default function EditSitePreferencesModal( { - isModalActive, - toggleModal, -} ) { const sections = useMemo( () => [ { name: 'general', @@ -29,6 +52,14 @@ export default function EditSitePreferencesModal( { 'Customize options related to the block editor interface and editing flow.' ) } > + <EnableFeature + featureName="distractionFree" + onToggle={ toggleDistractionFree } + help={ __( + 'Reduce visual distractions by hiding the toolbar and other elements to focus on writing.' + ) } + label={ __( 'Distraction free' ) } + /> <EnableFeature featureName="focusMode" help={ __( @@ -55,6 +86,13 @@ export default function EditSitePreferencesModal( { ) } label={ __( 'Display block breadcrumbs' ) } /> + <EnableFeature + featureName="allowRightClickOverrides" + help={ __( + 'Allows contextual menus via right-click, overriding browser defaults.' + ) } + label={ __( 'Allow right-click contextual menus' ) } + /> </PreferencesModalSection> ), }, diff --git a/packages/edit-site/src/components/resizable-frame/index.js b/packages/edit-site/src/components/resizable-frame/index.js index f5dff65f3749b5..ee5c98da09033f 100644 --- a/packages/edit-site/src/components/resizable-frame/index.js +++ b/packages/edit-site/src/components/resizable-frame/index.js @@ -6,17 +6,20 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useState, useRef, useEffect } from '@wordpress/element'; +import { useState, useRef } from '@wordpress/element'; import { ResizableBox, + Tooltip, __unstableMotion as motion, } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; +import { useInstanceId } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; // Removes the inline styles in the drag handles. @@ -33,7 +36,7 @@ const HANDLE_STYLES_OVERRIDE = { }; // The minimum width of the frame (in px) while resizing. -const FRAME_MIN_WIDTH = 340; +const FRAME_MIN_WIDTH = 320; // The reference width of the frame (in px) used to calculate the aspect ratio. const FRAME_REFERENCE_WIDTH = 1300; // 9 : 19.5 is the target aspect ratio enforced (when possible) while resizing. @@ -42,6 +45,8 @@ const FRAME_TARGET_ASPECT_RATIO = 9 / 19.5; // viewport's edge. If the frame is resized to be closer to the viewport's edge // than this distance, then "canvas mode" will be enabled. const SNAP_TO_EDIT_CANVAS_MODE_THRESHOLD = 200; +// Default size for the `frameSize` state. +const INITIAL_FRAME_SIZE = { width: '100%', height: '100%' }; function calculateNewHeight( width, initialAspectRatio ) { const lerp = ( a, b, amount ) => { @@ -73,33 +78,32 @@ function calculateNewHeight( width, initialAspectRatio ) { function ResizableFrame( { isFullWidth, + isOversized, + setIsOversized, isReady, children, - oversizedClassName, + /** The default (unresized) width/height of the frame, based on the space availalbe in the viewport. */ + defaultSize, + innerContentStyle, } ) { - const [ frameSize, setFrameSize ] = useState( { - width: '100%', - height: '100%', - } ); + const [ frameSize, setFrameSize ] = useState( INITIAL_FRAME_SIZE ); // The width of the resizable frame when a new resize gesture starts. const [ startingWidth, setStartingWidth ] = useState(); const [ isResizing, setIsResizing ] = useState( false ); - const [ isHovering, setIsHovering ] = useState( false ); - const [ isOversized, setIsOversized ] = useState( false ); + const [ shouldShowHandle, setShouldShowHandle ] = useState( false ); const [ resizeRatio, setResizeRatio ] = useState( 1 ); + const canvasMode = useSelect( + ( select ) => unlock( select( editSiteStore ) ).getCanvasMode(), + [] + ); const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); - const initialAspectRatioRef = useRef( null ); - // The width of the resizable frame on initial render. - const initialComputedWidthRef = useRef( null ); const FRAME_TRANSITION = { type: 'tween', duration: isResizing ? 0 : 0.5 }; const frameRef = useRef( null ); - - // Remember frame dimensions on initial render. - useEffect( () => { - const { offsetWidth, offsetHeight } = frameRef.current.resizable; - initialComputedWidthRef.current = offsetWidth; - initialAspectRatioRef.current = offsetWidth / offsetHeight; - }, [] ); + const resizableHandleHelpId = useInstanceId( + ResizableFrame, + 'edit-site-resizable-frame-handle-help' + ); + const defaultAspectRatio = defaultSize.width / defaultSize.height; const handleResizeStart = ( _event, _direction, ref ) => { // Remember the starting width so we don't have to get `ref.offsetWidth` on @@ -115,7 +119,7 @@ function ResizableFrame( { const maxDoubledDelta = delta.width < 0 // is shrinking ? deltaAbs - : ( initialComputedWidthRef.current - startingWidth ) / 2; + : ( defaultSize.width - startingWidth ) / 2; const deltaToDouble = Math.min( deltaAbs, maxDoubledDelta ); const doubleSegment = deltaAbs === 0 ? 0 : deltaToDouble / deltaAbs; const singleSegment = 1 - doubleSegment; @@ -124,17 +128,14 @@ function ResizableFrame( { const updatedWidth = startingWidth + delta.width; - setIsOversized( updatedWidth > initialComputedWidthRef.current ); + setIsOversized( updatedWidth > defaultSize.width ); // Width will be controlled by the library (via `resizeRatio`), // so we only need to update the height. setFrameSize( { height: isOversized ? '100%' - : calculateNewHeight( - updatedWidth, - initialAspectRatioRef.current - ), + : calculateNewHeight( updatedWidth, defaultAspectRatio ), } ); }; @@ -153,13 +154,37 @@ function ResizableFrame( { if ( remainingWidth > SNAP_TO_EDIT_CANVAS_MODE_THRESHOLD ) { // Reset the initial aspect ratio if the frame is resized slightly // above the sidebar but not far enough to trigger full screen. - setFrameSize( { width: '100%', height: '100%' } ); + setFrameSize( INITIAL_FRAME_SIZE ); } else { // Trigger full screen if the frame is resized far enough to the left. setCanvasMode( 'edit' ); } }; + // Handle resize by arrow keys + const handleResizableHandleKeyDown = ( event ) => { + if ( ! [ 'ArrowLeft', 'ArrowRight' ].includes( event.key ) ) { + return; + } + + event.preventDefault(); + + const step = 20 * ( event.shiftKey ? 5 : 1 ); + const delta = step * ( event.key === 'ArrowLeft' ? 1 : -1 ); + const newWidth = Math.min( + Math.max( + FRAME_MIN_WIDTH, + frameRef.current.resizable.offsetWidth + delta + ), + defaultSize.width + ); + + setFrameSize( { + width: newWidth, + height: calculateNewHeight( newWidth, defaultAspectRatio ), + } ); + }; + const frameAnimationVariants = { default: { flexGrow: 0, @@ -171,6 +196,28 @@ function ResizableFrame( { }, }; + const resizeHandleVariants = { + hidden: { + opacity: 0, + left: 0, + }, + visible: { + opacity: 1, + left: -16, + }, + active: { + opacity: 1, + left: -16, + scaleY: 1.3, + }, + }; + const currentResizeHandleVariant = ( () => { + if ( isResizing ) { + return 'active'; + } + return shouldShowHandle ? 'visible' : 'hidden'; + } )(); + return ( <ResizableBox as={ motion.div } @@ -204,37 +251,54 @@ function ResizableFrame( { minWidth={ FRAME_MIN_WIDTH } maxWidth={ isFullWidth ? '100%' : '150%' } maxHeight={ '100%' } - onMouseOver={ () => setIsHovering( true ) } - onMouseOut={ () => setIsHovering( false ) } + onFocus={ () => setShouldShowHandle( true ) } + onBlur={ () => setShouldShowHandle( false ) } + onMouseOver={ () => setShouldShowHandle( true ) } + onMouseOut={ () => setShouldShowHandle( false ) } handleComponent={ { - left: - isHovering || isResizing ? ( - <motion.div - key="handle" - className="edit-site-resizable-frame__handle" - title="Drag to resize" - initial={ { - opacity: 0, - left: 0, - } } - animate={ { - opacity: 1, - left: -15, - } } - exit={ { - opacity: 0, - left: 0, - } } - whileHover={ { scale: 1.1 } } - /> - ) : null, + left: canvasMode === 'view' && ( + <> + <Tooltip text={ __( 'Drag to resize' ) }> + { /* Disable reason: role="separator" does in fact support aria-valuenow */ } + { /* eslint-disable-next-line jsx-a11y/role-supports-aria-props */ } + <motion.button + key="handle" + role="separator" + aria-orientation="vertical" + className={ classnames( + 'edit-site-resizable-frame__handle', + { 'is-resizing': isResizing } + ) } + variants={ resizeHandleVariants } + animate={ currentResizeHandleVariant } + aria-label={ __( 'Drag to resize' ) } + aria-describedby={ resizableHandleHelpId } + aria-valuenow={ + frameRef.current?.resizable?.offsetWidth || + undefined + } + aria-valuemin={ FRAME_MIN_WIDTH } + aria-valuemax={ defaultSize.width } + onKeyDown={ handleResizableHandleKeyDown } + initial="hidden" + exit="hidden" + whileFocus="active" + whileHover="active" + /> + </Tooltip> + <div hidden id={ resizableHandleHelpId }> + { __( + 'Use left and right arrow keys to resize the canvas. Hold shift to resize in larger increments.' + ) } + </div> + </> + ), } } onResizeStart={ handleResizeStart } onResize={ handleResize } onResizeStop={ handleResizeStop } className={ classnames( 'edit-site-resizable-frame__inner', { 'is-resizing': isResizing, - [ oversizedClassName ]: isOversized, } ) } > <motion.div @@ -243,6 +307,7 @@ function ResizableFrame( { borderRadius: isFullWidth ? 0 : 8, } } transition={ FRAME_TRANSITION } + style={ innerContentStyle } > { children } </motion.div> diff --git a/packages/edit-site/src/components/resizable-frame/style.scss b/packages/edit-site/src/components/resizable-frame/style.scss index 2bd478b9bf9916..3247525cc2e163 100644 --- a/packages/edit-site/src/components/resizable-frame/style.scss +++ b/packages/edit-site/src/components/resizable-frame/style.scss @@ -28,42 +28,44 @@ } .edit-site-resizable-frame__handle { - position: absolute; - width: 5px; - height: 50px; - background-color: rgba(255, 255, 255, 0.3); - z-index: 100; - border-radius: 5px; + align-items: center; + background-color: rgba($gray-700, 0.4); + border: 0; + border-radius: $grid-unit-05; cursor: col-resize; display: flex; - align-items: center; + height: $grid-unit-80; justify-content: flex-end; - top: 50%; + padding: 0; + position: absolute; + top: calc(50% - #{$grid-unit-40}); + width: $grid-unit-05; + z-index: 100; + &::before { - position: absolute; - left: 100%; - height: 100%; - width: $grid-unit-30; content: ""; + height: 100%; + left: 100%; + position: absolute; + width: $grid-unit-40; } &::after { + content: ""; + height: 100%; position: absolute; right: 100%; - height: 100%; - width: $grid-unit-30; - content: ""; + width: $grid-unit-40; } - &:hover { - background-color: var(--wp-admin-theme-color); + &:focus-visible { + // Works with Windows high contrast mode while also hiding weird outline in Safari. + outline: 2px solid transparent; } - .edit-site-resizable-frame__handle-label { - border-radius: 2px; - background: var(--wp-admin-theme-color); - padding: 4px 8px; - color: #fff; - margin-right: $grid-unit-10; + &:hover, + &:focus, + &.is-resizing { + background-color: var(--wp-admin-theme-color); } } diff --git a/packages/edit-site/src/components/revisions/index.js b/packages/edit-site/src/components/revisions/index.js index 3e06b6415cc4ea..4757c9b27213ea 100644 --- a/packages/edit-site/src/components/revisions/index.js +++ b/packages/edit-site/src/components/revisions/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { isEmpty } from 'lodash'; - /** * WordPress dependencies */ @@ -23,13 +18,17 @@ import { store as coreStore } from '@wordpress/core-data'; * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { mergeBaseAndUserConfigs } from '../global-styles/global-styles-provider'; import EditorCanvasContainer from '../editor-canvas-container'; const { ExperimentalBlockEditorProvider, useGlobalStylesOutputWithConfig } = unlock( blockEditorPrivateApis ); +function isObjectEmpty( object ) { + return ! object || Object.keys( object ).length === 0; +} + function Revisions( { onClose, userConfig, blocks } ) { const { baseConfig } = useSelect( ( select ) => ( { @@ -42,7 +41,7 @@ function Revisions( { onClose, userConfig, blocks } ) { ); const mergedConfig = useMemo( () => { - if ( ! isEmpty( userConfig ) && ! isEmpty( baseConfig ) ) { + if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) { return mergeBaseAndUserConfigs( baseConfig, userConfig ); } return {}; @@ -65,7 +64,7 @@ function Revisions( { onClose, userConfig, blocks } ) { const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig ); const editorStyles = - ! isEmpty( globalStyles ) && ! isEmpty( userConfig ) + ! isObjectEmpty( globalStyles ) && ! isObjectEmpty( userConfig ) ? globalStyles : settings.styles; diff --git a/packages/edit-site/src/components/routes/link.js b/packages/edit-site/src/components/routes/link.js index 71313176a1c60b..3191e6b9c6f3ac 100644 --- a/packages/edit-site/src/components/routes/link.js +++ b/packages/edit-site/src/components/routes/link.js @@ -7,7 +7,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { isPreviewingTheme, currentlyPreviewingTheme, @@ -37,7 +37,7 @@ export function useLink( params = {}, state, shouldReplace = false ) { if ( isPreviewingTheme() ) { params = { ...params, - theme_preview: currentlyPreviewingTheme(), + wp_theme_preview: currentlyPreviewingTheme(), }; } diff --git a/packages/edit-site/src/components/routes/use-title.js b/packages/edit-site/src/components/routes/use-title.js index 26321721a5c452..6d06c593dd253a 100644 --- a/packages/edit-site/src/components/routes/use-title.js +++ b/packages/edit-site/src/components/routes/use-title.js @@ -12,7 +12,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); diff --git a/packages/edit-site/src/components/save-button/index.js b/packages/edit-site/src/components/save-button/index.js index 0d78e32307797a..37d99a1ae13941 100644 --- a/packages/edit-site/src/components/save-button/index.js +++ b/packages/edit-site/src/components/save-button/index.js @@ -17,7 +17,9 @@ export default function SaveButton( { className = 'edit-site-save-button__button', variant = 'primary', showTooltip = true, + defaultLabel, icon, + __next40pxDefaultSize = false, } ) { const { isDirty, isSaving, isSaveViewOpen } = useSelect( ( select ) => { const { __experimentalGetDirtyEntityRecords, isSavingEntityRecord } = @@ -38,16 +40,24 @@ export default function SaveButton( { const disabled = isSaving || ! activateSaveEnabled; const getLabel = () => { - if ( disabled ) { - return __( 'Saved' ); - } - - if ( isPreviewingTheme() && isDirty ) { - return __( 'Activate & Save' ); - } else if ( isPreviewingTheme() ) { + if ( isPreviewingTheme() ) { + if ( isSaving ) { + return __( 'Activating' ); + } else if ( disabled ) { + return __( 'Saved' ); + } else if ( isDirty ) { + return __( 'Activate & Save' ); + } return __( 'Activate' ); } + if ( isSaving ) { + return __( 'Saving' ); + } else if ( disabled ) { + return __( 'Saved' ); + } else if ( defaultLabel ) { + return defaultLabel; + } return __( 'Save' ); }; const label = getLabel(); @@ -74,6 +84,7 @@ export default function SaveButton( { */ showTooltip={ showTooltip } icon={ icon } + __next40pxDefaultSize={ __next40pxDefaultSize } > { label } </Button> diff --git a/packages/edit-site/src/components/save-hub/index.js b/packages/edit-site/src/components/save-hub/index.js index 0c99cfd9d4d44c..9154429f770b0b 100644 --- a/packages/edit-site/src/components/save-hub/index.js +++ b/packages/edit-site/src/components/save-hub/index.js @@ -1,60 +1,170 @@ /** * WordPress dependencies */ -import { useSelect } from '@wordpress/data'; -import { __experimentalHStack as HStack } from '@wordpress/components'; -import { sprintf, _n } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { Button, __experimentalHStack as HStack } from '@wordpress/components'; +import { __, sprintf, _n } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; import { check } from '@wordpress/icons'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ import SaveButton from '../save-button'; import { isPreviewingTheme } from '../../utils/is-previewing-theme'; +import { unlock } from '../../lock-unlock'; + +const { useLocation } = unlock( routerPrivateApis ); + +const PUBLISH_ON_SAVE_ENTITIES = [ + { + kind: 'postType', + name: 'wp_navigation', + }, +]; export default function SaveHub() { - const { countUnsavedChanges, isDirty, isSaving } = useSelect( - ( select ) => { - const { - __experimentalGetDirtyEntityRecords, - isSavingEntityRecord, - } = select( coreStore ); - const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); - return { - isDirty: dirtyEntityRecords.length > 0, - isSaving: dirtyEntityRecords.some( ( record ) => - isSavingEntityRecord( record.kind, record.name, record.key ) - ), - countUnsavedChanges: dirtyEntityRecords.length, - }; - }, - [] - ); + const saveNoticeId = 'site-edit-save-notice'; + const { params } = useLocation(); + + const { __unstableMarkLastChangeAsPersistent } = + useDispatch( blockEditorStore ); + + const { createSuccessNotice, createErrorNotice, removeNotice } = + useDispatch( noticesStore ); + + const { dirtyCurrentEntity, countUnsavedChanges, isDirty, isSaving } = + useSelect( + ( select ) => { + const { + __experimentalGetDirtyEntityRecords, + isSavingEntityRecord, + } = select( coreStore ); + const dirtyEntityRecords = + __experimentalGetDirtyEntityRecords(); + let calcDirtyCurrentEntity = null; + + if ( dirtyEntityRecords.length === 1 ) { + // if we are on global styles + if ( params.path?.includes( 'wp_global_styles' ) ) { + calcDirtyCurrentEntity = dirtyEntityRecords.find( + ( record ) => record.name === 'globalStyles' + ); + } + // if we are on pages + else if ( params.postId ) { + calcDirtyCurrentEntity = dirtyEntityRecords.find( + ( record ) => + record.name === params.postType && + String( record.key ) === params.postId + ); + } + } + + return { + dirtyCurrentEntity: calcDirtyCurrentEntity, + isDirty: dirtyEntityRecords.length > 0, + isSaving: dirtyEntityRecords.some( ( record ) => + isSavingEntityRecord( + record.kind, + record.name, + record.key + ) + ), + countUnsavedChanges: dirtyEntityRecords.length, + }; + }, + [ params.path, params.postType, params.postId ] + ); + + const { + editEntityRecord, + saveEditedEntityRecord, + __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits, + } = useDispatch( coreStore ); const disabled = isSaving || ( ! isDirty && ! isPreviewingTheme() ); + // if we have only one unsaved change and it matches current context, we can show a more specific label + let label = dirtyCurrentEntity + ? __( 'Save' ) + : sprintf( + // translators: %d: number of unsaved changes (number). + _n( + 'Review %d change…', + 'Review %d changes…', + countUnsavedChanges + ), + countUnsavedChanges + ); + + if ( isSaving ) { + label = __( 'Saving' ); + } + + const saveCurrentEntity = async () => { + if ( ! dirtyCurrentEntity ) return; + + removeNotice( saveNoticeId ); + const { kind, name, key, property } = dirtyCurrentEntity; + + try { + if ( 'root' === dirtyCurrentEntity.kind && 'site' === name ) { + await saveSpecifiedEntityEdits( 'root', 'site', undefined, [ + property, + ] ); + } else { + if ( + PUBLISH_ON_SAVE_ENTITIES.some( + ( typeToPublish ) => + typeToPublish.kind === kind && + typeToPublish.name === name + ) + ) { + editEntityRecord( kind, name, key, { status: 'publish' } ); + } + + await saveEditedEntityRecord( kind, name, key ); + } + + __unstableMarkLastChangeAsPersistent(); + + createSuccessNotice( __( 'Site updated.' ), { + type: 'snackbar', + id: saveNoticeId, + } ); + } catch ( error ) { + createErrorNotice( `${ __( 'Saving failed.' ) } ${ error }` ); + } + }; + return ( <HStack className="edit-site-save-hub" alignment="right" spacing={ 4 }> - { isDirty && ( - <span> - { sprintf( - // translators: %d: number of unsaved changes (number). - _n( - '%d unsaved change', - '%d unsaved changes', - countUnsavedChanges - ), - countUnsavedChanges - ) } - </span> + { dirtyCurrentEntity ? ( + <Button + variant="primary" + onClick={ saveCurrentEntity } + isBusy={ isSaving } + disabled={ isSaving } + aria-disabled={ isSaving } + className="edit-site-save-hub__button" + __next40pxDefaultSize + > + { label } + </Button> + ) : ( + <SaveButton + className="edit-site-save-hub__button" + variant={ disabled ? null : 'primary' } + showTooltip={ false } + icon={ disabled && ! isSaving ? check : null } + defaultLabel={ label } + __next40pxDefaultSize + /> ) } - <SaveButton - className="edit-site-save-hub__button" - variant={ disabled ? null : 'primary' } - showTooltip={ false } - icon={ disabled ? check : null } - /> </HStack> ); } diff --git a/packages/edit-site/src/components/save-hub/style.scss b/packages/edit-site/src/components/save-hub/style.scss index 0b8fb9c510f7a9..e864444b2077b4 100644 --- a/packages/edit-site/src/components/save-hub/style.scss +++ b/packages/edit-site/src/components/save-hub/style.scss @@ -1,9 +1,15 @@ .edit-site-save-hub { color: $gray-600; + border-top: 1px solid $gray-800; + flex-shrink: 0; + margin: 0; + padding: $grid-unit-20 + $grid-unit-05 $canvas-padding; } .edit-site-save-hub__button { color: inherit; + width: 100%; + justify-content: center; &[aria-disabled="true"] { opacity: 1; diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js index fa9516f3599d9b..5c1bcc1df281e0 100644 --- a/packages/edit-site/src/components/save-panel/index.js +++ b/packages/edit-site/src/components/save-panel/index.js @@ -7,17 +7,81 @@ import classnames from 'classnames'; * WordPress dependencies */ import { Button, Modal } from '@wordpress/components'; -import { EntitiesSavedStates } from '@wordpress/editor'; +import { + EntitiesSavedStates, + useEntitiesSavedStatesIsDirty, + privateApis, +} from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { NavigableRegion } from '@wordpress/interface'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { useActivateTheme } from '../../utils/use-activate-theme'; +import { + currentlyPreviewingTheme, + isPreviewingTheme, +} from '../../utils/is-previewing-theme'; + +const { EntitiesSavedStatesExtensible } = unlock( privateApis ); + +const EntitiesSavedStatesForPreview = ( { onClose } ) => { + const isDirtyProps = useEntitiesSavedStatesIsDirty(); + let activateSaveLabel; + if ( isDirtyProps.isDirty ) { + activateSaveLabel = __( 'Activate & Save' ); + } else { + activateSaveLabel = __( 'Activate' ); + } + + const themeName = useSelect( ( select ) => { + const theme = select( coreStore ).getTheme( + currentlyPreviewingTheme() + ); + + return theme?.name?.rendered; + }, [] ); + + const additionalPrompt = ( + <p> + { sprintf( + 'Saving your changes will change your active theme to %s.', + themeName + ) } + </p> + ); + + const activateTheme = useActivateTheme(); + const onSave = async ( values ) => { + await activateTheme(); + return values; + }; + + return ( + <EntitiesSavedStatesExtensible + { ...{ + ...isDirtyProps, + additionalPrompt, + close: onClose, + onSave, + saveEnabled: true, + saveLabel: activateSaveLabel, + } } + /> + ); +}; + +const _EntitiesSavedStates = ( { onClose } ) => { + if ( isPreviewingTheme() ) { + return <EntitiesSavedStatesForPreview onClose={ onClose } />; + } + return <EntitiesSavedStates close={ onClose } />; +}; export default function SavePanel() { const { isSaveViewOpen, canvasMode } = useSelect( ( select ) => { @@ -33,18 +97,7 @@ export default function SavePanel() { }; }, [] ); const { setIsSaveViewOpened } = useDispatch( editSiteStore ); - const activateTheme = useActivateTheme(); const onClose = () => setIsSaveViewOpened( false ); - const onSave = async ( values ) => { - await activateTheme(); - return values; - }; - - const entitySavedStates = window?.__experimentalEnableThemePreviews ? ( - <EntitiesSavedStates close={ onClose } onSave={ onSave } /> - ) : ( - <EntitiesSavedStates close={ onClose } /> - ); if ( canvasMode === 'view' ) { return isSaveViewOpen ? ( @@ -56,7 +109,7 @@ export default function SavePanel() { 'Save site, content, and template changes' ) } > - { entitySavedStates } + <_EntitiesSavedStates onClose={ onClose } /> </Modal> ) : null; } @@ -69,7 +122,7 @@ export default function SavePanel() { ariaLabel={ __( 'Save panel' ) } > { isSaveViewOpen ? ( - entitySavedStates + <_EntitiesSavedStates onClose={ onClose } /> ) : ( <div className="edit-site-editor__toggle-save-panel"> <Button diff --git a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js index e4eff5ad037ddc..0e46e99decd2f5 100644 --- a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js @@ -9,27 +9,27 @@ import { useMergeRefs, } from '@wordpress/compose'; import { useDispatch } from '@wordpress/data'; -import { useState } from '@wordpress/element'; +import { useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { ESCAPE } from '@wordpress/keycodes'; +import { focus } from '@wordpress/dom'; +import { useShortcut } from '@wordpress/keyboard-shortcuts'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { PrivateListView } = unlock( blockEditorPrivateApis ); export default function ListViewSidebar() { const { setIsListViewOpened } = useDispatch( editSiteStore ); - // Use internal state instead of a ref to make sure that the component - // re-renders when the dropZoneElement updates. - const [ dropZoneElement, setDropZoneElement ] = useState( null ); - + // This hook handles focus when the sidebar first renders. const focusOnMountRef = useFocusOnMount( 'firstElement' ); + // The next 2 hooks handle focus for when the sidebar closes and returning focus to the element that had focus before sidebar opened. const headerFocusReturnRef = useFocusReturn(); const contentFocusReturnRef = useFocusReturn(); @@ -39,11 +39,56 @@ export default function ListViewSidebar() { } } + // Use internal state instead of a ref to make sure that the component + // re-renders when the dropZoneElement updates. + const [ dropZoneElement, setDropZoneElement ] = useState( null ); + + // This ref refers to the sidebar as a whole. + const sidebarRef = useRef(); + // This ref refers to the close button. + const sidebarCloseButtonRef = useRef(); + // This ref refers to the list view application area. + const listViewRef = useRef(); + + /* + * Callback function to handle list view or close button focus. + * + * @return void + */ + function handleSidebarFocus() { + // Either focus the list view or the sidebar close button. Must have a fallback because the list view does not render when there are no blocks. + const listViewApplicationFocus = focus.tabbable.find( + listViewRef.current + )[ 0 ]; + const listViewFocusArea = sidebarRef.current.contains( + listViewApplicationFocus + ) + ? listViewApplicationFocus + : sidebarCloseButtonRef.current; + listViewFocusArea.focus(); + } + + // This only fires when the sidebar is open because of the conditional rendering. It is the same shortcut to open but that is defined as a global shortcut and only fires when the sidebar is closed. + useShortcut( 'core/edit-site/toggle-list-view', () => { + // If the sidebar has focus, it is safe to close. + if ( + sidebarRef.current.contains( + sidebarRef.current.ownerDocument.activeElement + ) + ) { + setIsListViewOpened( false ); + // If the list view or close button does not have focus, focus should be moved to it. + } else { + handleSidebarFocus(); + } + } ); + return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions <div className="edit-site-editor__list-view-panel" onKeyDown={ closeOnEscape } + ref={ sidebarRef } > <div className="edit-site-editor__list-view-panel-header" @@ -54,6 +99,7 @@ export default function ListViewSidebar() { icon={ closeSmall } label={ __( 'Close' ) } onClick={ () => setIsListViewOpened( false ) } + ref={ sidebarCloseButtonRef } /> </div> <div @@ -62,6 +108,7 @@ export default function ListViewSidebar() { contentFocusReturnRef, focusOnMountRef, setDropZoneElement, + listViewRef, ] ) } > <PrivateListView dropZoneElement={ dropZoneElement } /> diff --git a/packages/edit-site/src/components/sidebar-button/style.scss b/packages/edit-site/src/components/sidebar-button/style.scss index 8388aae266e3ed..5135f97869bb8b 100644 --- a/packages/edit-site/src/components/sidebar-button/style.scss +++ b/packages/edit-site/src/components/sidebar-button/style.scss @@ -19,6 +19,6 @@ &:focus, &:not([aria-disabled="true"]):active, &[aria-expanded="true"] { - color: $white; + color: $gray-100; } } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/default-sidebar.js b/packages/edit-site/src/components/sidebar-edit-mode/default-sidebar.js index ed3e7bef6575b5..a06077e30b1761 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/default-sidebar.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/default-sidebar.js @@ -35,6 +35,7 @@ export default function DefaultSidebar( { scope="core/edit-site" identifier={ identifier } title={ title } + smallScreenTitle={ title } icon={ icon } closeLabel={ closeLabel } header={ header } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js b/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js index ae124422f66ce1..a3be71723e8730 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js @@ -7,6 +7,7 @@ import { styles, seen } from '@wordpress/icons'; import { useSelect, useDispatch } from '@wordpress/data'; import { useEffect } from '@wordpress/element'; import { store as interfaceStore } from '@wordpress/interface'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies @@ -15,31 +16,36 @@ import DefaultSidebar from './default-sidebar'; import { GlobalStylesUI } from '../global-styles'; import { store as editSiteStore } from '../../store'; import { GlobalStylesMenuSlot } from '../global-styles/ui'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; export default function GlobalStylesSidebar() { - const { shouldClearCanvasContainerView, isStyleBookOpened } = useSelect( - ( select ) => { - const { getActiveComplementaryArea } = select( interfaceStore ); - const { getEditorCanvasContainerView, getCanvasMode } = unlock( - select( editSiteStore ) - ); - const _isVisualEditorMode = - 'visual' === select( editSiteStore ).getEditorMode(); - const _isEditCanvasMode = 'edit' === getCanvasMode(); + const { + shouldClearCanvasContainerView, + isStyleBookOpened, + showListViewByDefault, + } = useSelect( ( select ) => { + const { getActiveComplementaryArea } = select( interfaceStore ); + const { getEditorCanvasContainerView, getCanvasMode } = unlock( + select( editSiteStore ) + ); + const _isVisualEditorMode = + 'visual' === select( editSiteStore ).getEditorMode(); + const _isEditCanvasMode = 'edit' === getCanvasMode(); + const _showListViewByDefault = select( preferencesStore ).get( + 'core/edit-site', + 'showListViewByDefault' + ); - return { - isStyleBookOpened: - 'style-book' === getEditorCanvasContainerView(), - shouldClearCanvasContainerView: - 'edit-site/global-styles' !== - getActiveComplementaryArea( 'core/edit-site' ) || - ! _isVisualEditorMode || - ! _isEditCanvasMode, - }; - }, - [] - ); + return { + isStyleBookOpened: 'style-book' === getEditorCanvasContainerView(), + shouldClearCanvasContainerView: + 'edit-site/global-styles' !== + getActiveComplementaryArea( 'core/edit-site' ) || + ! _isVisualEditorMode || + ! _isEditCanvasMode, + showListViewByDefault: _showListViewByDefault, + }; + }, [] ); const { setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) ); @@ -50,6 +56,8 @@ export default function GlobalStylesSidebar() { } }, [ shouldClearCanvasContainerView ] ); + const { setIsListViewOpened } = useDispatch( editSiteStore ); + return ( <DefaultSidebar className="edit-site-global-styles-sidebar" @@ -59,7 +67,11 @@ export default function GlobalStylesSidebar() { closeLabel={ __( 'Close Styles' ) } panelClassName="edit-site-global-styles-sidebar__panel" header={ - <Flex className="edit-site-global-styles-sidebar__header"> + <Flex + className="edit-site-global-styles-sidebar__header" + role="menubar" + aria-label={ __( 'Styles actions' ) } + > <FlexBlock style={ { minWidth: 'min-content' } }> <strong>{ __( 'Styles' ) }</strong> </FlexBlock> @@ -69,16 +81,17 @@ export default function GlobalStylesSidebar() { label={ __( 'Style Book' ) } isPressed={ isStyleBookOpened } disabled={ shouldClearCanvasContainerView } - onClick={ () => + onClick={ () => { + setIsListViewOpened( + isStyleBookOpened && showListViewByDefault + ); setEditorCanvasContainerView( isStyleBookOpened ? undefined : 'style-book' - ) - } + ); + } } /> </FlexItem> - <FlexItem> - <GlobalStylesMenuSlot /> - </FlexItem> + <GlobalStylesMenuSlot /> </Flex> } > diff --git a/packages/edit-site/src/components/sidebar-edit-mode/index.js b/packages/edit-site/src/components/sidebar-edit-mode/index.js index 5086981f871447..8a36a0b5395610 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/index.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { createSlotFill, PanelBody, PanelRow } from '@wordpress/components'; +import { createSlotFill } from '@wordpress/components'; import { isRTL, __ } from '@wordpress/i18n'; import { drawerLeft, drawerRight } from '@wordpress/icons'; import { useEffect } from '@wordpress/element'; @@ -16,8 +16,8 @@ import DefaultSidebar from './default-sidebar'; import GlobalStylesSidebar from './global-styles-sidebar'; import { STORE_NAME } from '../../store/constants'; import SettingsHeader from './settings-header'; -import LastRevision from './template-revisions'; -import TemplateCard from './template-card'; +import PagePanels from './page-panels'; +import TemplatePanel from './template-panel'; import PluginTemplateSettingPanel from '../plugin-template-setting-panel'; import { SIDEBAR_BLOCK, SIDEBAR_TEMPLATE } from './constants'; import { store as editSiteStore } from '../../store'; @@ -33,6 +33,7 @@ export function SidebarComplementaryAreaFills() { isEditorSidebarOpened, hasBlockSelection, supportsGlobalStyles, + hasPageContentFocus, } = useSelect( ( select ) => { const _sidebar = select( interfaceStore ).getActiveComplementaryArea( STORE_NAME ); @@ -47,18 +48,25 @@ export function SidebarComplementaryAreaFills() { hasBlockSelection: !! select( blockEditorStore ).getBlockSelectionStart(), supportsGlobalStyles: ! settings?.supportsTemplatePartsMode, + hasPageContentFocus: select( editSiteStore ).hasPageContentFocus(), }; }, [] ); const { enableComplementaryArea } = useDispatch( interfaceStore ); useEffect( () => { - if ( ! isEditorSidebarOpened ) return; + // Don't automatically switch tab when the sidebar is closed or when we + // are focused on page content. + if ( ! isEditorSidebarOpened ) { + return; + } if ( hasBlockSelection ) { - enableComplementaryArea( STORE_NAME, SIDEBAR_BLOCK ); + if ( ! hasPageContentFocus ) { + enableComplementaryArea( STORE_NAME, SIDEBAR_BLOCK ); + } } else { enableComplementaryArea( STORE_NAME, SIDEBAR_TEMPLATE ); } - }, [ hasBlockSelection, isEditorSidebarOpened ] ); + }, [ hasBlockSelection, isEditorSidebarOpened, hasPageContentFocus ] ); let sidebarName = sidebar; if ( ! isEditorSidebarOpened ) { @@ -77,15 +85,11 @@ export function SidebarComplementaryAreaFills() { > { sidebarName === SIDEBAR_TEMPLATE && ( <> - <PanelBody> - <TemplateCard /> - <PanelRow - header={ __( 'Editing history' ) } - className="edit-site-template-revisions" - > - <LastRevision /> - </PanelRow> - </PanelBody> + { hasPageContentFocus ? ( + <PagePanels /> + ) : ( + <TemplatePanel /> + ) } <PluginTemplateSettingPanel.Slot /> </> ) } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js new file mode 100644 index 00000000000000..b49e8ac459e3fe --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { BlockContextProvider, BlockPreview } from '@wordpress/block-editor'; +import { Button, __experimentalVStack as VStack } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { store as coreStore } from '@wordpress/core-data'; +import { parse } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../../store'; + +export default function EditTemplate() { + const { context, hasResolved, template } = useSelect( ( select ) => { + const { getEditedPostContext, getEditedPostType, getEditedPostId } = + select( editSiteStore ); + const { getEditedEntityRecord, hasFinishedResolution } = + select( coreStore ); + const _context = getEditedPostContext(); + const queryArgs = [ + 'postType', + getEditedPostType(), + getEditedPostId(), + ]; + return { + context: _context, + hasResolved: hasFinishedResolution( + 'getEditedEntityRecord', + queryArgs + ), + template: getEditedEntityRecord( ...queryArgs ), + }; + }, [] ); + + const { setHasPageContentFocus } = useDispatch( editSiteStore ); + + const blockContext = useMemo( + () => ( { ...context, postType: null, postId: null } ), + [ context ] + ); + + const blocks = useMemo( + () => + template.blocks ?? + ( template.content && typeof template.content !== 'function' + ? parse( template.content ) + : [] ), + [ template.blocks, template.content ] + ); + + if ( ! hasResolved ) { + return null; + } + + return ( + <VStack> + <div>{ decodeEntities( template.title ) }</div> + <div className="edit-site-page-panels__edit-template-preview"> + <BlockContextProvider value={ blockContext }> + <BlockPreview viewportWidth={ 1024 } blocks={ blocks } /> + </BlockContextProvider> + </div> + <Button + className="edit-site-page-panels__edit-template-button" + variant="secondary" + onClick={ () => setHasPageContentFocus( false ) } + > + { __( 'Edit template' ) } + </Button> + </VStack> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js new file mode 100644 index 00000000000000..69971d1ad413ae --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js @@ -0,0 +1,89 @@ +/** + * WordPress dependencies + */ +import { + PanelBody, + __experimentalText as Text, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { page as pageIcon } from '@wordpress/icons'; +import { __, sprintf } from '@wordpress/i18n'; +import { humanTimeDiff } from '@wordpress/date'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../../store'; +import SidebarCard from '../sidebar-card'; +import PageContent from './page-content'; +import PageSummary from './page-summary'; +import EditTemplate from './edit-template'; + +export default function PagePanels() { + const { id, type, hasResolved, status, date, password, title, modified } = + useSelect( ( select ) => { + const { getEditedPostContext } = select( editSiteStore ); + const { getEditedEntityRecord, hasFinishedResolution } = + select( coreStore ); + const context = getEditedPostContext(); + const queryArgs = [ 'postType', context.postType, context.postId ]; + const page = getEditedEntityRecord( ...queryArgs ); + return { + hasResolved: hasFinishedResolution( + 'getEditedEntityRecord', + queryArgs + ), + title: page?.title, + id: page?.id, + type: page?.type, + status: page?.status, + date: page?.date, + password: page?.password, + modified: page?.modified, + }; + }, [] ); + + if ( ! hasResolved ) { + return null; + } + + return ( + <> + <PanelBody> + <SidebarCard + title={ decodeEntities( title ) } + icon={ pageIcon } + description={ + <VStack> + <Text> + { sprintf( + // translators: %s: Human-readable time difference, e.g. "2 days ago". + __( 'Last edited %s' ), + humanTimeDiff( modified ) + ) } + </Text> + </VStack> + } + /> + </PanelBody> + <PanelBody title={ __( 'Summary' ) }> + <PageSummary + status={ status } + date={ date } + password={ password } + postId={ id } + postType={ type } + /> + </PanelBody> + <PanelBody title={ __( 'Content' ) }> + <PageContent /> + </PanelBody> + <PanelBody title={ __( 'Template' ) }> + <EditTemplate /> + </PanelBody> + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js new file mode 100644 index 00000000000000..dd40bcaef9f707 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { + store as blockEditorStore, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../../lock-unlock'; + +const { BlockQuickNavigation } = unlock( blockEditorPrivateApis ); + +export default function PageContent() { + const clientIdsTree = useSelect( + ( select ) => + unlock( select( blockEditorStore ) ).getEnabledClientIdsTree(), + [] + ); + const clientIds = useMemo( + () => clientIdsTree.map( ( { clientId } ) => clientId ), + [ clientIdsTree ] + ); + return <BlockQuickNavigation clientIds={ clientIds } />; +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-status.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-status.js new file mode 100644 index 00000000000000..f80ed4d3fe8204 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-status.js @@ -0,0 +1,249 @@ +/** + * WordPress dependencies + */ +import { + Button, + ToggleControl, + Dropdown, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + TextControl, + RadioControl, + VisuallyHidden, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { useState, useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import { __experimentalInspectorPopoverHeader as InspectorPopoverHeader } from '@wordpress/block-editor'; +import { useInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import StatusLabel from '../../sidebar-navigation-screen-page/status-label'; + +const STATUS_OPTIONS = [ + { + label: ( + <> + { __( 'Draft' ) } + <Text variant="muted">{ __( 'Not ready to publish.' ) }</Text> + </> + ), + value: 'draft', + }, + { + label: ( + <> + { __( 'Pending' ) } + <Text variant="muted"> + { __( 'Waiting for review before publishing.' ) } + </Text> + </> + ), + value: 'pending', + }, + { + label: ( + <> + { __( 'Private' ) } + <Text variant="muted"> + { __( 'Only visible to site admins and editors.' ) } + </Text> + </> + ), + value: 'private', + }, + { + label: ( + <> + { __( 'Scheduled' ) } + <Text variant="muted"> + { __( 'Publish automatically on a chosen date.' ) } + </Text> + </> + ), + value: 'future', + }, + { + label: ( + <> + { __( 'Published' ) } + <Text variant="muted">{ __( 'Visible to everyone.' ) }</Text> + </> + ), + value: 'publish', + }, +]; + +export default function PageStatus( { + postType, + postId, + status, + password, + date, +} ) { + const [ showPassword, setShowPassword ] = useState( !! password ); + const instanceId = useInstanceId( PageStatus ); + + const { editEntityRecord } = useDispatch( coreStore ); + const { createErrorNotice } = useDispatch( noticesStore ); + + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + // Memoize popoverProps to avoid returning a new object every time. + const popoverProps = useMemo( + () => ( { + // Anchor the popover to the middle of the entire row so that it doesn't + // move around when the label changes. + anchor: popoverAnchor, + 'aria-label': __( 'Change status' ), + placement: 'bottom-end', + } ), + [ popoverAnchor ] + ); + + const saveStatus = async ( { + status: newStatus = status, + password: newPassword = password, + date: newDate = date, + } ) => { + try { + await editEntityRecord( 'postType', postType, postId, { + status: newStatus, + date: newDate, + password: newPassword, + } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while updating the status' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } + }; + + const handleTogglePassword = ( value ) => { + setShowPassword( value ); + if ( ! value ) { + saveStatus( { password: '' } ); + } + }; + + const handleStatus = ( value ) => { + let newDate = date; + let newPassword = password; + if ( value === 'publish' ) { + if ( new Date( date ) > new Date() ) { + newDate = null; + } + } else if ( value === 'future' ) { + if ( ! date || new Date( date ) < new Date() ) { + newDate = new Date(); + newDate.setDate( newDate.getDate() + 7 ); + } + } else if ( value === 'private' && password ) { + setShowPassword( false ); + newPassword = ''; + } + saveStatus( { + status: value, + date: newDate, + password: newPassword, + } ); + }; + + return ( + <HStack className="edit-site-summary-field"> + <Text className="edit-site-summary-field__label"> + { __( 'Status' ) } + </Text> + <Dropdown + contentClassName="edit-site-change-status__content" + popoverProps={ popoverProps } + focusOnMount + ref={ setPopoverAnchor } + renderToggle={ ( { onToggle } ) => ( + <Button + className="edit-site-summary-field__trigger" + variant="tertiary" + onClick={ onToggle } + > + <StatusLabel + status={ password ? 'protected' : status } + /> + </Button> + ) } + renderContent={ ( { onClose } ) => ( + <> + <InspectorPopoverHeader + title={ __( 'Status' ) } + onClose={ onClose } + /> + <form> + <VStack spacing={ 5 }> + <RadioControl + className="edit-site-change-status__options" + hideLabelFromVision + label={ __( 'Status' ) } + options={ STATUS_OPTIONS } + onChange={ handleStatus } + selected={ status } + /> + { status !== 'private' && ( + <fieldset className="edit-site-change-status__password-fieldset"> + <Text + as="legend" + className="edit-site-change-status__password-legend" + size="11" + lineHeight={ 1.4 } + weight={ 500 } + upperCase={ true } + > + { __( 'Password' ) } + </Text> + <ToggleControl + label={ __( + 'Hide this page behind a password' + ) } + checked={ showPassword } + onChange={ handleTogglePassword } + /> + { showPassword && ( + <div className="edit-site-change-status__password-input"> + <VisuallyHidden + as="label" + htmlFor={ `edit-site-change-status__password-input-${ instanceId }` } + > + { __( 'Create password' ) } + </VisuallyHidden> + <TextControl + onChange={ ( value ) => + saveStatus( { + password: value, + } ) + } + value={ password } + placeholder={ __( + 'Use a secure password' + ) } + type="text" + id={ `edit-site-change-status__password-input-${ instanceId }` } + /> + </div> + ) } + </fieldset> + ) } + </VStack> + </form> + </> + ) } + /> + </HStack> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js new file mode 100644 index 00000000000000..3dce743b298d45 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { __experimentalVStack as VStack } from '@wordpress/components'; +/** + * Internal dependencies + */ +import PageStatus from './page-status'; +import PublishDate from './publish-date'; + +export default function PageSummary( { + status, + date, + password, + postId, + postType, +} ) { + return ( + <VStack> + <PageStatus + status={ status } + date={ date } + password={ password } + postId={ postId } + postType={ postType } + /> + <PublishDate + status={ status } + date={ date } + postId={ postId } + postType={ postType } + /> + </VStack> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/publish-date.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/publish-date.js new file mode 100644 index 00000000000000..d000394f6816ba --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/publish-date.js @@ -0,0 +1,94 @@ +/** + * WordPress dependencies + */ +import { + Button, + Dropdown, + __experimentalText as Text, + __experimentalHStack as HStack, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { useState, useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import { __experimentalPublishDateTimePicker as PublishDateTimePicker } from '@wordpress/block-editor'; +import { humanTimeDiff } from '@wordpress/date'; + +export default function ChangeStatus( { postType, postId, status, date } ) { + const { editEntityRecord } = useDispatch( coreStore ); + const { createErrorNotice } = useDispatch( noticesStore ); + + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + // Memoize popoverProps to avoid returning a new object every time. + const popoverProps = useMemo( + () => ( { + // Anchor the popover to the middle of the entire row so that it doesn't + // move around when the label changes. + anchor: popoverAnchor, + 'aria-label': __( 'Change publish date' ), + placement: 'bottom-end', + } ), + [ popoverAnchor ] + ); + + const saveDate = async ( newDate ) => { + try { + let newStatus = status; + if ( status === 'future' && new Date( newDate ) < new Date() ) { + newStatus = 'publish'; + } else if ( + status === 'publish' && + new Date( newDate ) > new Date() + ) { + newStatus = 'future'; + } + await editEntityRecord( 'postType', postType, postId, { + status: newStatus, + date: newDate, + } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while updating the status' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } + }; + + const relateToNow = date ? humanTimeDiff( date ) : __( 'Immediately' ); + + return ( + <HStack className="edit-site-summary-field"> + <Text className="edit-site-summary-field__label"> + { __( 'Publish' ) } + </Text> + <Dropdown + contentClassName="edit-site-change-status__content" + popoverProps={ popoverProps } + focusOnMount + ref={ setPopoverAnchor } + renderToggle={ ( { onToggle } ) => ( + <Button + className="edit-site-summary-field__trigger" + variant="tertiary" + onClick={ onToggle } + > + { relateToNow } + </Button> + ) } + renderContent={ ( { onClose } ) => ( + <PublishDateTimePicker + currentDate={ date } + is12Hour + onClose={ onClose } + onChange={ saveDate } + /> + ) } + /> + </HStack> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss new file mode 100644 index 00000000000000..8c10b32085612b --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss @@ -0,0 +1,50 @@ +.edit-site-page-panels__edit-template-preview { + border: 1px solid $gray-200; + height: 200px; + max-height: 200px; + overflow: hidden; +} + +.edit-site-page-panels__edit-template-button { + justify-content: center; +} + +.edit-site-change-status__content { + .components-popover__content { + min-width: 320px; + padding: $grid-unit-20; + } + + .edit-site-change-status__options { + .components-base-control__field > .components-v-stack { + gap: $grid-unit-10; + } + + label { + .components-text { + display: block; + margin-left: $radio-input-size + 6; + } + } + } + + .edit-site-change-status__password-legend { + padding: 0; + margin-bottom: $grid-unit-10; + } +} + +.edit-site-summary-field { + .components-dropdown { + flex-grow: 1; + } + + .edit-site-summary-field__trigger { + width: 100%; + } + + .edit-site-summary-field__label { + width: 30%; + } +} + diff --git a/packages/edit-site/src/components/sidebar-edit-mode/settings-header/index.js b/packages/edit-site/src/components/sidebar-edit-mode/settings-header/index.js index 8e5e80d9fecc57..7bc951524e7e5c 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/settings-header/index.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/settings-header/index.js @@ -1,9 +1,14 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ import { Button } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; import { store as interfaceStore } from '@wordpress/interface'; /** @@ -11,27 +16,49 @@ import { store as interfaceStore } from '@wordpress/interface'; */ import { STORE_NAME } from '../../../store/constants'; import { SIDEBAR_BLOCK, SIDEBAR_TEMPLATE } from '../constants'; +import { store as editSiteStore } from '../../../store'; + +const entityLabels = { + wp_navigation: __( 'Navigation' ), + wp_block: __( 'Pattern' ), + wp_template: __( 'Template' ), +}; const SettingsHeader = ( { sidebarName } ) => { + const { hasPageContentFocus, entityType } = useSelect( ( select ) => { + const { getEditedPostType, hasPageContentFocus: _hasPageContentFocus } = + select( editSiteStore ); + + return { + hasPageContentFocus: _hasPageContentFocus(), + entityType: getEditedPostType(), + }; + } ); + + const entityLabel = entityLabels[ entityType ] || entityLabels.wp_template; + const { enableComplementaryArea } = useDispatch( interfaceStore ); const openTemplateSettings = () => enableComplementaryArea( STORE_NAME, SIDEBAR_TEMPLATE ); const openBlockSettings = () => enableComplementaryArea( STORE_NAME, SIDEBAR_BLOCK ); - const [ templateAriaLabel, templateActiveClass ] = - sidebarName === SIDEBAR_TEMPLATE - ? // translators: ARIA label for the Template sidebar tab, selected. - [ __( 'Template (selected)' ), 'is-active' ] - : // translators: ARIA label for the Template Settings Sidebar tab, not selected. - [ __( 'Template' ), '' ]; - - const [ blockAriaLabel, blockActiveClass ] = - sidebarName === SIDEBAR_BLOCK - ? // translators: ARIA label for the Block Settings Sidebar tab, selected. - [ __( 'Block (selected)' ), 'is-active' ] - : // translators: ARIA label for the Block Settings Sidebar tab, not selected. - [ __( 'Block' ), '' ]; + let templateAriaLabel; + if ( hasPageContentFocus ) { + templateAriaLabel = + sidebarName === SIDEBAR_TEMPLATE + ? // translators: ARIA label for the Template sidebar tab, selected. + __( 'Page (selected)' ) + : // translators: ARIA label for the Template Settings Sidebar tab, not selected. + __( 'Page' ); + } else { + templateAriaLabel = + sidebarName === SIDEBAR_TEMPLATE + ? // translators: ARIA label for the Template sidebar tab, selected. + sprintf( __( '%s (selected)' ), entityLabel ) + : // translators: ARIA label for the Template Settings Sidebar tab, not selected. + entityLabel; + } /* Use a list so screen readers will announce how many tabs there are. */ return ( @@ -39,29 +66,39 @@ const SettingsHeader = ( { sidebarName } ) => { <li> <Button onClick={ openTemplateSettings } - className={ `edit-site-sidebar-edit-mode__panel-tab ${ templateActiveClass }` } + className={ classnames( + 'edit-site-sidebar-edit-mode__panel-tab', + { + 'is-active': sidebarName === SIDEBAR_TEMPLATE, + } + ) } aria-label={ templateAriaLabel } - // translators: Data label for the Template Settings Sidebar tab. - data-label={ __( 'Template' ) } - > - { - // translators: Text label for the Template Settings Sidebar tab. - __( 'Template' ) + data-label={ + hasPageContentFocus ? __( 'Page' ) : entityLabel } + > + { hasPageContentFocus ? __( 'Page' ) : entityLabel } </Button> </li> <li> <Button onClick={ openBlockSettings } - className={ `edit-site-sidebar-edit-mode__panel-tab ${ blockActiveClass }` } - aria-label={ blockAriaLabel } - // translators: Data label for the Block Settings Sidebar tab. + className={ classnames( + 'edit-site-sidebar-edit-mode__panel-tab', + { + 'is-active': sidebarName === SIDEBAR_BLOCK, + } + ) } + aria-label={ + sidebarName === SIDEBAR_BLOCK + ? // translators: ARIA label for the Block Settings Sidebar tab, selected. + __( 'Block (selected)' ) + : // translators: ARIA label for the Block Settings Sidebar tab, not selected. + __( 'Block' ) + } data-label={ __( 'Block' ) } > - { - // translators: Text label for the Block Settings Sidebar tab. - __( 'Block' ) - } + { __( 'Block' ) } </Button> </li> </ul> diff --git a/packages/edit-site/src/components/sidebar-edit-mode/sidebar-card/index.js b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-card/index.js new file mode 100644 index 00000000000000..04e8d5667a2c20 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-card/index.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Icon } from '@wordpress/components'; + +export default function SidebarCard( { + className, + title, + icon, + description, + actions, + children, +} ) { + return ( + <div className={ classnames( 'edit-site-sidebar-card', className ) }> + <Icon className="edit-site-sidebar-card__icon" icon={ icon } /> + <div className="edit-site-sidebar-card__content"> + <div className="edit-site-sidebar-card__header"> + <h2 className="edit-site-sidebar-card__title">{ title }</h2> + { actions } + </div> + <div className="edit-site-sidebar-card__description"> + { description } + </div> + { children } + </div> + </div> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/sidebar-card/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-card/style.scss new file mode 100644 index 00000000000000..718fe8fb5a0fbd --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-card/style.scss @@ -0,0 +1,34 @@ +.edit-site-sidebar-card { + display: flex; + align-items: flex-start; + + &__content { + flex-grow: 1; + margin-bottom: $grid-unit-05; + } + + &__title { + font-weight: 500; + line-height: $icon-size; + &.edit-site-sidebar-card__title { + margin: 0; + } + } + + &__description { + font-size: $default-font-size; + } + + &__icon { + flex: 0 0 $icon-size; + margin-right: $grid-unit-15; + width: $icon-size; + height: $icon-size; + } + + &__header { + display: flex; + justify-content: space-between; + margin: 0 0 $grid-unit-05; + } +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js index c44b8c9c85c7fc..56e205bd330286 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js @@ -6,7 +6,7 @@ import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { createPrivateSlotFill } = unlock( componentsPrivateApis ); const SIDEBAR_FIXED_BOTTOM_SLOT_FILL_NAME = 'SidebarFixedBottom'; diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js b/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js deleted file mode 100644 index d43dca3b803f52..00000000000000 --- a/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { Icon } from '@wordpress/components'; -import { store as editorStore } from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; -import { decodeEntities } from '@wordpress/html-entities'; - -/** - * Internal dependencies - */ -import { store as editSiteStore } from '../../../store'; -import TemplateActions from './template-actions'; -import TemplateAreas from './template-areas'; - -export default function TemplateCard() { - const { - info: { title, description, icon }, - template, - } = useSelect( ( select ) => { - const { getEditedPostType, getEditedPostId } = select( editSiteStore ); - const { getEditedEntityRecord } = select( coreStore ); - const { __experimentalGetTemplateInfo: getTemplateInfo } = - select( editorStore ); - - const postType = getEditedPostType(); - const postId = getEditedPostId(); - const record = getEditedEntityRecord( 'postType', postType, postId ); - - const info = record ? getTemplateInfo( record ) : {}; - - return { info, template: record }; - }, [] ); - - if ( ! title && ! description ) { - return null; - } - - return ( - <> - <div className="edit-site-template-card"> - <Icon className="edit-site-template-card__icon" icon={ icon } /> - <div className="edit-site-template-card__content"> - <div className="edit-site-template-card__header"> - <h2 className="edit-site-template-card__title"> - { decodeEntities( title ) } - </h2> - <TemplateActions template={ template } /> - </div> - <div className="edit-site-template-card__description"> - { decodeEntities( description ) } - </div> - <TemplateAreas /> - </div> - </div> - </> - ); -} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-card/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/template-card/style.scss deleted file mode 100644 index 67054c25d2476c..00000000000000 --- a/packages/edit-site/src/components/sidebar-edit-mode/template-card/style.scss +++ /dev/null @@ -1,69 +0,0 @@ -.edit-site-template-card { - display: flex; - align-items: flex-start; - - &__content { - flex-grow: 1; - margin-bottom: $grid-unit-05; - } - - &__title { - font-weight: 500; - line-height: $icon-size; - &.edit-site-template-card__title { - margin: 0; - } - } - - &__description { - font-size: $default-font-size; - margin: 0 0 $grid-unit-20; - } - - &__icon { - flex: 0 0 $icon-size; - margin-right: $grid-unit-15; - width: $icon-size; - height: $icon-size; - } - - &__template-areas-list { - margin: 0; - - > li { - margin: 0; - } - } - - &__template-areas-item { - width: 100%; - - // Override the default padding. - &.components-button.has-icon { - padding: 0; - } - } - - &__header { - display: flex; - justify-content: space-between; - margin: 0 0 $grid-unit-05; - } - - &__actions { - line-height: 0; - > .components-button.is-small.has-icon { - padding: 0; - min-width: auto; - } - } -} - -.edit-site-template-revisions { - margin-left: math.div(-$grid-unit-10, 2); -} - -h3.edit-site-template-card__template-areas-title { - font-weight: 500; - margin: 0 0 $grid-unit-10; -} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-panel/index.js b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/index.js new file mode 100644 index 00000000000000..a76626d247af95 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/index.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { PanelRow, PanelBody } from '@wordpress/components'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; +import { navigation, symbol } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../../store'; +import TemplateActions from './template-actions'; +import TemplateAreas from './template-areas'; +import LastRevision from './last-revision'; +import SidebarCard from '../sidebar-card'; + +const CARD_ICONS = { + wp_block: symbol, + wp_navigation: navigation, +}; + +export default function TemplatePanel() { + const { title, description, icon, record } = useSelect( ( select ) => { + const { getEditedPostType, getEditedPostId } = select( editSiteStore ); + const { getEditedEntityRecord } = select( coreStore ); + const { __experimentalGetTemplateInfo: getTemplateInfo } = + select( editorStore ); + + const postType = getEditedPostType(); + const postId = getEditedPostId(); + const _record = getEditedEntityRecord( 'postType', postType, postId ); + const info = getTemplateInfo( _record ); + + return { + title: info.title, + description: info.description, + icon: info.icon, + record: _record, + }; + }, [] ); + + if ( ! title && ! description ) { + return null; + } + + return ( + <PanelBody className="edit-site-template-panel"> + <SidebarCard + className="edit-site-template-card" + title={ decodeEntities( title ) } + icon={ CARD_ICONS[ record?.type ] ?? icon } + description={ decodeEntities( description ) } + actions={ <TemplateActions template={ record } /> } + > + <TemplateAreas /> + </SidebarCard> + <PanelRow + header={ __( 'Editing history' ) } + className="edit-site-template-revisions" + > + <LastRevision /> + </PanelRow> + </PanelBody> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-revisions/index.js b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/last-revision.js similarity index 100% rename from packages/edit-site/src/components/sidebar-edit-mode/template-revisions/index.js rename to packages/edit-site/src/components/sidebar-edit-mode/template-panel/last-revision.js diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-panel/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/style.scss new file mode 100644 index 00000000000000..4c8ef94855dcb1 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/style.scss @@ -0,0 +1,39 @@ +.edit-site-template-card { + &__template-areas { + margin-top: $grid-unit-20; + } + + &__template-areas-list { + margin: 0; + + > li { + margin: 0; + } + } + + &__template-areas-item { + width: 100%; + + // Override the default padding. + &.components-button.has-icon { + padding: 0; + } + } + + &__actions { + line-height: 0; + > .components-button.is-small.has-icon { + padding: 0; + min-width: auto; + } + } +} + +.edit-site-template-revisions { + margin-left: math.div(-$grid-unit-10, 2); +} + +h3.edit-site-template-card__template-areas-title { + font-weight: 500; + margin: 0 0 $grid-unit-10; +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-card/template-actions.js b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/template-actions.js similarity index 100% rename from packages/edit-site/src/components/sidebar-edit-mode/template-card/template-actions.js rename to packages/edit-site/src/components/sidebar-edit-mode/template-panel/template-actions.js diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-card/template-areas.js b/packages/edit-site/src/components/sidebar-edit-mode/template-panel/template-areas.js similarity index 100% rename from packages/edit-site/src/components/sidebar-edit-mode/template-card/template-areas.js rename to packages/edit-site/src/components/sidebar-edit-mode/template-panel/template-areas.js diff --git a/packages/edit-site/src/components/sidebar-navigation-item/index.js b/packages/edit-site/src/components/sidebar-navigation-item/index.js index 244efdf0f869c1..fad9f634217146 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-item/index.js @@ -11,12 +11,14 @@ import { __experimentalHStack as HStack, FlexBlock, } from '@wordpress/components'; -import { chevronRightSmall, Icon } from '@wordpress/icons'; +import { isRTL } from '@wordpress/i18n'; +import { chevronRightSmall, chevronLeftSmall, Icon } from '@wordpress/icons'; export default function SidebarNavigationItem( { className, icon, withChevron = false, + suffix, children, ...props } ) { @@ -24,6 +26,7 @@ export default function SidebarNavigationItem( { <Item className={ classnames( 'edit-site-sidebar-navigation-item', + { 'with-suffix': ! withChevron && suffix }, className ) } { ...props } @@ -39,11 +42,12 @@ export default function SidebarNavigationItem( { <FlexBlock>{ children }</FlexBlock> { withChevron && ( <Icon - icon={ chevronRightSmall } + icon={ isRTL() ? chevronLeftSmall : chevronRightSmall } className="edit-site-sidebar-navigation-item__drilldown-indicator" size={ 24 } /> ) } + { ! withChevron && suffix } </HStack> </Item> ); diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss index e9ec7ecf91909e..88ff27a9c1d2f0 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -4,29 +4,30 @@ padding: $grid-unit-10 6px $grid-unit-10 $grid-unit-20; border: none; min-height: $grid-unit-50; + border-radius: $radius-block-ui; &:hover, &:focus, &[aria-current] { - color: $white; + color: $gray-200; background: $gray-800; + + .edit-site-sidebar-navigation-item__drilldown-indicator { + fill: $gray-200; + } } &[aria-current] { background: var(--wp-admin-theme-color); + color: $white; } .edit-site-sidebar-navigation-item__drilldown-indicator { - fill: $gray-700; + fill: $gray-600; } - &:is(a) { - text-decoration: none; - - &:focus { - box-shadow: none; - outline: none; - } + &.with-suffix { + padding-right: $grid-unit-20; } } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-footer/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-details-footer/index.js new file mode 100644 index 00000000000000..f9d7f112a41d5f --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-footer/index.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { humanTimeDiff } from '@wordpress/date'; +import { createInterpolateElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + SidebarNavigationScreenDetailsPanelRow, + SidebarNavigationScreenDetailsPanelLabel, + SidebarNavigationScreenDetailsPanelValue, +} from '../sidebar-navigation-screen-details-panel'; + +export default function SidebarNavigationScreenDetailsFooter( { + lastModifiedDateTime, +} ) { + return ( + <> + { lastModifiedDateTime && ( + <SidebarNavigationScreenDetailsPanelRow className="edit-site-sidebar-navigation-screen-details-footer"> + <SidebarNavigationScreenDetailsPanelLabel> + { __( 'Last modified' ) } + </SidebarNavigationScreenDetailsPanelLabel> + <SidebarNavigationScreenDetailsPanelValue> + { createInterpolateElement( + sprintf( + /* translators: %s: is the relative time when the post was last modified. */ + __( '<time>%s</time>' ), + humanTimeDiff( lastModifiedDateTime ) + ), + { + time: ( + <time dateTime={ lastModifiedDateTime } /> + ), + } + ) } + </SidebarNavigationScreenDetailsPanelValue> + </SidebarNavigationScreenDetailsPanelRow> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-footer/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-details-footer/style.scss new file mode 100644 index 00000000000000..fcd20d64696eb5 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-footer/style.scss @@ -0,0 +1,5 @@ +.edit-site-sidebar-navigation-screen-details-footer { + padding-top: $grid-unit-10; + padding-bottom: $grid-unit-10; + padding-left: $grid-unit-20; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/index.js new file mode 100644 index 00000000000000..7d7a3932c99473 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/index.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHeading as Heading, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import SidebarNavigationScreenDetailsPanelLabel from './sidebar-navigation-screen-details-panel-label'; +import SidebarNavigationScreenDetailsPanelRow from './sidebar-navigation-screen-details-panel-row'; +import SidebarNavigationScreenDetailsPanelValue from './sidebar-navigation-screen-details-panel-value'; + +function SidebarNavigationScreenDetailsPanel( { title, children, spacing } ) { + return ( + <VStack + className="edit-site-sidebar-navigation-details-screen-panel" + spacing={ spacing } + > + { title && ( + <Heading + className="edit-site-sidebar-navigation-details-screen-panel__heading" + level={ 2 } + > + { title } + </Heading> + ) } + { children } + </VStack> + ); +} + +export { + SidebarNavigationScreenDetailsPanel, + SidebarNavigationScreenDetailsPanelRow, + SidebarNavigationScreenDetailsPanelLabel, + SidebarNavigationScreenDetailsPanelValue, +}; diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-label.js b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-label.js new file mode 100644 index 00000000000000..157eecd557519c --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-label.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { __experimentalText as Text } from '@wordpress/components'; + +export default function SidebarNavigationScreenDetailsPanelLabel( { + children, +} ) { + return ( + <Text className="edit-site-sidebar-navigation-details-screen-panel__label"> + { children } + </Text> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-row.js b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-row.js new file mode 100644 index 00000000000000..541e654b0933ed --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-row.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __experimentalHStack as HStack } from '@wordpress/components'; + +export default function SidebarNavigationScreenDetailsPanelRow( { + label, + children, + className, +} ) { + return ( + <HStack + key={ label } + spacing={ 5 } + alignment="left" + className={ classnames( + 'edit-site-sidebar-navigation-details-screen-panel__row', + className + ) } + > + { children } + </HStack> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-value.js b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-value.js new file mode 100644 index 00000000000000..80e8ba8cf1d538 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/sidebar-navigation-screen-details-panel-value.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { __experimentalText as Text } from '@wordpress/components'; + +export default function SidebarNavigationScreenDetailsPanelValue( { + children, +} ) { + return ( + <Text className="edit-site-sidebar-navigation-details-screen-panel__value"> + { children } + </Text> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/style.scss new file mode 100644 index 00000000000000..2757ce5a620c58 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-details-panel/style.scss @@ -0,0 +1,26 @@ +.edit-site-sidebar-navigation-details-screen-panel { + margin: $grid-unit-30 0; + + &:last-of-type { + margin-bottom: 0; + } + + .edit-site-sidebar-navigation-details-screen-panel__heading { + color: $gray-400; + text-transform: uppercase; + font-weight: 500; + font-size: 11px; + padding: 0; + margin-bottom: 0; + } +} + +.edit-site-sidebar-navigation-details-screen-panel__label.edit-site-sidebar-navigation-details-screen-panel__label { + color: $gray-600; + width: 100px; + flex-shrink: 0; +} + +.edit-site-sidebar-navigation-details-screen-panel__value.edit-site-sidebar-navigation-details-screen-panel__value { + color: $gray-200; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js index a7b8add9dd54fe..5ad7691c5e5a2a 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js @@ -2,26 +2,38 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { edit, seen } from '@wordpress/icons'; +import { backup, edit, seen } from '@wordpress/icons'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { __experimentalNavigatorButton as NavigatorButton } from '@wordpress/components'; +import { + Icon, + __experimentalNavigatorButton as NavigatorButton, + __experimentalVStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; +import { BlockEditorProvider } from '@wordpress/block-editor'; +import { humanTimeDiff } from '@wordpress/date'; +import { useCallback } from '@wordpress/element'; /** * Internal dependencies */ import SidebarNavigationScreen from '../sidebar-navigation-screen'; import StyleVariationsContainer from '../global-styles/style-variations-container'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import SidebarButton from '../sidebar-button'; import SidebarNavigationItem from '../sidebar-navigation-item'; import StyleBook from '../style-book'; +import useGlobalStylesRevisions from '../global-styles/screen-revisions/use-global-styles-revisions'; + +const noop = () => {}; export function SidebarNavigationItemGlobalStyles( props ) { const { openGeneralSidebar } = useDispatch( editSiteStore ); const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); + const hasGlobalStyleVariations = useSelect( ( select ) => !! select( @@ -42,42 +54,139 @@ export function SidebarNavigationItemGlobalStyles( props ) { <SidebarNavigationItem { ...props } onClick={ () => { - // switch to edit mode. + // Switch to edit mode. setCanvasMode( 'edit' ); - // open global styles sidebar. + // Open global styles sidebar. openGeneralSidebar( 'edit-site/global-styles' ); } } /> ); } +function SidebarNavigationScreenGlobalStylesContent() { + const { storedSettings } = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + + return { + storedSettings: getSettings( false ), + }; + }, [] ); + + // Wrap in a BlockEditorProvider to ensure that the Iframe's dependencies are + // loaded. This is necessary because the Iframe component waits until + // the block editor store's `__internalIsInitialized` is true before + // rendering the iframe. Without this, the iframe previews will not render + // in mobile viewport sizes, where the editor canvas is hidden. + return ( + <BlockEditorProvider + settings={ storedSettings } + onChange={ noop } + onInput={ noop } + > + <StyleVariationsContainer /> + </BlockEditorProvider> + ); +} + +function SidebarNavigationScreenGlobalStylesFooter( { + modifiedDateTime, + onClickRevisions, +} ) { + return ( + <VStack className="edit-site-sidebar-navigation-screen-global-styles__footer"> + <SidebarNavigationItem + className="edit-site-sidebar-navigation-screen-global-styles__revisions" + label={ __( 'Revisions' ) } + onClick={ onClickRevisions } + > + <HStack + as="span" + alignment="center" + spacing={ 5 } + direction="row" + justify="space-between" + > + <span className="edit-site-sidebar-navigation-screen-global-styles__revisions__label"> + { __( 'Last modified' ) } + </span> + <span> + <time dateTime={ modifiedDateTime }> + { humanTimeDiff( modifiedDateTime ) } + </time> + </span> + <Icon icon={ backup } style={ { fill: 'currentcolor' } } /> + </HStack> + </SidebarNavigationItem> + </VStack> + ); +} + export default function SidebarNavigationScreenGlobalStyles() { - const { openGeneralSidebar } = useDispatch( editSiteStore ); + const { revisions, isLoading: isLoadingRevisions } = + useGlobalStylesRevisions(); + const { openGeneralSidebar, setIsListViewOpened } = + useDispatch( editSiteStore ); const isMobileViewport = useViewportMatch( 'medium', '<' ); const { setCanvasMode, setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) ); - - const isStyleBookOpened = useSelect( - ( select ) => - 'style-book' === - unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), + const { isViewMode, isStyleBookOpened, revisionsCount } = useSelect( + ( select ) => { + const { getCanvasMode, getEditorCanvasContainerView } = unlock( + select( editSiteStore ) + ); + const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = + select( coreStore ); + const globalStylesId = __experimentalGetCurrentGlobalStylesId(); + const globalStyles = globalStylesId + ? getEntityRecord( 'root', 'globalStyles', globalStylesId ) + : undefined; + return { + isViewMode: 'view' === getCanvasMode(), + isStyleBookOpened: + 'style-book' === getEditorCanvasContainerView(), + revisionsCount: + globalStyles?._links?.[ 'version-history' ]?.[ 0 ]?.count ?? + 0, + }; + }, [] ); - const openGlobalStyles = async () => - Promise.all( [ + const openGlobalStyles = useCallback( async () => { + return Promise.all( [ setCanvasMode( 'edit' ), openGeneralSidebar( 'edit-site/global-styles' ), ] ); + }, [ setCanvasMode, openGeneralSidebar ] ); - const openStyleBook = async () => { + const openStyleBook = useCallback( async () => { await openGlobalStyles(); // Open the Style Book once the canvas mode is set to edit, // and the global styles sidebar is open. This ensures that // the Style Book is not prematurely closed. setEditorCanvasContainerView( 'style-book' ); - }; + setIsListViewOpened( false ); + }, [ + openGlobalStyles, + setEditorCanvasContainerView, + setIsListViewOpened, + ] ); + + const openRevisions = useCallback( async () => { + await openGlobalStyles(); + // Open the global styles revisions once the canvas mode is set to edit, + // and the global styles sidebar is open. The global styles UI is responsible + // for redirecting to the revisions screen once the editor canvas container + // has been set to 'global-styles-revisions'. + setEditorCanvasContainerView( 'global-styles-revisions' ); + }, [ openGlobalStyles, setEditorCanvasContainerView ] ); + + // If there are no revisions, do not render a footer. + const hasRevisions = revisionsCount > 0; + const modifiedDateTime = revisions?.[ 0 ]?.modified; + const shouldShowGlobalStylesFooter = + hasRevisions && ! isLoadingRevisions && modifiedDateTime; return ( <> @@ -86,9 +195,17 @@ export default function SidebarNavigationScreenGlobalStyles() { description={ __( 'Choose a different style combination for the theme styles.' ) } - content={ <StyleVariationsContainer /> } + content={ <SidebarNavigationScreenGlobalStylesContent /> } + footer={ + shouldShowGlobalStylesFooter && ( + <SidebarNavigationScreenGlobalStylesFooter + modifiedDateTime={ modifiedDateTime } + onClickRevisions={ openRevisions } + /> + ) + } actions={ - <div> + <> { ! isMobileViewport && ( <SidebarButton icon={ seen } @@ -108,10 +225,10 @@ export default function SidebarNavigationScreenGlobalStyles() { label={ __( 'Edit styles' ) } onClick={ async () => await openGlobalStyles() } /> - </div> + </> } /> - { isStyleBookOpened && ! isMobileViewport && ( + { isStyleBookOpened && ! isMobileViewport && isViewMode && ( <StyleBook enableResizing={ false } isSelected={ () => false } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/style.scss new file mode 100644 index 00000000000000..099cdcae4410e1 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/style.scss @@ -0,0 +1,12 @@ + +.edit-site-sidebar-navigation-screen-global-styles__revisions { + border-radius: $radius-block-ui; + + &:not(:hover) { + color: $gray-200; + + .edit-site-sidebar-navigation-screen-global-styles__revisions__label { + color: $gray-600; + } + } +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js index 06514b6f960d71..152139870fa59f 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js @@ -4,10 +4,11 @@ import { __experimentalItemGroup as ItemGroup, __experimentalNavigatorButton as NavigatorButton, + __experimentalUseNavigator as useNavigator, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { layout, symbol, navigation, styles, page } from '@wordpress/icons'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch } from '@wordpress/data'; import { useEffect } from '@wordpress/element'; @@ -17,25 +18,22 @@ import { useEffect } from '@wordpress/element'; import SidebarNavigationScreen from '../sidebar-navigation-screen'; import SidebarNavigationItem from '../sidebar-navigation-item'; import { SidebarNavigationItemGlobalStyles } from '../sidebar-navigation-screen-global-styles'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; -import SidebarNavigationScreenNavigationMenuButton from '../sidebar-navigation-screen-navigation-menus/navigator-button'; +import TemplatePartHint from './template-part-hint'; export default function SidebarNavigationScreenMain() { - const editorCanvasContainerView = useSelect( ( select ) => { - return unlock( select( editSiteStore ) ).getEditorCanvasContainerView(); - }, [] ); - + const { location } = useNavigator(); const { setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) ); // Clear the editor canvas container view when accessing the main navigation screen. useEffect( () => { - if ( editorCanvasContainerView ) { + if ( location?.path === '/' ) { setEditorCanvasContainerView( undefined ); } - }, [ editorCanvasContainerView, setEditorCanvasContainerView ] ); + }, [ setEditorCanvasContainerView, location?.path ] ); return ( <SidebarNavigationScreen @@ -45,46 +43,49 @@ export default function SidebarNavigationScreenMain() { 'Customize the appearance of your website using the block editor.' ) } content={ - <ItemGroup> - <SidebarNavigationScreenNavigationMenuButton - withChevron - icon={ navigation } - as={ SidebarNavigationItem } - > - { __( 'Navigation' ) } - </SidebarNavigationScreenNavigationMenuButton> - - <SidebarNavigationItemGlobalStyles - withChevron - icon={ styles } - > - { __( 'Styles' ) } - </SidebarNavigationItemGlobalStyles> - <NavigatorButton - as={ SidebarNavigationItem } - path="/page" - withChevron - icon={ page } - > - { __( 'Pages' ) } - </NavigatorButton> - <NavigatorButton - as={ SidebarNavigationItem } - path="/wp_template" - withChevron - icon={ layout } - > - { __( 'Templates' ) } - </NavigatorButton> - <NavigatorButton - as={ SidebarNavigationItem } - path="/wp_template_part" - withChevron - icon={ symbol } - > - { __( 'Library' ) } - </NavigatorButton> - </ItemGroup> + <> + <ItemGroup> + <NavigatorButton + as={ SidebarNavigationItem } + path="/navigation" + withChevron + icon={ navigation } + > + { __( 'Navigation' ) } + </NavigatorButton> + <SidebarNavigationItemGlobalStyles + withChevron + icon={ styles } + > + { __( 'Styles' ) } + </SidebarNavigationItemGlobalStyles> + <NavigatorButton + as={ SidebarNavigationItem } + path="/page" + withChevron + icon={ page } + > + { __( 'Pages' ) } + </NavigatorButton> + <NavigatorButton + as={ SidebarNavigationItem } + path="/wp_template" + withChevron + icon={ layout } + > + { __( 'Templates' ) } + </NavigatorButton> + <NavigatorButton + as={ SidebarNavigationItem } + path="/patterns" + withChevron + icon={ symbol } + > + { __( 'Patterns' ) } + </NavigatorButton> + </ItemGroup> + <TemplatePartHint /> + </> } /> ); diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/template-part-hint.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/template-part-hint.js new file mode 100644 index 00000000000000..c6f270465b86f3 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/template-part-hint.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { Notice } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as preferencesStore } from '@wordpress/preferences'; + +const PREFERENCE_NAME = 'isTemplatePartMoveHintVisible'; + +export default function TemplatePartHint() { + const showTemplatePartHint = useSelect( + ( select ) => + select( preferencesStore ).get( 'core', PREFERENCE_NAME ) ?? true, + [] + ); + + const { set: setPreference } = useDispatch( preferencesStore ); + if ( ! showTemplatePartHint ) { + return null; + } + + return ( + <Notice + politeness="polite" + className="edit-site-sidebar__notice" + onRemove={ () => { + setPreference( 'core', PREFERENCE_NAME, false ); + } } + > + { __( 'Looking for template parts? Find them in "Patterns".' ) } + </Notice> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js index 35e662a23c33da..385938597da1f7 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js @@ -15,7 +15,7 @@ import { pencil } from '@wordpress/icons'; * Internal dependencies */ import SidebarNavigationScreen from '../sidebar-navigation-screen'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import SidebarButton from '../sidebar-button'; diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/delete-modal.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/delete-modal.js new file mode 100644 index 00000000000000..16bf88af41eacc --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/delete-modal.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export default function RenameModal( { onClose, onConfirm } ) { + return ( + <ConfirmDialog + isOpen={ true } + onConfirm={ ( e ) => { + e.preventDefault(); + onConfirm(); + + // Immediate close avoids ability to hit delete multiple times. + onClose(); + } } + onCancel={ onClose } + confirmButtonText={ __( 'Delete' ) } + > + { __( 'Are you sure you want to delete this Navigation menu?' ) } + </ConfirmDialog> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/edit-button.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/edit-button.js new file mode 100644 index 00000000000000..391017796b5e64 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/edit-button.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { pencil } from '@wordpress/icons'; +/** + * Internal dependencies + */ +import SidebarButton from '../sidebar-button'; +import { useLink } from '../routes/link'; + +export default function EditButton( { postId } ) { + const linkInfo = useLink( { + postId, + postType: 'wp_navigation', + canvas: 'edit', + } ); + return ( + <SidebarButton { ...linkInfo } label={ __( 'Edit' ) } icon={ pencil } /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/index.js index fcfec7b345478d..5ae860d4bb8298 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/index.js @@ -1,48 +1,65 @@ /** * WordPress dependencies */ -import { useEntityRecord } from '@wordpress/core-data'; +import { useEntityRecord, store as coreStore } from '@wordpress/core-data'; import { __experimentalUseNavigator as useNavigator, Spinner, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useCallback, useMemo } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; -import { privateApis as routerPrivateApis } from '@wordpress/router'; -import { BlockEditorProvider } from '@wordpress/block-editor'; -import { createBlock } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; -import { store as editSiteStore } from '../../store'; -import { - isPreviewingTheme, - currentlyPreviewingTheme, -} from '../../utils/is-previewing-theme'; import { SidebarNavigationScreenWrapper } from '../sidebar-navigation-screen-navigation-menus'; -import NavigationMenuContent from '../sidebar-navigation-screen-navigation-menus/navigation-menu-content'; +import ScreenNavigationMoreMenu from './more-menu'; +import SingleNavigationMenu from './single-navigation-menu'; +import useNavigationMenuHandlers from './use-navigation-menu-handlers'; +import buildNavigationLabel from '../sidebar-navigation-screen-navigation-menus/build-navigation-label'; -const { useHistory } = unlock( routerPrivateApis ); -const noop = () => {}; +export const postType = `wp_navigation`; export default function SidebarNavigationScreenNavigationMenu() { - const postType = `wp_navigation`; const { params: { postId }, } = useNavigator(); - const { record: navigationMenu, isResolving: isLoading } = useEntityRecord( + const { record: navigationMenu, isResolving } = useEntityRecord( 'postType', postType, postId ); + const { isSaving, isDeleting } = useSelect( + ( select ) => { + const { isSavingEntityRecord, isDeletingEntityRecord } = + select( coreStore ); + + return { + isSaving: isSavingEntityRecord( 'postType', postType, postId ), + isDeleting: isDeletingEntityRecord( + 'postType', + postType, + postId + ), + }; + }, + [ postId ] + ); + + const isLoading = isResolving || isSaving || isDeleting; + const menuTitle = navigationMenu?.title?.rendered || navigationMenu?.slug; + const { handleSave, handleDelete, handleDuplicate } = + useNavigationMenuHandlers(); + + const _handleDelete = () => handleDelete( navigationMenu ); + const _handleSave = ( edits ) => handleSave( navigationMenu, edits ); + const _handleDuplicate = () => handleDuplicate( navigationMenu ); + if ( isLoading ) { return ( <SidebarNavigationScreenWrapper @@ -66,92 +83,30 @@ export default function SidebarNavigationScreenNavigationMenu() { if ( ! navigationMenu?.content?.raw ) { return ( <SidebarNavigationScreenWrapper - title={ decodeEntities( menuTitle ) } + actions={ + <ScreenNavigationMoreMenu + menuTitle={ decodeEntities( menuTitle ) } + onDelete={ _handleDelete } + onSave={ _handleSave } + onDuplicate={ _handleDuplicate } + /> + } + title={ buildNavigationLabel( + navigationMenu?.title, + navigationMenu?.id, + navigationMenu?.status + ) } description={ __( 'This Navigation Menu is empty.' ) } /> ); } return ( - <SidebarNavigationScreenWrapper - title={ decodeEntities( menuTitle ) } - description={ __( - 'Navigation menus are a curated collection of blocks that allow visitors to get around your site.' - ) } - > - <NavigationMenuEditor navigationMenu={ navigationMenu } /> - </SidebarNavigationScreenWrapper> - ); -} - -function NavigationMenuEditor( { navigationMenu } ) { - const history = useHistory(); - - const onSelect = useCallback( - ( selectedBlock ) => { - const { attributes, name } = selectedBlock; - if ( - attributes.kind === 'post-type' && - attributes.id && - attributes.type && - history - ) { - history.push( { - postType: attributes.type, - postId: attributes.id, - ...( isPreviewingTheme() && { - theme_preview: currentlyPreviewingTheme(), - } ), - } ); - } - if ( name === 'core/page-list-item' && attributes.id && history ) { - history.push( { - postType: 'page', - postId: attributes.id, - ...( isPreviewingTheme() && { - theme_preview: currentlyPreviewingTheme(), - } ), - } ); - } - }, - [ history ] - ); - - const { storedSettings } = useSelect( ( select ) => { - const { getSettings } = unlock( select( editSiteStore ) ); - - return { - storedSettings: getSettings( false ), - }; - }, [] ); - - const blocks = useMemo( () => { - if ( ! NavigationMenuEditor ) { - return []; - } - - return [ - createBlock( 'core/navigation', { ref: navigationMenu?.id } ), - ]; - }, [ navigationMenu ] ); - - if ( ! navigationMenu || ! blocks?.length ) { - return null; - } - - return ( - <BlockEditorProvider - settings={ storedSettings } - value={ blocks } - onChange={ noop } - onInput={ noop } - > - <div className="edit-site-sidebar-navigation-screen-navigation-menus__content"> - <NavigationMenuContent - rootClientId={ blocks[ 0 ].clientId } - onSelect={ onSelect } - /> - </div> - </BlockEditorProvider> + <SingleNavigationMenu + navigationMenu={ navigationMenu } + handleDelete={ _handleDelete } + handleSave={ _handleSave } + handleDuplicate={ _handleDuplicate } + /> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/more-menu.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/more-menu.js new file mode 100644 index 00000000000000..2d386d67bb7a75 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/more-menu.js @@ -0,0 +1,89 @@ +/** + * WordPress dependencies + */ +import { DropdownMenu, MenuItem, MenuGroup } from '@wordpress/components'; +import { moreVertical } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import RenameModal from './rename-modal'; +import DeleteModal from './delete-modal'; + +const POPOVER_PROPS = { + position: 'bottom right', +}; + +export default function ScreenNavigationMoreMenu( props ) { + const { onDelete, onSave, onDuplicate, menuTitle } = props; + + const [ renameModalOpen, setRenameModalOpen ] = useState( false ); + const [ deleteModalOpen, setDeleteModalOpen ] = useState( false ); + + const closeModals = () => { + setRenameModalOpen( false ); + setDeleteModalOpen( false ); + }; + const openRenameModal = () => setRenameModalOpen( true ); + const openDeleteModal = () => setDeleteModalOpen( true ); + + return ( + <> + <DropdownMenu + className="sidebar-navigation__more-menu" + label={ __( 'Actions' ) } + icon={ moreVertical } + popoverProps={ POPOVER_PROPS } + > + { ( { onClose } ) => ( + <div> + <MenuGroup> + <MenuItem + onClick={ () => { + openRenameModal(); + // Close the dropdown after opening the modal. + onClose(); + } } + > + { __( 'Rename' ) } + </MenuItem> + <MenuItem + onClick={ () => { + onDuplicate(); + onClose(); + } } + > + { __( 'Duplicate' ) } + </MenuItem> + <MenuItem + isDestructive + onClick={ () => { + openDeleteModal(); + + // Close the dropdown after opening the modal. + onClose(); + } } + > + { __( 'Delete' ) } + </MenuItem> + </MenuGroup> + </div> + ) } + </DropdownMenu> + + { deleteModalOpen && ( + <DeleteModal onClose={ closeModals } onConfirm={ onDelete } /> + ) } + + { renameModalOpen && ( + <RenameModal + onClose={ closeModals } + menuTitle={ menuTitle } + onSave={ onSave } + /> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/navigation-menu-editor.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/navigation-menu-editor.js new file mode 100644 index 00000000000000..7d3be6f631f438 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/navigation-menu-editor.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { BlockEditorProvider } from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import NavigationMenuContent from '../sidebar-navigation-screen-navigation-menus/navigation-menu-content'; + +const noop = () => {}; + +export default function NavigationMenuEditor( { navigationMenuId } ) { + const { storedSettings } = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + + return { + storedSettings: getSettings( false ), + }; + }, [] ); + + const blocks = useMemo( () => { + if ( ! navigationMenuId ) { + return []; + } + + return [ createBlock( 'core/navigation', { ref: navigationMenuId } ) ]; + }, [ navigationMenuId ] ); + + if ( ! navigationMenuId || ! blocks?.length ) { + return null; + } + + return ( + <BlockEditorProvider + settings={ storedSettings } + value={ blocks } + onChange={ noop } + onInput={ noop } + > + <div className="edit-site-sidebar-navigation-screen-navigation-menus__content"> + <NavigationMenuContent rootClientId={ blocks[ 0 ].clientId } /> + </div> + </BlockEditorProvider> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js new file mode 100644 index 00000000000000..668179755ec35a --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalVStack as VStack, + Button, + TextControl, + Modal, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; + +const notEmptyString = ( testString ) => testString?.trim()?.length > 0; + +export default function RenameModal( { menuTitle, onClose, onSave } ) { + const [ editedMenuTitle, setEditedMenuTitle ] = useState( menuTitle ); + + const titleHasChanged = editedMenuTitle !== menuTitle; + + const isEditedMenuTitleValid = + titleHasChanged && notEmptyString( editedMenuTitle ); + + return ( + <Modal title={ __( 'Rename' ) } onRequestClose={ onClose }> + <form className="sidebar-navigation__rename-modal-form"> + <VStack spacing="3"> + <TextControl + __nextHasNoMarginBottom + value={ editedMenuTitle } + placeholder={ __( 'Navigation title' ) } + onChange={ setEditedMenuTitle } + /> + <HStack justify="right"> + <Button variant="tertiary" onClick={ onClose }> + { __( 'Cancel' ) } + </Button> + + <Button + disabled={ ! isEditedMenuTitleValid } + variant="primary" + type="submit" + onClick={ ( e ) => { + e.preventDefault(); + + if ( ! isEditedMenuTitleValid ) { + return; + } + onSave( { title: editedMenuTitle } ); + + // Immediate close avoids ability to hit save multiple times. + onClose(); + } } + > + { __( 'Save' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/single-navigation-menu.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/single-navigation-menu.js new file mode 100644 index 00000000000000..960e0363f2e588 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/single-navigation-menu.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; +/** + * Internal dependencies + */ +import { SidebarNavigationScreenWrapper } from '../sidebar-navigation-screen-navigation-menus'; +import ScreenNavigationMoreMenu from './more-menu'; +import NavigationMenuEditor from './navigation-menu-editor'; +import buildNavigationLabel from '../sidebar-navigation-screen-navigation-menus/build-navigation-label'; +import EditButton from './edit-button'; + +export default function SingleNavigationMenu( { + navigationMenu, + handleDelete, + handleDuplicate, + handleSave, +} ) { + const menuTitle = navigationMenu?.title?.rendered; + + return ( + <SidebarNavigationScreenWrapper + actions={ + <> + <ScreenNavigationMoreMenu + menuTitle={ decodeEntities( menuTitle ) } + onDelete={ handleDelete } + onSave={ handleSave } + onDuplicate={ handleDuplicate } + /> + <EditButton postId={ navigationMenu?.id } /> + </> + } + title={ buildNavigationLabel( + navigationMenu?.title, + navigationMenu?.id, + navigationMenu?.status + ) } + description={ __( + 'Navigation menus are a curated collection of blocks that allow visitors to get around your site.' + ) } + > + <NavigationMenuEditor navigationMenuId={ navigationMenu?.id } /> + </SidebarNavigationScreenWrapper> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/style.scss new file mode 100644 index 00000000000000..012622f9b1040b --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/style.scss @@ -0,0 +1,10 @@ +.sidebar-navigation__more-menu { + .components-button { + color: $gray-200; + &:hover, + &:focus, + &[aria-current] { + color: $gray-100; + } + } +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js new file mode 100644 index 00000000000000..c7dbf919324f61 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js @@ -0,0 +1,189 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { __experimentalUseNavigator as useNavigator } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +/** + * Internal dependencies + */ +import { postType } from '.'; + +function useDeleteNavigationMenu() { + const { goTo } = useNavigator(); + + const { deleteEntityRecord } = useDispatch( coreStore ); + + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + const handleDelete = async ( navigationMenu ) => { + const postId = navigationMenu?.id; + try { + await deleteEntityRecord( + 'postType', + postType, + postId, + { + force: true, + }, + { + throwOnError: true, + } + ); + createSuccessNotice( __( 'Deleted Navigation menu' ), { + type: 'snackbar', + } ); + goTo( '/navigation' ); + } catch ( error ) { + createErrorNotice( + sprintf( + /* translators: %s: error message describing why the navigation menu could not be deleted. */ + __( `Unable to delete Navigation menu (%s).` ), + error?.message + ), + + { + type: 'snackbar', + } + ); + } + }; + + return handleDelete; +} + +function useSaveNavigationMenu() { + const { getEditedEntityRecord } = useSelect( ( select ) => { + const { getEditedEntityRecord: getEditedEntityRecordSelector } = + select( coreStore ); + + return { + getEditedEntityRecord: getEditedEntityRecordSelector, + }; + }, [] ); + + const { + editEntityRecord, + __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits, + } = useDispatch( coreStore ); + + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + const handleSave = async ( navigationMenu, edits ) => { + if ( ! edits ) { + return; + } + + const postId = navigationMenu?.id; + // Prepare for revert in case of error. + const originalRecord = getEditedEntityRecord( + 'postType', + 'wp_navigation', + postId + ); + + // Apply the edits. + editEntityRecord( 'postType', postType, postId, edits ); + + const recordPropertiesToSave = Object.keys( edits ); + + // Attempt to persist. + try { + await saveSpecifiedEntityEdits( + 'postType', + postType, + postId, + recordPropertiesToSave, + { + throwOnError: true, + } + ); + createSuccessNotice( __( 'Renamed Navigation menu' ), { + type: 'snackbar', + } ); + } catch ( error ) { + // Revert to original in case of error. + editEntityRecord( 'postType', postType, postId, originalRecord ); + + createErrorNotice( + sprintf( + /* translators: %s: error message describing why the navigation menu could not be renamed. */ + __( `Unable to rename Navigation menu (%s).` ), + error?.message + ), + + { + type: 'snackbar', + } + ); + } + }; + + return handleSave; +} + +function useDuplicateNavigationMenu() { + const { goTo } = useNavigator(); + + const { saveEntityRecord } = useDispatch( coreStore ); + + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + const handleDuplicate = async ( navigationMenu ) => { + const menuTitle = + navigationMenu?.title?.rendered || navigationMenu?.slug; + + try { + const savedRecord = await saveEntityRecord( + 'postType', + postType, + { + title: sprintf( + /* translators: %s: Navigation menu title */ + __( '%s (Copy)' ), + menuTitle + ), + content: navigationMenu?.content?.raw, + status: 'publish', + }, + { + throwOnError: true, + } + ); + + if ( savedRecord ) { + createSuccessNotice( __( 'Duplicated Navigation menu' ), { + type: 'snackbar', + } ); + goTo( `/navigation/${ postType }/${ savedRecord.id }` ); + } + } catch ( error ) { + createErrorNotice( + sprintf( + /* translators: %s: error message describing why the navigation menu could not be deleted. */ + __( `Unable to duplicate Navigation menu (%s).` ), + error?.message + ), + + { + type: 'snackbar', + } + ); + } + }; + + return handleDuplicate; +} + +export default function useNavigationMenuHandlers() { + return { + handleDelete: useDeleteNavigationMenu(), + handleSave: useSaveNavigationMenu(), + handleDuplicate: useDuplicateNavigationMenu(), + }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js new file mode 100644 index 00000000000000..d5e5ff02c63bfd --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; + +// Copied from packages/block-library/src/navigation/edit/navigation-menu-selector.js. +export default function buildNavigationLabel( title, id, status ) { + if ( ! title?.rendered ) { + /* translators: %s is the index of the menu in the list of menus. */ + return sprintf( __( '(no title %s)' ), id ); + } + + if ( status === 'publish' ) { + return decodeEntities( title?.rendered ); + } + + return sprintf( + // translators: %1s: title of the menu; %2s: status of the menu (draft, pending, etc.). + __( '%1$s (%2$s)' ), + decodeEntities( title?.rendered ), + status + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/constants.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/constants.js index 740474597b3b21..643a452901b541 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/constants.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/constants.js @@ -1,9 +1,13 @@ // This requested is preloaded in `gutenberg_preload_navigation_posts`. // As unbounded queries are limited to 100 by `fetchAllMiddleware` // on apiFetch this query is limited to 100. +// These parameters must be kept aligned with those in +// lib/compat/wordpress-6.3/navigation-block-preloading.php +// and +// block-library/src/navigation/constants.js export const PRELOADED_NAVIGATION_MENUS_QUERY = { per_page: 100, - status: 'publish', + status: [ 'publish', 'draft' ], order: 'desc', orderby: 'date', }; diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js index 5706ff17bc0384..c6c91d443947f6 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js @@ -1,8 +1,9 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; -import { useEntityRecords } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; +import { useEntityRecords, store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; import { decodeEntities } from '@wordpress/html-entities'; import { @@ -18,14 +19,68 @@ import SidebarNavigationScreen from '../sidebar-navigation-screen'; import SidebarNavigationItem from '../sidebar-navigation-item'; import { PRELOADED_NAVIGATION_MENUS_QUERY } from './constants'; import { useLink } from '../routes/link'; +import SingleNavigationMenu from '../sidebar-navigation-screen-navigation-menu/single-navigation-menu'; +import useNavigationMenuHandlers from '../sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers'; +import { unlock } from '../../lock-unlock'; + +// Copied from packages/block-library/src/navigation/edit/navigation-menu-selector.js. +function buildMenuLabel( title, id, status ) { + if ( ! title ) { + /* translators: %s is the index of the menu in the list of menus. */ + return sprintf( __( '(no title %s)' ), id ); + } + + if ( status === 'publish' ) { + return decodeEntities( title ); + } + + return sprintf( + // translators: %1s: title of the menu; %2s: status of the menu (draft, pending, etc.). + __( '%1$s (%2$s)' ), + decodeEntities( title ), + status + ); +} + +// Save a boolean to prevent us creating a fallback more than once per session. +let hasCreatedFallback = false; export default function SidebarNavigationScreenNavigationMenus() { - const { records: navigationMenus, isResolving: isLoading } = - useEntityRecords( - 'postType', - `wp_navigation`, - PRELOADED_NAVIGATION_MENUS_QUERY - ); + const { + records: navigationMenus, + isResolving: isResolvingNavigationMenus, + hasResolved: hasResolvedNavigationMenus, + } = useEntityRecords( + 'postType', + `wp_navigation`, + PRELOADED_NAVIGATION_MENUS_QUERY + ); + + const isLoading = + isResolvingNavigationMenus && ! hasResolvedNavigationMenus; + + const { getNavigationFallbackId } = unlock( useSelect( coreStore ) ); + + const firstNavigationMenu = navigationMenus?.[ 0 ]; + + // Save a boolean to prevent us creating a fallback more than once per session. + if ( firstNavigationMenu ) { + hasCreatedFallback = true; + } + + // If there is no navigation menu found + // then trigger fallback algorithm to create one. + if ( + ! firstNavigationMenu && + ! isResolvingNavigationMenus && + hasResolvedNavigationMenus && + ! hasCreatedFallback + ) { + getNavigationFallbackId(); + } + + const { handleSave, handleDelete, handleDuplicate } = + useNavigationMenuHandlers(); const hasNavigationMenus = !! navigationMenus?.length; @@ -45,19 +100,31 @@ export default function SidebarNavigationScreenNavigationMenus() { ); } + // if single menu then render it + if ( navigationMenus?.length === 1 ) { + return ( + <SingleNavigationMenu + navigationMenu={ firstNavigationMenu } + handleDelete={ () => handleDelete( firstNavigationMenu ) } + handleDuplicate={ () => handleDuplicate( firstNavigationMenu ) } + handleSave={ ( edits ) => + handleSave( firstNavigationMenu, edits ) + } + /> + ); + } + return ( <SidebarNavigationScreenWrapper> <ItemGroup> - { navigationMenus?.map( ( navMenu ) => ( + { navigationMenus?.map( ( { id, title, status }, index ) => ( <NavMenuItem - postId={ navMenu.id } - key={ navMenu.id } + postId={ id } + key={ id } withChevron icon={ navigation } > - { decodeEntities( - navMenu.title?.rendered || navMenu.slug - ) } + { buildMenuLabel( title?.rendered, index + 1, status ) } </NavMenuItem> ) ) } </ItemGroup> diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/leaf-more-menu.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/leaf-more-menu.js index 7b7472c8e30bf2..6b093ad27e25b9 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/leaf-more-menu.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/leaf-more-menu.js @@ -5,16 +5,31 @@ import { chevronUp, chevronDown, moreVertical } from '@wordpress/icons'; import { DropdownMenu, MenuItem, MenuGroup } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { BlockTitle, store as blockEditorStore } from '@wordpress/block-editor'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; const POPOVER_PROPS = { className: 'block-editor-block-settings-menu__popover', - position: 'bottom right', - variant: 'toolbar', + placement: 'bottom-start', }; +/** + * Internal dependencies + */ +import { + isPreviewingTheme, + currentlyPreviewingTheme, +} from '../../utils/is-previewing-theme'; +import { unlock } from '../../lock-unlock'; +import { getPathFromURL } from '../sync-state-with-url/use-sync-path-with-url'; + +const { useLocation, useHistory } = unlock( routerPrivateApis ); + export default function LeafMoreMenu( props ) { + const location = useLocation(); + const history = useHistory(); const { block } = props; const { clientId } = block; const { moveBlocksDown, moveBlocksUp, removeBlocks } = @@ -26,6 +41,12 @@ export default function LeafMoreMenu( props ) { BlockTitle( { clientId, maximumLength: 25 } ) ); + const goToLabel = sprintf( + /* translators: %s: block name */ + __( 'Go to %s' ), + BlockTitle( { clientId, maximumLength: 25 } ) + ); + const rootClientId = useSelect( ( select ) => { const { getBlockRootClientId } = select( blockEditorStore ); @@ -35,6 +56,46 @@ export default function LeafMoreMenu( props ) { [ clientId ] ); + const onGoToPage = useCallback( + ( selectedBlock ) => { + const { attributes, name } = selectedBlock; + if ( + attributes.kind === 'post-type' && + attributes.id && + attributes.type && + history + ) { + history.push( + { + postType: attributes.type, + postId: attributes.id, + ...( isPreviewingTheme() && { + wp_theme_preview: currentlyPreviewingTheme(), + } ), + }, + { + backPath: getPathFromURL( location.params ), + } + ); + } + if ( name === 'core/page-list-item' && attributes.id && history ) { + history.push( + { + postType: 'page', + postId: attributes.id, + ...( isPreviewingTheme() && { + wp_theme_preview: currentlyPreviewingTheme(), + } ), + }, + { + backPath: getPathFromURL( location.params ), + } + ); + } + }, + [ history ] + ); + return ( <DropdownMenu icon={ moreVertical } @@ -65,6 +126,17 @@ export default function LeafMoreMenu( props ) { > { __( 'Move down' ) } </MenuItem> + { block.attributes?.type === 'page' && + block.attributes?.id && ( + <MenuItem + onClick={ () => { + onGoToPage( block ); + onClose(); + } } + > + { goToLabel } + </MenuItem> + ) } </MenuGroup> <MenuGroup> <MenuItem diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js index 9d25db73d45b6d..6cc3138c9c2f7f 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js @@ -5,20 +5,20 @@ import { privateApis as blockEditorPrivateApis, store as blockEditorStore, BlockList, - BlockTools, } from '@wordpress/block-editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { createBlock } from '@wordpress/blocks'; -import { VisuallyHidden } from '@wordpress/components'; -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useCallback } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import LeafMoreMenu from './leaf-more-menu'; +const { PrivateListView } = unlock( blockEditorPrivateApis ); + // Needs to be kept in sync with the query used at packages/block-library/src/page-list/edit.js. const MAX_PAGE_COUNT = 100; const PAGES_QUERY = [ @@ -35,37 +35,40 @@ const PAGES_QUERY = [ }, ]; -export default function NavigationMenuContent( { rootClientId, onSelect } ) { - const [ isLoading, setIsLoading ] = useState( true ); - const { clientIdsTree, shouldKeepLoading, isSinglePageList } = useSelect( +export default function NavigationMenuContent( { rootClientId } ) { + const { listViewRootClientId, isLoading } = useSelect( ( select ) => { const { - __unstableGetClientIdsTree, areInnerBlocksControlled, getBlockName, + getBlockCount, + getBlockOrder, } = select( blockEditorStore ); const { isResolving } = select( coreStore ); - const _clientIdsTree = __unstableGetClientIdsTree( rootClientId ); + const blockClientIds = getBlockOrder( rootClientId ); + const hasOnlyPageListBlock = - _clientIdsTree.length === 1 && - getBlockName( _clientIdsTree[ 0 ].clientId ) === - 'core/page-list'; + blockClientIds.length === 1 && + getBlockName( blockClientIds[ 0 ] ) === 'core/page-list'; + const pageListHasBlocks = + hasOnlyPageListBlock && + getBlockCount( blockClientIds[ 0 ] ) > 0; + const isLoadingPages = isResolving( 'getEntityRecords', PAGES_QUERY ); + return { - clientIdsTree: _clientIdsTree, + listViewRootClientId: pageListHasBlocks + ? blockClientIds[ 0 ] + : rootClientId, // This is a small hack to wait for the navigation block // to actually load its inner blocks. - shouldKeepLoading: + isLoading: ! areInnerBlocksControlled( rootClientId ) || isLoadingPages, - isSinglePageList: - hasOnlyPageListBlock && - ! isLoadingPages && - _clientIdsTree[ 0 ].innerBlocks.length > 0, }; }, [ rootClientId ] @@ -73,26 +76,6 @@ export default function NavigationMenuContent( { rootClientId, onSelect } ) { const { replaceBlock, __unstableMarkNextChangeAsNotPersistent } = useDispatch( blockEditorStore ); - // Delay loading stop by 50ms to avoid flickering. - useEffect( () => { - let timeoutId; - if ( shouldKeepLoading && ! isLoading ) { - setIsLoading( true ); - } - if ( ! shouldKeepLoading && isLoading ) { - timeoutId = setTimeout( () => { - setIsLoading( false ); - timeoutId = undefined; - }, 50 ); - } - return () => { - if ( timeoutId ) { - clearTimeout( timeoutId ); - } - }; - }, [ shouldKeepLoading, clientIdsTree, isLoading ] ); - - const { PrivateListView } = unlock( blockEditorPrivateApis ); const offCanvasOnselect = useCallback( ( block ) => { if ( @@ -104,11 +87,9 @@ export default function NavigationMenuContent( { rootClientId, onSelect } ) { block.clientId, createBlock( 'core/navigation-link', block.attributes ) ); - } else { - onSelect( block ); } }, - [ onSelect, __unstableMarkNextChangeAsNotPersistent, replaceBlock ] + [ __unstableMarkNextChangeAsNotPersistent, replaceBlock ] ); // The hidden block is needed because it makes block edit side effects trigger. @@ -117,21 +98,15 @@ export default function NavigationMenuContent( { rootClientId, onSelect } ) { <> { ! isLoading && ( <PrivateListView - blocks={ - isSinglePageList - ? clientIdsTree[ 0 ].innerBlocks - : clientIdsTree - } + rootClientId={ listViewRootClientId } onSelect={ offCanvasOnselect } blockSettingsMenu={ LeafMoreMenu } showAppender={ false } /> ) } - <VisuallyHidden aria-hidden="true"> - <BlockTools> - <BlockList /> - </BlockTools> - </VisuallyHidden> + <div className="edit-site-sidebar-navigation-screen-navigation-menus__helper-block-editor"> + <BlockList /> + </div> </> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigator-button.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigator-button.js deleted file mode 100644 index 7e75c544a0f167..00000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigator-button.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * WordPress dependencies - */ - -import { __experimentalNavigatorButton as NavigatorButton } from '@wordpress/components'; -import { useEntityRecords } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { PRELOADED_NAVIGATION_MENUS_QUERY } from './constants'; - -export default function SidebarNavigationScreenNavigationMenuButton( { - children, - ...props -} ) { - const { records: navigationMenus, isResolving: isLoading } = - useEntityRecords( - 'postType', - `wp_navigation`, - PRELOADED_NAVIGATION_MENUS_QUERY - ); - - const hasNavigationMenus = !! navigationMenus?.length; - const hasSingleNavigationMenu = navigationMenus?.length === 1; - const firstNavigationMenu = navigationMenus?.[ 0 ]; - - const showNavigationScreen = process.env.IS_GUTENBERG_PLUGIN - ? hasNavigationMenus - : false; - - // If there is a single menu then we can go directly to that menu. - // Otherwise we go to the navigation listing screen. - const path = hasSingleNavigationMenu - ? `/navigation/wp_navigation/${ firstNavigationMenu?.id }` - : '/navigation'; - - if ( ! showNavigationScreen ) { - return null; - } - - return ( - <NavigatorButton { ...props } disabled={ isLoading } path={ path }> - { children } - </NavigatorButton> - ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss index e594fa0384e1db..a022b7824cb4a7 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss @@ -1,5 +1,5 @@ .edit-site-sidebar-navigation-screen__description { - margin: 0 0 $grid-unit-40 $grid-unit-20; + margin: 0 0 $grid-unit-40 0; } .edit-site-sidebar-navigation-screen-navigation-menus__content { @@ -99,3 +99,7 @@ margin-right: auto; display: block; } + +.edit-site-sidebar-navigation-screen-navigation-menus__helper-block-editor { + display: none; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js index 5acfc98ffe52c1..c5799d97a80645 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js @@ -2,58 +2,132 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { __experimentalUseNavigator as useNavigator, + __experimentalVStack as VStack, ExternalLink, + __experimentalTruncate as Truncate, } from '@wordpress/components'; -import { useEntityRecord } from '@wordpress/core-data'; +import { store as coreStore, useEntityRecord } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; import { pencil } from '@wordpress/icons'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { escapeAttribute } from '@wordpress/escape-html'; +import { safeDecodeURIComponent, filterURLForDisplay } from '@wordpress/url'; /** * Internal dependencies */ import SidebarNavigationScreen from '../sidebar-navigation-screen'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import SidebarButton from '../sidebar-button'; +import PageDetails from './page-details'; +import PageActions from '../page-actions'; +import SidebarNavigationScreenDetailsFooter from '../sidebar-navigation-screen-details-footer'; export default function SidebarNavigationScreenPage() { + const navigator = useNavigator(); const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); const { params: { postId }, } = useNavigator(); const { record } = useEntityRecord( 'postType', 'page', postId ); - return ( + const { featuredMediaAltText, featuredMediaSourceUrl } = useSelect( + ( select ) => { + const { getEntityRecord } = select( coreStore ); + // Featured image. + const attachedMedia = record?.featured_media + ? getEntityRecord( + 'postType', + 'attachment', + record?.featured_media + ) + : null; + + return { + featuredMediaSourceUrl: + attachedMedia?.media_details.sizes?.medium?.source_url || + attachedMedia?.source_url, + featuredMediaAltText: escapeAttribute( + attachedMedia?.alt_text || + attachedMedia?.description?.raw || + '' + ), + }; + }, + [ record ] + ); + + const featureImageAltText = featuredMediaAltText + ? decodeEntities( featuredMediaAltText ) + : decodeEntities( record?.title?.rendered || __( 'Featured image' ) ); + + return record ? ( <SidebarNavigationScreen - title={ record ? decodeEntities( record?.title?.rendered ) : null } + title={ decodeEntities( + record?.title?.rendered || __( '(no title)' ) + ) } actions={ - <SidebarButton - onClick={ () => setCanvasMode( 'edit' ) } - label={ __( 'Edit' ) } - icon={ pencil } - /> + <> + <PageActions + postId={ postId } + toggleProps={ { as: SidebarButton } } + onRemove={ () => { + navigator.goTo( '/page' ); + } } + /> + <SidebarButton + onClick={ () => setCanvasMode( 'edit' ) } + label={ __( 'Edit' ) } + icon={ pencil } + /> + </> + } + meta={ + <ExternalLink + className="edit-site-sidebar-navigation-screen__page-link" + href={ record.link } + > + { filterURLForDisplay( + safeDecodeURIComponent( record.link ) + ) } + </ExternalLink> } - description={ __( - 'Pages are static and are not listed by date. Pages do not use tags or categories.' - ) } content={ <> - { record?.link ? ( - <ExternalLink - className="edit-site-sidebar-navigation-screen__page-link" - href={ record.link } + { !! featuredMediaSourceUrl && ( + <VStack + className="edit-site-sidebar-navigation-screen-page__featured-image-wrapper" + alignment="left" + spacing={ 2 } + > + <div className="edit-site-sidebar-navigation-screen-page__featured-image has-image"> + <img + alt={ featureImageAltText } + src={ featuredMediaSourceUrl } + /> + </div> + </VStack> + ) } + { !! record?.excerpt?.rendered && ( + <Truncate + className="edit-site-sidebar-navigation-screen-page__excerpt" + numberOfLines={ 3 } > - { record.link } - </ExternalLink> - ) : null } - { record - ? decodeEntities( record?.description?.rendered ) - : null } + { stripHTML( record.excerpt.rendered ) } + </Truncate> + ) } + <PageDetails id={ postId } /> </> } + footer={ + <SidebarNavigationScreenDetailsFooter + lastModifiedDateTime={ record?.modified } + /> + } /> - ); + ) : null; } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/page-details.js b/packages/edit-site/src/components/sidebar-navigation-screen-page/page-details.js new file mode 100644 index 00000000000000..47c435a54e0caa --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/page-details.js @@ -0,0 +1,171 @@ +/** + * WordPress dependencies + */ +import { __, _x, sprintf } from '@wordpress/i18n'; +import { __experimentalTruncate as Truncate } from '@wordpress/components'; +import { count as wordCount } from '@wordpress/wordcount'; +import { useSelect } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { store as coreStore, useEntityRecord } from '@wordpress/core-data'; +import { safeDecodeURIComponent } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import StatusLabel from './status-label'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import { + SidebarNavigationScreenDetailsPanel, + SidebarNavigationScreenDetailsPanelRow, + SidebarNavigationScreenDetailsPanelLabel, + SidebarNavigationScreenDetailsPanelValue, +} from '../sidebar-navigation-screen-details-panel'; + +// Taken from packages/editor/src/components/time-to-read/index.js. +const AVERAGE_READING_RATE = 189; + +function getPageDetails( page ) { + if ( ! page ) { + return []; + } + + const details = [ + { + label: __( 'Status' ), + value: ( + <StatusLabel + status={ page?.password ? 'protected' : page.status } + date={ page?.date } + short + /> + ), + }, + { + label: __( 'Slug' ), + value: ( + <Truncate numberOfLines={ 1 }> + { safeDecodeURIComponent( page.slug ) } + </Truncate> + ), + }, + ]; + + if ( page?.templateTitle ) { + details.push( { + label: __( 'Template' ), + value: decodeEntities( page.templateTitle ), + } ); + } + + if ( page?.parentTitle ) { + details.push( { + label: __( 'Parent' ), + value: decodeEntities( page.parentTitle || __( '(no title)' ) ), + } ); + } + + /* + * translators: If your word count is based on single characters (e.g. East Asian characters), + * enter 'characters_excluding_spaces' or 'characters_including_spaces'. Otherwise, enter 'words'. + * Do not translate into your own language. + */ + const wordCountType = _x( 'words', 'Word count type. Do not translate!' ); + const wordsCounted = page?.content?.rendered + ? wordCount( page.content.rendered, wordCountType ) + : 0; + const readingTime = Math.round( wordsCounted / AVERAGE_READING_RATE ); + + if ( wordsCounted && ! page?.isPostsPage ) { + details.push( + { + label: __( 'Words' ), + value: wordsCounted.toLocaleString() || __( 'Unknown' ), + }, + { + label: __( 'Time to read' ), + value: + readingTime > 1 + ? sprintf( + /* translators: %s: is the number of minutes. */ + __( '%s mins' ), + readingTime.toLocaleString() + ) + : __( '< 1 min' ), + } + ); + } + return details; +} + +export default function PageDetails( { id } ) { + const { record } = useEntityRecord( 'postType', 'page', id ); + const { parentTitle, templateTitle, isPostsPage } = useSelect( + ( select ) => { + const { getEditedPostContext } = unlock( select( editSiteStore ) ); + const postContext = getEditedPostContext(); + const templates = select( coreStore ).getEntityRecords( + 'postType', + 'wp_template', + { per_page: -1 } + ); + // Template title. + const templateSlug = + // Checks that the post type matches the current theme's post type, otherwise + // the templateSlug returns 'home'. + postContext?.postType === 'page' + ? postContext?.templateSlug + : null; + const _templateTitle = + templates && templateSlug + ? templates.find( + ( template ) => template.slug === templateSlug + )?.title?.rendered + : null; + + // Parent page title. + const _parentTitle = record?.parent + ? select( coreStore ).getEntityRecord( + 'postType', + 'page', + record.parent, + { + _fields: [ 'title' ], + } + )?.title?.rendered + : null; + + const { getEntityRecord } = select( coreStore ); + const siteSettings = getEntityRecord( 'root', 'site' ); + + return { + parentTitle: _parentTitle, + templateTitle: _templateTitle, + isPostsPage: record?.id === siteSettings?.page_for_posts, + }; + }, + [ record?.parent, record?.id ] + ); + return ( + <SidebarNavigationScreenDetailsPanel + spacing={ 5 } + title={ __( 'Details' ) } + > + { getPageDetails( { + parentTitle, + templateTitle, + isPostsPage, + ...record, + } ).map( ( { label, value } ) => ( + <SidebarNavigationScreenDetailsPanelRow key={ label }> + <SidebarNavigationScreenDetailsPanelLabel> + { label } + </SidebarNavigationScreenDetailsPanelLabel> + <SidebarNavigationScreenDetailsPanelValue> + { value } + </SidebarNavigationScreenDetailsPanelValue> + </SidebarNavigationScreenDetailsPanelRow> + ) ) } + </SidebarNavigationScreenDetailsPanel> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/status-label.js b/packages/edit-site/src/components/sidebar-navigation-screen-page/status-label.js new file mode 100644 index 00000000000000..f864d48de33834 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/status-label.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { dateI18n, getDate, humanTimeDiff } from '@wordpress/date'; +import { createInterpolateElement } from '@wordpress/element'; + +export default function StatusLabel( { status, date, short } ) { + const relateToNow = humanTimeDiff( date ); + let statusLabel = status; + switch ( status ) { + case 'publish': + statusLabel = date + ? createInterpolateElement( + sprintf( + /* translators: %s: is the relative time when the post was published. */ + __( 'Published <time>%s</time>' ), + relateToNow + ), + { time: <time dateTime={ date } /> } + ) + : __( 'Published' ); + break; + case 'future': + const formattedDate = dateI18n( + short ? 'M j' : 'F j', + getDate( date ) + ); + statusLabel = date + ? createInterpolateElement( + sprintf( + /* translators: %s: is the formatted date and time on which the post is scheduled to be published. */ + __( 'Scheduled: <time>%s</time>' ), + formattedDate + ), + { time: <time dateTime={ date } /> } + ) + : __( 'Scheduled' ); + break; + case 'draft': + statusLabel = __( 'Draft' ); + break; + case 'pending': + statusLabel = __( 'Pending' ); + break; + case 'private': + statusLabel = __( 'Private' ); + break; + case 'protected': + statusLabel = __( 'Password protected' ); + break; + } + + return ( + <div + className={ classnames( + 'edit-site-sidebar-navigation-screen-page__status', + { + [ `has-status has-${ status }-status` ]: !! status, + } + ) } + > + { statusLabel } + </div> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss new file mode 100644 index 00000000000000..66efe31726e8b7 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss @@ -0,0 +1,63 @@ +.edit-site-sidebar-navigation-screen-page__featured-image-wrapper { + background-color: $gray-800; + margin-bottom: $grid-unit-20; + min-height: 128px; + border-radius: $grid-unit-05; +} + +.edit-site-sidebar-navigation-screen-page__featured-image { + border-radius: 2px; + height: 128px; + overflow: hidden; + width: 100%; + background-size: cover; + background-position: 50% 50%; + display: flex; + align-items: center; + justify-content: center; + color: $gray-600; + img { + object-fit: cover; + height: 100%; + width: 100%; + object-position: 50% 50%; + } +} + +.edit-site-sidebar-navigation-screen-page__featured-image-description { + font-size: $helptext-font-size; +} + +.edit-site-sidebar-navigation-screen-page__excerpt { + font-size: $helptext-font-size; + margin-bottom: $grid-unit-30; +} + +.edit-site-sidebar-navigation-screen-page__modified { + margin: 0 0 $grid-unit-20 0; + color: $gray-600; + margin-left: $grid-unit-20; + .components-text { + color: $gray-600; + } +} + +.edit-site-sidebar-navigation-screen-page__status { + display: inline-flex; + + time { + display: contents; + } + + svg { + height: 16px; + width: 16px; + margin-right: $grid-unit-10; + fill: $alert-yellow; + } + + &.has-publish-status svg, + &.has-future-status svg { + fill: $alert-green; + } +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js index 5f4958f44d2514..13e5f9c3cb3266 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js @@ -4,10 +4,16 @@ import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, + __experimentalTruncate as Truncate, + __experimentalVStack as VStack, } from '@wordpress/components'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { useEntityRecords } from '@wordpress/core-data'; +import { useEntityRecords, store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { layout, page, home, verse, plus } from '@wordpress/icons'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -15,68 +21,214 @@ import { decodeEntities } from '@wordpress/html-entities'; import SidebarNavigationScreen from '../sidebar-navigation-screen'; import { useLink } from '../routes/link'; import SidebarNavigationItem from '../sidebar-navigation-item'; -import SidebarNavigationSubtitle from '../sidebar-navigation-subtitle'; +import SidebarButton from '../sidebar-button'; +import AddNewPageModal from '../add-new-page'; +import { unlock } from '../../lock-unlock'; -const PageItem = ( { postId, ...props } ) => { - const linkInfo = useLink( { - postType: 'page', - postId, - } ); +const { useHistory } = unlock( routerPrivateApis ); + +const PageItem = ( { postType = 'page', postId, ...props } ) => { + const linkInfo = useLink( + { + postType, + postId, + }, + { + backPath: '/page', + } + ); return <SidebarNavigationItem { ...linkInfo } { ...props } />; }; export default function SidebarNavigationScreenPages() { - const { records: pages, isResolving: isLoading } = useEntityRecords( + const { records: pages, isResolving: isLoadingPages } = useEntityRecords( 'postType', - 'page' + 'page', + { + status: 'any', + per_page: -1, + } + ); + const { records: templates, isResolving: isLoadingTemplates } = + useEntityRecords( 'postType', 'wp_template', { + per_page: -1, + } ); + + const dynamicPageTemplates = templates?.filter( ( { slug } ) => + [ '404', 'search' ].includes( slug ) ); + const homeTemplate = + templates?.find( ( template ) => template.slug === 'front-page' ) || + templates?.find( ( template ) => template.slug === 'home' ) || + templates?.find( ( template ) => template.slug === 'index' ); + + const getPostsPageTemplate = () => + templates?.find( ( template ) => template.slug === 'home' ) || + templates?.find( ( template ) => template.slug === 'index' ); + + const pagesAndTemplates = pages?.concat( dynamicPageTemplates, [ + homeTemplate, + ] ); + + const { frontPage, postsPage } = useSelect( ( select ) => { + const { getEntityRecord } = select( coreStore ); + const siteSettings = getEntityRecord( 'root', 'site' ); + return { + frontPage: siteSettings?.page_on_front, + postsPage: siteSettings?.page_for_posts, + }; + }, [] ); + + const isHomePageBlog = frontPage === postsPage; + + const reorderedPages = pages && [ ...pages ]; + + if ( ! isHomePageBlog && reorderedPages?.length ) { + const homePageIndex = reorderedPages.findIndex( + ( item ) => item.id === frontPage + ); + const homePage = reorderedPages.splice( homePageIndex, 1 ); + reorderedPages?.splice( 0, 0, ...homePage ); + + const postsPageIndex = reorderedPages.findIndex( + ( item ) => item.id === postsPage + ); + + const blogPage = reorderedPages.splice( postsPageIndex, 1 ); + + reorderedPages.splice( 1, 0, ...blogPage ); + } + + const [ showAddPage, setShowAddPage ] = useState( false ); + + const history = useHistory(); + + const handleNewPage = ( { type, id } ) => { + // Navigate to the created template editor. + history.push( { + postId: id, + postType: type, + canvas: 'edit', + } ); + setShowAddPage( false ); + }; + + const getPageProps = ( id ) => { + let itemIcon = page; + const postsPageTemplateId = + postsPage && postsPage === id ? getPostsPageTemplate()?.id : null; + + switch ( id ) { + case frontPage: + itemIcon = home; + break; + case postsPage: + itemIcon = verse; + break; + } + + return { + icon: itemIcon, + postType: postsPageTemplateId ? 'wp_template' : 'page', + postId: postsPageTemplateId || id, + }; + }; + return ( - <SidebarNavigationScreen - title={ __( 'Pages' ) } - description={ __( 'Browse and edit pages on your site.' ) } - content={ - <> - { isLoading && ( - <ItemGroup> - <Item>{ __( 'Loading pages' ) }</Item> - </ItemGroup> - ) } - { ! isLoading && ( - <> - <SidebarNavigationSubtitle> - { __( 'Recent' ) } - </SidebarNavigationSubtitle> + <> + { showAddPage && ( + <AddNewPageModal + onSave={ handleNewPage } + onClose={ () => setShowAddPage( false ) } + /> + ) } + <SidebarNavigationScreen + title={ __( 'Pages' ) } + description={ __( 'Browse and edit pages on your site.' ) } + actions={ + <SidebarButton + icon={ plus } + label={ __( 'Draft a new page' ) } + onClick={ () => setShowAddPage( true ) } + /> + } + content={ + <> + { ( isLoadingPages || isLoadingTemplates ) && ( <ItemGroup> - { ! pages?.length && ( + <Item>{ __( 'Loading pages…' ) }</Item> + </ItemGroup> + ) } + { ! ( isLoadingPages || isLoadingTemplates ) && ( + <ItemGroup> + { ! pagesAndTemplates?.length && ( <Item>{ __( 'No page found' ) }</Item> ) } - { pages?.map( ( page ) => ( + { isHomePageBlog && homeTemplate && ( + <PageItem + postType="wp_template" + postId={ homeTemplate.id } + key={ homeTemplate.id } + icon={ home } + withChevron + > + <Truncate numberOfLines={ 1 }> + { decodeEntities( + homeTemplate.title?.rendered || + __( '(no title)' ) + ) } + </Truncate> + </PageItem> + ) } + { reorderedPages?.map( ( { id, title } ) => ( <PageItem - postId={ page.id } - key={ page.id } + { ...getPageProps( id ) } + key={ id } withChevron > - { decodeEntities( - page.title?.rendered - ) ?? __( '(no title)' ) } + <Truncate numberOfLines={ 1 }> + { decodeEntities( + title?.rendered || + __( '(no title)' ) + ) } + </Truncate> </PageItem> ) ) } - <SidebarNavigationItem - className="edit-site-sidebar-navigation-screen-pages__see-all" - href="edit.php?post_type=page" - onClick={ () => { - document.location = - 'edit.php?post_type=page'; - } } - > - { __( 'Manage all pages' ) } - </SidebarNavigationItem> </ItemGroup> - </> - ) } - </> - } - /> + ) } + </> + } + footer={ + <VStack spacing={ 0 }> + { dynamicPageTemplates?.map( ( item ) => ( + <PageItem + postType="wp_template" + postId={ item.id } + key={ item.id } + icon={ layout } + withChevron + > + <Truncate numberOfLines={ 1 }> + { decodeEntities( + item.title?.rendered || + __( '(no title)' ) + ) } + </Truncate> + </PageItem> + ) ) } + <SidebarNavigationItem + className="edit-site-sidebar-navigation-screen-pages__see-all" + href="edit.php?post_type=page" + onClick={ () => { + document.location = 'edit.php?post_type=page'; + } } + > + { __( 'Manage all pages' ) } + </SidebarNavigationItem> + </VStack> + } + /> + </> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss deleted file mode 100644 index 7bbdd103b6bcea..00000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss +++ /dev/null @@ -1,4 +0,0 @@ -.edit-site-sidebar-navigation-screen-pages__see-all { - /* Overrides the margin that comes from the Item component */ - margin-top: $grid-unit-20 !important; -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js new file mode 100644 index 00000000000000..39f28dba6d5204 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { __experimentalUseNavigator as useNavigator } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { pencil } from '@wordpress/icons'; +import { getQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import SidebarButton from '../sidebar-button'; +import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import useInitEditedEntityFromURL from '../sync-state-with-url/use-init-edited-entity-from-url'; +import usePatternDetails from './use-pattern-details'; +import { store as editSiteStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +export default function SidebarNavigationScreenPattern() { + const { params } = useNavigator(); + const { categoryType } = getQueryArgs( window.location.href ); + const { postType, postId } = params; + const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); + + useInitEditedEntityFromURL(); + + const patternDetails = usePatternDetails( postType, postId ); + + // The absence of a category type in the query params for template parts + // indicates the user has arrived at the template part via the "manage all" + // page and the back button should return them to that list page. + const backPath = + ! categoryType && postType === 'wp_template_part' + ? '/wp_template_part/all' + : '/patterns'; + + return ( + <SidebarNavigationScreen + actions={ + <SidebarButton + onClick={ () => setCanvasMode( 'edit' ) } + label={ __( 'Edit' ) } + icon={ pencil } + /> + } + backPath={ backPath } + { ...patternDetails } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/style.scss new file mode 100644 index 00000000000000..a092ac50935b67 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/style.scss @@ -0,0 +1,29 @@ +.edit-site-sidebar-navigation-screen-pattern__added-by-description { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: $grid-unit-30; +} + +.edit-site-sidebar-navigation-screen-pattern__added-by-description-author { + display: inline-flex; + align-items: center; + + img { + border-radius: $grid-unit-15; + } + + svg { + fill: $gray-600; + } +} + +.edit-site-sidebar-navigation-screen-pattern__added-by-description-author-icon { + width: 24px; + height: 24px; + margin-right: $grid-unit-10; +} + +.edit-site-sidebar-navigation-screen-pattern__lock-icon { + display: inline-flex; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu-list-item.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu-list-item.js new file mode 100644 index 00000000000000..b685c766107a32 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu-list-item.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { useEntityProp } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import SidebarNavigationItem from '../sidebar-navigation-item'; +import { useLink } from '../routes/link'; + +export default function TemplatePartNavigationMenuListItem( { id } ) { + const [ title ] = useEntityProp( 'postType', 'wp_navigation', 'title', id ); + + const linkInfo = useLink( { + postId: id, + postType: 'wp_navigation', + } ); + + if ( ! id ) return null; + + return ( + <SidebarNavigationItem withChevron { ...linkInfo }> + { title || __( '(no title)' ) } + </SidebarNavigationItem> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu-list.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu-list.js new file mode 100644 index 00000000000000..4171b1e782575e --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu-list.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __experimentalItemGroup as ItemGroup } from '@wordpress/components'; +/** + * Internal dependencies + */ +import TemplatePartNavigationMenuListItem from './template-part-navigation-menu-list-item'; + +export default function TemplatePartNavigationMenuList( { menus } ) { + return ( + <ItemGroup className="edit-site-sidebar-navigation-screen-template-part-navigation-menu-list"> + { menus.map( ( menuId ) => ( + <TemplatePartNavigationMenuListItem + key={ menuId } + id={ menuId } + /> + ) ) } + </ItemGroup> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu.js new file mode 100644 index 00000000000000..a124c4163fc54c --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menu.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { __experimentalHeading as Heading } from '@wordpress/components'; +import { useEntityProp } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import NavigationMenuEditor from '../sidebar-navigation-screen-navigation-menu/navigation-menu-editor'; + +export default function TemplatePartNavigationMenu( { id } ) { + const [ title ] = useEntityProp( 'postType', 'wp_navigation', 'title', id ); + + if ( ! id ) return null; + + return ( + <> + <Heading + className="edit-site-sidebar-navigation-screen-template-part-navigation-menu__title" + size="11" + upperCase={ true } + weight={ 500 } + > + { title || __( 'Navigation' ) } + </Heading> + <NavigationMenuEditor navigationMenuId={ id } /> + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menus.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menus.js new file mode 100644 index 00000000000000..d04eba2e435b45 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/template-part-navigation-menus.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { __experimentalHeading as Heading } from '@wordpress/components'; +/** + * Internal dependencies + */ +import TemplatePartNavigationMenu from './template-part-navigation-menu'; +import TemplatePartNavigationMenuList from './template-part-navigation-menu-list'; + +export default function TemplatePartNavigationMenus( { menus } ) { + if ( ! menus.length ) return null; + + // if there is a single menu then render TemplatePartNavigationMenu + if ( menus.length === 1 ) { + return <TemplatePartNavigationMenu id={ menus[ 0 ] } />; + } + + // if there are multiple menus then render TemplatePartNavigationMenuList + return ( + <> + <Heading + className="edit-site-sidebar-navigation-screen-template-part-navigation-menu__title" + size="11" + upperCase={ true } + weight={ 500 } + > + { __( 'Navigation' ) } + </Heading> + <TemplatePartNavigationMenuList menus={ menus } /> + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/use-navigation-menu-content.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/use-navigation-menu-content.js new file mode 100644 index 00000000000000..0a5de0a6274d4d --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/use-navigation-menu-content.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { parse } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import TemplatePartNavigationMenus from './template-part-navigation-menus'; +import useEditedEntityRecord from '../use-edited-entity-record'; + +/** + * Retrieves a list of specific blocks from a given tree of blocks. + * + * @param {string} targetBlockType The name of the block type to find. + * @param {Array} blocks A list of blocks from a template part entity. + * + * @return {Array} A list of any navigation blocks found in the blocks. + */ +function getBlocksOfTypeFromBlocks( targetBlockType, blocks ) { + if ( ! targetBlockType || ! blocks?.length ) { + return []; + } + + const findInBlocks = ( _blocks ) => { + if ( ! _blocks ) { + return []; + } + + const navigationBlocks = []; + + for ( const block of _blocks ) { + if ( block.name === targetBlockType ) { + navigationBlocks.push( block ); + } + + if ( block?.innerBlocks ) { + const innerNavigationBlocks = findInBlocks( block.innerBlocks ); + + if ( innerNavigationBlocks.length ) { + navigationBlocks.push( ...innerNavigationBlocks ); + } + } + } + + return navigationBlocks; + }; + + return findInBlocks( blocks ); +} + +export default function useNavigationMenuContent( postType, postId ) { + const { record } = useEditedEntityRecord( postType, postId ); + + // Only managing navigation menus in template parts is supported + // to match previous behaviour. This could potentially be expanded + // to patterns as well. + if ( postType !== 'wp_template_part' ) { + return; + } + + const blocks = + record?.content && typeof record.content !== 'function' + ? parse( record.content ) + : []; + + const navigationBlocks = getBlocksOfTypeFromBlocks( + 'core/navigation', + blocks + ); + + if ( ! navigationBlocks.length ) { + return; + } + + const navigationMenuIds = navigationBlocks?.map( + ( block ) => block.attributes.ref + ); + + // Dedupe the Navigation blocks, as you can have multiple navigation blocks in the template. + // Also, filter out undefined values, as blocks don't have an id when initially added. + const uniqueNavigationMenuIds = [ ...new Set( navigationMenuIds ) ].filter( + ( menuId ) => menuId + ); + + if ( ! uniqueNavigationMenuIds?.length ) { + return; + } + + return <TemplatePartNavigationMenus menus={ uniqueNavigationMenuIds } />; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/use-pattern-details.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/use-pattern-details.js new file mode 100644 index 00000000000000..60fbb7161d160e --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/use-pattern-details.js @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { sentenceCase } from 'change-case'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useAddedBy } from '../list/added-by'; +import useEditedEntityRecord from '../use-edited-entity-record'; +import useNavigationMenuContent from './use-navigation-menu-content'; +import SidebarNavigationScreenDetailsFooter from '../sidebar-navigation-screen-details-footer'; +import { + SidebarNavigationScreenDetailsPanel, + SidebarNavigationScreenDetailsPanelRow, + SidebarNavigationScreenDetailsPanelLabel, + SidebarNavigationScreenDetailsPanelValue, +} from '../sidebar-navigation-screen-details-panel'; + +export default function usePatternDetails( postType, postId ) { + const { getDescription, getTitle, record } = useEditedEntityRecord( + postType, + postId + ); + const templatePartAreas = useSelect( + ( select ) => + select( editorStore ).__experimentalGetDefaultTemplatePartAreas(), + [] + ); + const currentTheme = useSelect( + ( select ) => select( coreStore ).getCurrentTheme(), + [] + ); + const addedBy = useAddedBy( postType, postId ); + const isAddedByActiveTheme = + addedBy.type === 'theme' && record.theme === currentTheme?.stylesheet; + const title = getTitle(); + let description = getDescription(); + + if ( ! description && addedBy.text ) { + description = sprintf( + // translators: %s: pattern title e.g: "Header". + __( 'This is the %s pattern.' ), + getTitle() + ); + } + + if ( ! description && postType === 'wp_block' && record?.title ) { + description = sprintf( + // translators: %s: user created pattern title e.g. "Footer". + __( 'This is the %s pattern.' ), + record.title + ); + } + + const footer = !! record?.modified ? ( + <SidebarNavigationScreenDetailsFooter + lastModifiedDateTime={ record.modified } + /> + ) : null; + + const details = []; + + if ( postType === 'wp_block' || 'wp_template_part' ) { + details.push( { + label: __( 'Syncing' ), + value: + record.wp_pattern_sync_status === 'unsynced' + ? __( 'Not synced' ) + : __( 'Fully synced' ), + } ); + } + + if ( postType === 'wp_template_part' ) { + const templatePartArea = templatePartAreas.find( + ( area ) => area.area === record.area + ); + + let areaDetailValue = templatePartArea?.label; + + if ( ! areaDetailValue ) { + areaDetailValue = record.area + ? sprintf( + // translators: %s: Sentenced cased template part area e.g: "My custom area". + __( '%s (removed)' ), + sentenceCase( record.area ) + ) + : __( 'None' ); + } + + details.push( { label: __( 'Area' ), value: areaDetailValue } ); + } + + if ( + postType === 'wp_template_part' && + addedBy.text && + ! isAddedByActiveTheme + ) { + details.push( { + label: __( 'Added by' ), + value: ( + <span className="edit-site-sidebar-navigation-screen-pattern__added-by-description-author"> + { addedBy.text } + </span> + ), + } ); + } + + if ( + postType === 'wp_template_part' && + addedBy.text && + ( record.origin === 'plugin' || record.has_theme_file === true ) + ) { + details.push( { + label: __( 'Customized' ), + value: ( + <span className="edit-site-sidebar-navigation-screen-pattern__added-by-description-customized"> + { addedBy.isCustomized ? __( 'Yes' ) : __( 'No' ) } + </span> + ), + } ); + } + + const content = ( + <> + { useNavigationMenuContent( postType, postId ) } + { !! details.length && ( + <SidebarNavigationScreenDetailsPanel + spacing={ 5 } + title={ __( 'Details' ) } + > + { details.map( ( { label, value } ) => ( + <SidebarNavigationScreenDetailsPanelRow key={ label }> + <SidebarNavigationScreenDetailsPanelLabel> + { label } + </SidebarNavigationScreenDetailsPanelLabel> + <SidebarNavigationScreenDetailsPanelValue> + { value } + </SidebarNavigationScreenDetailsPanelValue> + </SidebarNavigationScreenDetailsPanelRow> + ) ) } + </SidebarNavigationScreenDetailsPanel> + ) } + </> + ); + + return { title, description, content, footer }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/category-item.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/category-item.js new file mode 100644 index 00000000000000..894cd5f8048183 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/category-item.js @@ -0,0 +1,35 @@ +/** + * Internal dependencies + */ +import SidebarNavigationItem from '../sidebar-navigation-item'; +import { useLink } from '../routes/link'; + +export default function CategoryItem( { + count, + icon, + id, + isActive, + label, + type, +} ) { + const linkInfo = useLink( { + path: '/patterns', + categoryType: type, + categoryId: id, + } ); + + if ( ! count ) { + return; + } + + return ( + <SidebarNavigationItem + { ...linkInfo } + icon={ icon } + suffix={ <span>{ count }</span> } + aria-current={ isActive ? 'true' : undefined } + > + { label } + </SidebarNavigationItem> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js new file mode 100644 index 00000000000000..3b6a6a8110f562 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js @@ -0,0 +1,186 @@ +/** + * WordPress dependencies + */ +import { + __experimentalItemGroup as ItemGroup, + __experimentalItem as Item, + Flex, + Icon, + Tooltip, + __experimentalHeading as Heading, +} from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { getTemplatePartIcon } from '@wordpress/editor'; +import { __, sprintf } from '@wordpress/i18n'; +import { getQueryArgs } from '@wordpress/url'; +import { file, starFilled, lockSmall } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import AddNewPattern from '../add-new-pattern'; +import SidebarNavigationItem from '../sidebar-navigation-item'; +import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import CategoryItem from './category-item'; +import { DEFAULT_CATEGORY, DEFAULT_TYPE } from '../page-patterns/utils'; +import { useLink } from '../routes/link'; +import usePatternCategories from './use-pattern-categories'; +import useMyPatterns from './use-my-patterns'; +import useTemplatePartAreas from './use-template-part-areas'; + +function TemplatePartGroup( { areas, currentArea, currentType } ) { + return ( + <> + <div className="edit-site-sidebar-navigation-screen-patterns__group-header"> + <Heading level={ 2 }>{ __( 'Template parts' ) }</Heading> + </div> + <ItemGroup className="edit-site-sidebar-navigation-screen-patterns__group"> + { Object.entries( areas ).map( + ( [ area, { label, templateParts } ] ) => ( + <CategoryItem + key={ area } + count={ templateParts?.length } + icon={ getTemplatePartIcon( area ) } + label={ label } + id={ area } + type="wp_template_part" + isActive={ + currentArea === area && + currentType === 'wp_template_part' + } + /> + ) + ) } + </ItemGroup> + </> + ); +} + +function ThemePatternsGroup( { categories, currentCategory, currentType } ) { + return ( + <> + <ItemGroup className="edit-site-sidebar-navigation-screen-patterns__group"> + { categories.map( ( category ) => ( + <CategoryItem + key={ category.name } + count={ category.count } + label={ + <Flex justify="left" align="center" gap={ 0 }> + { category.label } + <Tooltip + position="top center" + text={ sprintf( + // translators: %s: The pattern category name. + '"%s" patterns cannot be edited.', + category.label + ) } + > + <span className="edit-site-sidebar-navigation-screen-pattern__lock-icon"> + <Icon icon={ lockSmall } size={ 24 } /> + </span> + </Tooltip> + </Flex> + } + icon={ file } + id={ category.name } + type="pattern" + isActive={ + currentCategory === `${ category.name }` && + currentType === 'pattern' + } + /> + ) ) } + </ItemGroup> + </> + ); +} + +export default function SidebarNavigationScreenPatterns() { + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const { categoryType, categoryId } = getQueryArgs( window.location.href ); + const currentCategory = categoryId || DEFAULT_CATEGORY; + const currentType = categoryType || DEFAULT_TYPE; + + const { templatePartAreas, hasTemplateParts, isLoading } = + useTemplatePartAreas(); + const { patternCategories, hasPatterns } = usePatternCategories(); + const { myPatterns } = useMyPatterns(); + + const templatePartsLink = useLink( { path: '/wp_template_part/all' } ); + const footer = ! isMobileViewport ? ( + <ItemGroup> + <SidebarNavigationItem + as="a" + href="edit.php?post_type=wp_block" + withChevron + > + { __( 'Manage all of my patterns' ) } + </SidebarNavigationItem> + <SidebarNavigationItem withChevron { ...templatePartsLink }> + { __( 'Manage all template parts' ) } + </SidebarNavigationItem> + </ItemGroup> + ) : undefined; + + return ( + <SidebarNavigationScreen + title={ __( 'Patterns' ) } + description={ __( + 'Manage what patterns are available when editing the site.' + ) } + actions={ <AddNewPattern /> } + footer={ footer } + content={ + <> + { isLoading && __( 'Loading patterns…' ) } + { ! isLoading && ( + <> + { ! hasTemplateParts && ! hasPatterns && ( + <ItemGroup className="edit-site-sidebar-navigation-screen-patterns__group"> + <Item> + { __( + 'No template parts or patterns found' + ) } + </Item> + </ItemGroup> + ) } + <ItemGroup className="edit-site-sidebar-navigation-screen-patterns__group"> + <CategoryItem + key={ myPatterns.name } + count={ + ! myPatterns.count + ? '0' + : myPatterns.count + } + label={ myPatterns.label } + icon={ starFilled } + id={ myPatterns.name } + type="wp_block" + isActive={ + currentCategory === + `${ myPatterns.name }` && + currentType === 'wp_block' + } + /> + </ItemGroup> + { hasPatterns && ( + <ThemePatternsGroup + categories={ patternCategories } + currentCategory={ currentCategory } + currentType={ currentType } + /> + ) } + { hasTemplateParts && ( + <TemplatePartGroup + areas={ templatePartAreas } + currentArea={ currentCategory } + currentType={ currentType } + /> + ) } + </> + ) } + </> + } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss new file mode 100644 index 00000000000000..7b448c375fb404 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss @@ -0,0 +1,23 @@ +.edit-site-sidebar-navigation-screen-patterns__group { + margin-bottom: $grid-unit-30; + + &:last-of-type { + border-bottom: 0; + padding-bottom: 0; + margin-bottom: 0; + } +} + +.edit-site-sidebar-navigation-screen-patterns__group-header { + margin-top: $grid-unit-20; + + p { + color: $gray-600; + } + + h2 { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + } +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-default-pattern-categories.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-default-pattern-categories.js new file mode 100644 index 00000000000000..014d0e2e65b0ce --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-default-pattern-categories.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; + +export default function useDefaultPatternCategories() { + const blockPatternCategories = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + const settings = getSettings(); + + return ( + settings.__experimentalAdditionalBlockPatternCategories ?? + settings.__experimentalBlockPatternCategories + ); + } ); + + const restBlockPatternCategories = useSelect( ( select ) => + select( coreStore ).getBlockPatternCategories() + ); + + return [ + ...( blockPatternCategories || [] ), + ...( restBlockPatternCategories || [] ), + ]; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js new file mode 100644 index 00000000000000..37f0b0f8a4e063 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +export default function useMyPatterns() { + const myPatternsCount = useSelect( + ( select ) => + select( coreStore ).getEntityRecords( 'postType', 'wp_block', { + per_page: -1, + } )?.length ?? 0 + ); + + return { + myPatterns: { + count: myPatternsCount, + name: 'my-patterns', + label: __( 'My patterns' ), + }, + hasPatterns: myPatternsCount > 0, + }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js new file mode 100644 index 00000000000000..da4732f5be4487 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import useDefaultPatternCategories from './use-default-pattern-categories'; +import useThemePatterns from './use-theme-patterns'; + +export default function usePatternCategories() { + const defaultCategories = useDefaultPatternCategories(); + defaultCategories.push( { + name: 'uncategorized', + label: __( 'Uncategorized' ), + } ); + const themePatterns = useThemePatterns(); + + const patternCategories = useMemo( () => { + const categoryMap = {}; + const categoriesWithCounts = []; + + // Create a map for easier counting of patterns in categories. + defaultCategories.forEach( ( category ) => { + if ( ! categoryMap[ category.name ] ) { + categoryMap[ category.name ] = { ...category, count: 0 }; + } + } ); + + // Update the category counts to reflect theme registered patterns. + themePatterns.forEach( ( pattern ) => { + pattern.categories?.forEach( ( category ) => { + if ( categoryMap[ category ] ) { + categoryMap[ category ].count += 1; + } + } ); + // If the pattern has no categories, add it to uncategorized. + if ( ! pattern.categories?.length ) { + categoryMap.uncategorized.count += 1; + } + } ); + + // Filter categories so we only have those containing patterns. + defaultCategories.forEach( ( category ) => { + if ( categoryMap[ category.name ].count ) { + categoriesWithCounts.push( categoryMap[ category.name ] ); + } + } ); + + return categoriesWithCounts; + }, [ defaultCategories, themePatterns ] ); + + return { patternCategories, hasPatterns: !! patternCategories.length }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js new file mode 100644 index 00000000000000..bc538c5e7a85fa --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { useEntityRecords } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; + +const useTemplatePartsGroupedByArea = ( items ) => { + const allItems = items || []; + + const templatePartAreas = useSelect( + ( select ) => + select( editorStore ).__experimentalGetDefaultTemplatePartAreas(), + [] + ); + + // Create map of template areas ensuring that default areas are displayed before + // any custom registered template part areas. + const knownAreas = { + header: {}, + footer: {}, + sidebar: {}, + uncategorized: {}, + }; + + templatePartAreas.forEach( + ( templatePartArea ) => + ( knownAreas[ templatePartArea.area ] = { + ...templatePartArea, + templateParts: [], + } ) + ); + + const groupedByArea = allItems.reduce( ( accumulator, item ) => { + const key = accumulator[ item.area ] ? item.area : 'uncategorized'; + accumulator[ key ].templateParts.push( item ); + return accumulator; + }, knownAreas ); + + return groupedByArea; +}; + +export default function useTemplatePartAreas() { + const { records: templateParts, isResolving: isLoading } = useEntityRecords( + 'postType', + 'wp_template_part', + { per_page: -1 } + ); + + return { + hasTemplateParts: templateParts ? !! templateParts.length : false, + isLoading, + templatePartAreas: useTemplatePartsGroupedByArea( templateParts ), + }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-theme-patterns.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-theme-patterns.js new file mode 100644 index 00000000000000..bf6decf3414222 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-theme-patterns.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + CORE_PATTERN_SOURCES, + filterOutDuplicatesByName, +} from '../page-patterns/utils'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; + +export default function useThemePatterns() { + const blockPatterns = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + + return ( + getSettings().__experimentalAdditionalBlockPatterns ?? + getSettings().__experimentalBlockPatterns + ); + } ); + + const restBlockPatterns = useSelect( ( select ) => + select( coreStore ).getBlockPatterns() + ); + + const patterns = useMemo( + () => + [ ...( blockPatterns || [] ), ...( restBlockPatterns || [] ) ] + .filter( + ( pattern ) => + ! CORE_PATTERN_SOURCES.includes( pattern.source ) + ) + .filter( filterOutDuplicatesByName ) + .filter( ( pattern ) => pattern.inserter !== false ), + [ blockPatterns, restBlockPatterns ] + ); + + return patterns; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js b/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js new file mode 100644 index 00000000000000..5c25f7768dee0f --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js @@ -0,0 +1,232 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { debounce } from '@wordpress/compose'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { + CheckboxControl, + __experimentalUseNavigator as useNavigator, + __experimentalInputControl as InputControl, + __experimentalNumberControl as NumberControl, + __experimentalTruncate as Truncate, + __experimentalItemGroup as ItemGroup, +} from '@wordpress/components'; +import { header, footer, layout } from '@wordpress/icons'; +import { useMemo, useState, useEffect } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { + SidebarNavigationScreenDetailsPanel, + SidebarNavigationScreenDetailsPanelRow, +} from '../sidebar-navigation-screen-details-panel'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import { useLink } from '../routes/link'; +import SidebarNavigationItem from '../sidebar-navigation-item'; + +const EMPTY_OBJECT = {}; + +function TemplateAreaButton( { postId, icon, title } ) { + const icons = { + header, + footer, + }; + const linkInfo = useLink( { + postType: 'wp_template_part', + postId, + } ); + + return ( + <SidebarNavigationItem + className="edit-site-sidebar-navigation-screen-template__template-area-button" + { ...linkInfo } + icon={ icons[ icon ] ?? layout } + withChevron + > + <Truncate + limit={ 20 } + ellipsizeMode="tail" + numberOfLines={ 1 } + className="edit-site-sidebar-navigation-screen-template__template-area-label-text" + > + { decodeEntities( title ) } + </Truncate> + </SidebarNavigationItem> + ); +} + +export default function HomeTemplateDetails() { + const navigator = useNavigator(); + const { + params: { postType, postId }, + } = navigator; + const { editEntityRecord } = useDispatch( coreStore ); + + const { + allowCommentsOnNewPosts, + templatePartAreas, + postsPerPage, + postsPageTitle, + postsPageId, + currentTemplateParts, + } = useSelect( + ( select ) => { + const { getEntityRecord } = select( coreStore ); + const siteSettings = getEntityRecord( 'root', 'site' ); + const { getSettings } = unlock( select( editSiteStore ) ); + const _currentTemplateParts = + select( editSiteStore ).getCurrentTemplateTemplateParts(); + const siteEditorSettings = getSettings(); + const _postsPageRecord = siteSettings?.page_for_posts + ? select( coreStore ).getEntityRecord( + 'postType', + 'page', + siteSettings?.page_for_posts + ) + : EMPTY_OBJECT; + + return { + allowCommentsOnNewPosts: + siteSettings?.default_comment_status === 'open', + postsPageTitle: _postsPageRecord?.title?.rendered, + postsPageId: _postsPageRecord?.id, + postsPerPage: siteSettings?.posts_per_page, + templatePartAreas: siteEditorSettings?.defaultTemplatePartAreas, + currentTemplateParts: _currentTemplateParts, + }; + }, + [ postType, postId ] + ); + + const [ commentsOnNewPostsValue, setCommentsOnNewPostsValue ] = + useState( '' ); + const [ postsCountValue, setPostsCountValue ] = useState( 1 ); + const [ postsPageTitleValue, setPostsPageTitleValue ] = useState( '' ); + + /* + * This hook serves to set the server-retrieved values, + * postsPageTitle, allowCommentsOnNewPosts, postsPerPage, + * to local state. + */ + useEffect( () => { + setCommentsOnNewPostsValue( allowCommentsOnNewPosts ); + setPostsPageTitleValue( postsPageTitle ); + setPostsCountValue( postsPerPage ); + }, [ postsPageTitle, allowCommentsOnNewPosts, postsPerPage ] ); + + /* + * Merge data in currentTemplateParts with templatePartAreas, + * which contains the template icon and fallback labels + */ + const templateAreas = useMemo( () => { + return currentTemplateParts.length && templatePartAreas + ? currentTemplateParts.map( ( { templatePart } ) => ( { + ...templatePartAreas?.find( + ( { area } ) => area === templatePart?.area + ), + ...templatePart, + } ) ) + : []; + }, [ currentTemplateParts, templatePartAreas ] ); + + const setAllowCommentsOnNewPosts = ( newValue ) => { + setCommentsOnNewPostsValue( newValue ); + editEntityRecord( 'root', 'site', undefined, { + default_comment_status: newValue ? 'open' : null, + } ); + }; + + const setPostsPageTitle = ( newValue ) => { + setPostsPageTitleValue( newValue ); + editEntityRecord( 'postType', 'page', postsPageId, { + title: newValue, + } ); + }; + + const setPostsPerPage = ( newValue ) => { + setPostsCountValue( newValue ); + editEntityRecord( 'root', 'site', undefined, { + posts_per_page: newValue, + } ); + }; + + return ( + <> + <SidebarNavigationScreenDetailsPanel spacing={ 6 }> + { postsPageId && ( + <SidebarNavigationScreenDetailsPanelRow> + <InputControl + className="edit-site-sidebar-navigation-screen__input-control" + placeholder={ __( 'No Title' ) } + size={ '__unstable-large' } + value={ postsPageTitleValue } + onChange={ debounce( setPostsPageTitle, 300 ) } + label={ __( 'Blog title' ) } + help={ __( + 'Set the Posts Page title. Appears in search results, and when the page is shared on social media.' + ) } + /> + </SidebarNavigationScreenDetailsPanelRow> + ) } + <SidebarNavigationScreenDetailsPanelRow> + <NumberControl + className="edit-site-sidebar-navigation-screen__input-control" + placeholder={ 0 } + value={ postsCountValue } + size={ '__unstable-large' } + spinControls="custom" + step="1" + min="1" + onChange={ setPostsPerPage } + label={ __( 'Posts per page' ) } + help={ __( + 'Set the default number of posts to display on blog pages, including categories and tags. Some templates may override this setting.' + ) } + /> + </SidebarNavigationScreenDetailsPanelRow> + </SidebarNavigationScreenDetailsPanel> + + <SidebarNavigationScreenDetailsPanel + title={ __( 'Discussion' ) } + spacing={ 3 } + > + <SidebarNavigationScreenDetailsPanelRow> + <CheckboxControl + className="edit-site-sidebar-navigation-screen__input-control" + label={ __( 'Allow comments on new posts' ) } + help={ __( + 'Changes will apply to new posts only. Individual posts may override these settings.' + ) } + checked={ commentsOnNewPostsValue } + onChange={ setAllowCommentsOnNewPosts } + /> + </SidebarNavigationScreenDetailsPanelRow> + </SidebarNavigationScreenDetailsPanel> + <SidebarNavigationScreenDetailsPanel + title={ __( 'Areas' ) } + spacing={ 3 } + > + <ItemGroup> + { templateAreas.map( + ( { label, icon, theme, slug, title } ) => ( + <SidebarNavigationScreenDetailsPanelRow + key={ slug } + > + <TemplateAreaButton + postId={ `${ theme }//${ slug }` } + title={ title?.rendered || label } + icon={ icon } + /> + </SidebarNavigationScreenDetailsPanelRow> + ) + ) } + </ItemGroup> + </SidebarNavigationScreenDetailsPanel> + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js index 99139b55d87a5c..23f758b6f7c571 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { __, sprintf, _x } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { useDispatch, useSelect } from '@wordpress/data'; import { pencil } from '@wordpress/icons'; import { @@ -9,18 +9,20 @@ import { Icon, } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; - /** * Internal dependencies */ import SidebarNavigationScreen from '../sidebar-navigation-screen'; import useEditedEntityRecord from '../use-edited-entity-record'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import SidebarButton from '../sidebar-button'; import { useAddedBy } from '../list/added-by'; +import TemplateActions from '../template-actions'; +import HomeTemplateDetails from './home-template-details'; +import SidebarNavigationScreenDetailsFooter from '../sidebar-navigation-screen-details-footer'; -function useTemplateTitleAndDescription( postType, postId ) { +function useTemplateDetails( postType, postId ) { const { getDescription, getTitle, record } = useEditedEntityRecord( postType, postId @@ -36,19 +38,22 @@ function useTemplateTitleAndDescription( postType, postId ) { let descriptionText = getDescription(); if ( ! descriptionText && addedBy.text ) { - if ( record.type === 'wp_template' && record.is_custom ) { - descriptionText = __( - 'This is a custom template that can be applied manually to any Post or Page.' - ); - } else if ( record.type === 'wp_template_part' ) { - descriptionText = sprintf( - // translators: %s: template part title e.g: "Header". - __( 'This is your %s template part.' ), - getTitle() - ); - } + descriptionText = __( + 'This is a custom template that can be applied manually to any Post or Page.' + ); } + const content = + record?.slug === 'home' || record?.slug === 'index' ? ( + <HomeTemplateDetails /> + ) : null; + + const footer = !! record?.modified ? ( + <SidebarNavigationScreenDetailsFooter + lastModifiedDateTime={ record.modified } + /> + ) : null; + const description = ( <> { descriptionText } @@ -73,9 +78,7 @@ function useTemplateTitleAndDescription( postType, postId ) { { addedBy.isCustomized && ( <span className="edit-site-sidebar-navigation-screen-template__added-by-description-customized"> - { postType === 'wp_template' - ? _x( '(Customized)', 'template' ) - : _x( '(Customized)', 'template part' ) } + { _x( '(Customized)', 'template' ) } </span> ) } </span> @@ -83,14 +86,16 @@ function useTemplateTitleAndDescription( postType, postId ) { </> ); - return { title, description }; + return { title, description, content, footer }; } export default function SidebarNavigationScreenTemplate() { - const { params } = useNavigator(); - const { postType, postId } = params; + const navigator = useNavigator(); + const { + params: { postType, postId }, + } = navigator; const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); - const { title, description } = useTemplateTitleAndDescription( + const { title, content, description, footer } = useTemplateDetails( postType, postId ); @@ -99,13 +104,25 @@ export default function SidebarNavigationScreenTemplate() { <SidebarNavigationScreen title={ title } actions={ - <SidebarButton - onClick={ () => setCanvasMode( 'edit' ) } - label={ __( 'Edit' ) } - icon={ pencil } - /> + <> + <TemplateActions + postType={ postType } + postId={ postId } + toggleProps={ { as: SidebarButton } } + onRemove={ () => { + navigator.goTo( `/${ postType }/all` ); + } } + /> + <SidebarButton + onClick={ () => setCanvasMode( 'edit' ) } + label={ __( 'Edit' ) } + icon={ pencil } + /> + </> } description={ description } + content={ content } + footer={ footer } /> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-template/style.scss index e86c137a574d5f..20b39d8e89817a 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/style.scss @@ -3,23 +3,46 @@ align-items: center; justify-content: space-between; margin-top: $grid-unit-30; +} - &-author { - display: inline-flex; - align-items: center; +.edit-site-sidebar-navigation-screen-template__added-by-description-author { + display: inline-flex; + align-items: center; - img { - border-radius: $grid-unit-15; - } + img { + border-radius: $grid-unit-15; + } - svg { - fill: $gray-600; - } + svg { + fill: $gray-600; + } +} + +.edit-site-sidebar-navigation-screen-template__added-by-description-author-icon { + width: 24px; + height: 24px; + margin-right: $grid-unit-10; +} - &-icon { - width: 24px; - height: 24px; - margin-right: $grid-unit-10; - } +.edit-site-sidebar-navigation-screen-template__template-area-button { + color: $white; + display: flex; + align-items: center; + width: 100%; + flex-wrap: nowrap; + border-radius: 4px; + &:hover, + &:focus { + background: $gray-800; + color: $white; } } + +.edit-site-sidebar-navigation-screen-template__template-area-label-text { + margin: 0 $grid-unit-20 0 $grid-unit-05; + flex-grow: 1; +} + +.edit-site-sidebar-navigation-screen-template__template-icon { + display: flex; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js index 67e979c26e2cea..9d739036a5e97f 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/index.js @@ -2,12 +2,14 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; import { __experimentalUseNavigator as useNavigator } from '@wordpress/components'; /** * Internal dependencies */ import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import { store as editSiteStore } from '../../store'; const config = { wp_template: { @@ -21,6 +23,7 @@ const config = { description: __( 'Create new template parts, or reset any customizations made to the template parts supplied by your theme.' ), + backPath: '/patterns', }, }; @@ -28,10 +31,19 @@ export default function SidebarNavigationScreenTemplatesBrowse() { const { params: { postType }, } = useNavigator(); + + const isTemplatePartsMode = useSelect( ( select ) => { + const settings = select( editSiteStore ).getSettings(); + + return !! settings.supportsTemplatePartsMode; + }, [] ); + return ( <SidebarNavigationScreen + isRoot={ isTemplatePartsMode } title={ config[ postType ].title } description={ config[ postType ].description } + backPath={ config[ postType ].backPath } /> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js index c689ae063b15b5..68e6db58c9a220 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js @@ -4,14 +4,11 @@ import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, - __experimentalUseNavigator as useNavigator, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useEntityRecords } from '@wordpress/core-data'; -import { useSelect } from '@wordpress/data'; import { decodeEntities } from '@wordpress/html-entities'; import { useViewportMatch } from '@wordpress/compose'; -import { getTemplatePartIcon } from '@wordpress/editor'; /** * Internal dependencies @@ -20,51 +17,8 @@ import SidebarNavigationScreen from '../sidebar-navigation-screen'; import { useLink } from '../routes/link'; import SidebarNavigationItem from '../sidebar-navigation-item'; import AddNewTemplate from '../add-new-template'; -import { store as editSiteStore } from '../../store'; import SidebarButton from '../sidebar-button'; -const config = { - wp_template: { - labels: { - title: __( 'Templates' ), - loading: __( 'Loading templates' ), - notFound: __( 'No templates found' ), - manage: __( 'Manage all templates' ), - description: __( - 'Express the layout of your site with templates.' - ), - }, - }, - wp_template_part: { - labels: { - title: __( 'Library' ), - loading: __( 'Loading library' ), - notFound: __( 'No patterns found' ), - manage: __( 'Manage all template parts' ), - reusableBlocks: __( 'Manage reusable blocks' ), - description: __( - 'Manage what patterns are available when editing your site.' - ), - }, - sortCallback: ( items ) => { - const groupedByArea = items.reduce( - ( accumulator, item ) => { - const key = accumulator[ item.area ] ? item.area : 'rest'; - accumulator[ key ].push( item ); - return accumulator; - }, - { header: [], footer: [], sidebar: [], rest: [] } - ); - return [ - ...groupedByArea.header, - ...groupedByArea.footer, - ...groupedByArea.sidebar, - ...groupedByArea.rest, - ]; - }, - }, -}; - const TemplateItem = ( { postType, postId, ...props } ) => { const linkInfo = useLink( { postType, @@ -74,46 +28,33 @@ const TemplateItem = ( { postType, postId, ...props } ) => { }; export default function SidebarNavigationScreenTemplates() { - const { - params: { postType }, - } = useNavigator(); const isMobileViewport = useViewportMatch( 'medium', '<' ); - const isTemplatePartsMode = useSelect( ( select ) => { - const settings = select( editSiteStore ).getSettings(); - - return !! settings.supportsTemplatePartsMode; - }, [] ); const { records: templates, isResolving: isLoading } = useEntityRecords( 'postType', - postType, + 'wp_template', { per_page: -1, } ); - let sortedTemplates = templates ? [ ...templates ] : []; + + const sortedTemplates = templates ? [ ...templates ] : []; sortedTemplates.sort( ( a, b ) => a.title.rendered.localeCompare( b.title.rendered ) ); - if ( config[ postType ].sortCallback ) { - sortedTemplates = config[ postType ].sortCallback( sortedTemplates ); - } - const browseAllLink = useLink( { - path: '/' + postType + '/all', - } ); - - const canCreate = ! isMobileViewport && ! isTemplatePartsMode; - const isTemplateList = postType === 'wp_template'; + const browseAllLink = useLink( { path: '/wp_template/all' } ); + const canCreate = ! isMobileViewport; return ( <SidebarNavigationScreen - isRoot={ isTemplatePartsMode } - title={ config[ postType ].labels.title } - description={ config[ postType ].labels.description } + title={ __( 'Templates' ) } + description={ __( + 'Express the layout of your site with templates' + ) } actions={ canCreate && ( <AddNewTemplate - templateType={ postType } + templateType={ 'wp_template' } toggleProps={ { as: SidebarButton, } } @@ -122,24 +63,18 @@ export default function SidebarNavigationScreenTemplates() { } content={ <> - { isLoading && config[ postType ].labels.loading } + { isLoading && __( 'Loading templates…' ) } { ! isLoading && ( <ItemGroup> { ! templates?.length && ( - <Item> - { config[ postType ].labels.notFound } - </Item> + <Item>{ __( 'No templates found' ) }</Item> ) } { sortedTemplates.map( ( template ) => ( <TemplateItem - postType={ postType } + postType={ 'wp_template' } postId={ template.id } key={ template.id } withChevron - icon={ - ! isTemplateList && - getTemplatePartIcon( template.area ) - } > { decodeEntities( template.title?.rendered || @@ -147,34 +82,17 @@ export default function SidebarNavigationScreenTemplates() { ) } </TemplateItem> ) ) } - { ! isMobileViewport && ( - <> - <SidebarNavigationItem - className="edit-site-sidebar-navigation-screen-templates__see-all" - withChevron - { ...browseAllLink } - > - { config[ postType ].labels.manage } - </SidebarNavigationItem> - { !! config[ postType ].labels - .reusableBlocks && ( - <SidebarNavigationItem - as="a" - href="edit.php?post_type=wp_block" - withChevron - > - { - config[ postType ].labels - .reusableBlocks - } - </SidebarNavigationItem> - ) } - </> - ) } </ItemGroup> ) } </> } + footer={ + ! isMobileViewport && ( + <SidebarNavigationItem withChevron { ...browseAllLink }> + { __( 'Manage all templates' ) } + </SidebarNavigationItem> + ) + } /> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss deleted file mode 100644 index 432774f903e465..00000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss +++ /dev/null @@ -1,4 +0,0 @@ -.edit-site-sidebar-navigation-screen-templates__see-all { - /* Overrides the margin that comes from the Item component */ - margin-top: $grid-unit-20 !important; -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/index.js b/packages/edit-site/src/components/sidebar-navigation-screen/index.js index 77b9bedba3e655..f8824332f51528 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen/index.js @@ -1,34 +1,45 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ import { __experimentalHStack as HStack, - __experimentalVStack as VStack, - __experimentalNavigatorToParentButton as NavigatorToParentButton, __experimentalHeading as Heading, + __experimentalUseNavigator as useNavigator, + __experimentalVStack as VStack, } from '@wordpress/components'; import { isRTL, __, sprintf } from '@wordpress/i18n'; import { chevronRight, chevronLeft } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import SidebarButton from '../sidebar-button'; import { isPreviewingTheme, currentlyPreviewingTheme, } from '../../utils/is-previewing-theme'; +const { useLocation } = unlock( routerPrivateApis ); + export default function SidebarNavigationScreen( { isRoot, title, actions, + meta, content, + footer, description, + backPath: backPathProp, } ) { const { dashboardLink } = useSelect( ( select ) => { const { getSettings } = unlock( select( editSiteStore ) ); @@ -37,61 +48,103 @@ export default function SidebarNavigationScreen( { }; }, [] ); const { getTheme } = useSelect( coreStore ); + const location = useLocation(); + const navigator = useNavigator(); const theme = getTheme( currentlyPreviewingTheme() ); + const icon = isRTL() ? chevronRight : chevronLeft; return ( - <VStack spacing={ 2 }> - <HStack - spacing={ 4 } - alignment="flex-start" - className="edit-site-sidebar-navigation-screen__title-icon" - > - { ! isRoot ? ( - <NavigatorToParentButton - as={ SidebarButton } - icon={ isRTL() ? chevronRight : chevronLeft } - label={ __( 'Back' ) } - /> - ) : ( - <SidebarButton - icon={ isRTL() ? chevronRight : chevronLeft } - label={ - ! isPreviewingTheme() - ? __( 'Go back to the Dashboard' ) - : __( 'Go back to the theme showcase' ) - } - href={ - ! isPreviewingTheme() - ? dashboardLink || 'index.php' - : 'themes.php' - } - /> + <> + <VStack + className={ classnames( + 'edit-site-sidebar-navigation-screen__main', + { + 'has-footer': !! footer, + } ) } - <Heading - className="edit-site-sidebar-navigation-screen__title" - color={ 'white' } - level={ 2 } - size={ 20 } + spacing={ 0 } + justify="flex-start" + > + <HStack + spacing={ 4 } + alignment="flex-start" + className="edit-site-sidebar-navigation-screen__title-icon" > - { ! isPreviewingTheme() - ? title - : sprintf( - 'Previewing %1$s: %2$s', - theme?.name?.rendered, - title - ) } - </Heading> - { actions } - </HStack> - - <nav className="edit-site-sidebar-navigation-screen__content"> - { description && ( - <p className="edit-site-sidebar-navigation-screen__description"> - { description } - </p> + { ! isRoot && ( + <SidebarButton + onClick={ () => { + const backPath = + backPathProp ?? location.state?.backPath; + if ( backPath ) { + navigator.goTo( backPath, { + isBack: true, + } ); + } else { + navigator.goToParent(); + } + } } + icon={ icon } + label={ __( 'Back' ) } + showTooltip={ false } + /> + ) } + { isRoot && ( + <SidebarButton + icon={ icon } + label={ + ! isPreviewingTheme() + ? __( 'Go to the Dashboard' ) + : __( 'Go back to the theme showcase' ) + } + href={ + ! isPreviewingTheme() + ? dashboardLink || 'index.php' + : 'themes.php' + } + /> + ) } + <Heading + className="edit-site-sidebar-navigation-screen__title" + color={ '#e0e0e0' /* $gray-200 */ } + level={ 1 } + size={ 20 } + > + { ! isPreviewingTheme() + ? title + : sprintf( + 'Previewing %1$s: %2$s', + theme?.name?.rendered, + title + ) } + </Heading> + { actions && ( + <div className="edit-site-sidebar-navigation-screen__actions"> + { actions } + </div> + ) } + </HStack> + { meta && ( + <> + <div className="edit-site-sidebar-navigation-screen__meta"> + { meta } + </div> + </> ) } - { content } - </nav> - </VStack> + + <div className="edit-site-sidebar-navigation-screen__content"> + { description && ( + <p className="edit-site-sidebar-navigation-screen__description"> + { description } + </p> + ) } + { content } + </div> + </VStack> + { footer && ( + <footer className="edit-site-sidebar-navigation-screen__footer"> + { footer } + </footer> + ) } + </> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss index b72edaba63f7df..65fe323157b9a1 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss @@ -5,10 +5,40 @@ position: relative; } +.edit-site-sidebar-navigation-screen__main { + // Ensure the sidebar is always at least as tall as the viewport. + // This allows the footer section to be sticky at the bottom of the viewport. + flex-grow: 1; + margin-bottom: $grid-unit-20; + &.has-footer { + margin-bottom: 0; + } +} + .edit-site-sidebar-navigation-screen__content { + padding: 0 $grid-unit-20; + + .components-item-group { + margin-left: -$grid-unit-20; + margin-right: -$grid-unit-20; + } + + .components-text { + color: $gray-400; + } + + .components-heading { + margin-bottom: $grid-unit-10; + } +} + +.edit-site-sidebar-navigation-screen__meta { margin: 0 0 $grid-unit-20 0; - color: $gray-600; - //z-index: z-index(".edit-site-sidebar-navigation-screen__content"); + color: $gray-400; + margin-left: $grid-unit-20; + .components-text { + color: $gray-400; + } } .edit-site-sidebar-navigation-screen__page-link { @@ -22,7 +52,6 @@ .components-external-link__icon { margin-left: $grid-unit-05; } - margin-left: $grid-unit-20; display: inline-block; } @@ -31,7 +60,6 @@ top: 0; background: $gray-900; padding-top: $grid-unit-60 + $header-height; - box-shadow: 0 $grid-unit-10 $grid-unit-20 $gray-900; margin-bottom: $grid-unit-10; padding-bottom: $grid-unit-10; z-index: z-index(".edit-site-sidebar-navigation-screen__title-icon"); @@ -40,10 +68,15 @@ .edit-site-sidebar-navigation-screen__title { flex-grow: 1; padding: $grid-unit-15 * 0.5 0 0 0; + overflow: hidden; + overflow-wrap: break-word; +} + +.edit-site-sidebar-navigation-screen__actions { + flex-shrink: 0; } .edit-site-sidebar-navigation-screen__content .edit-site-global-styles-style-variations-container { - margin-left: $grid-unit-20; .edit-site-global-styles-variations_item-preview { border: $gray-900 $border-width solid; } @@ -58,3 +91,54 @@ border: var(--wp-admin-theme-color) var(--wp-admin-border-width-focus) solid; } } + +.edit-site-sidebar-navigation-screen__footer { + position: sticky; + bottom: 0; + background-color: $gray-900; + gap: 0; + padding: $grid-unit-20 0; + margin: $grid-unit-20 0 0; + border-top: 1px solid $gray-800; +} + +.edit-site-sidebar__notice { + background: $gray-800; + color: $gray-300; + margin: $grid-unit-30 0; + &.is-dismissible { + padding-right: $grid-unit-10; + } + .components-notice__dismiss:not(:disabled):not([aria-disabled="true"]):focus, + .components-notice__dismiss:not(:disabled):not([aria-disabled="true"]):not(.is-secondary):active, + .components-notice__dismiss:not(:disabled):not([aria-disabled="true"]):not(.is-secondary):hover { + color: $gray-100; + } +} + +/* In general style overrides are discouraged. + * This is a temporary solution to override the InputControl component's styles. + * The `Theme` component will potentially be the more appropriate approach + * once that component is stabilized. + * See: packages/components/src/theme + */ +.edit-site-sidebar-navigation-screen__input-control { + width: 100%; + .components-input-control__container { + background: $gray-800; + + .components-button { + color: $gray-200 !important; + } + } + .components-input-control__input { + color: $gray-200 !important; + background: $gray-800 !important; + } + .components-input-control__backdrop { + border: 4px !important; + } + .components-base-control__help { + color: $gray-600; + } +} diff --git a/packages/edit-site/src/components/sidebar-navigation-subtitle/index.js b/packages/edit-site/src/components/sidebar-navigation-subtitle/index.js deleted file mode 100644 index 2a20f31ce7fb48..00000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-subtitle/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export default function SidebarNavigationSubtitle( { children } ) { - return ( - <h3 className="edit-site-sidebar-navigation-subtitle">{ children }</h3> - ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss b/packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss deleted file mode 100644 index 735145ca1d80ce..00000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss +++ /dev/null @@ -1,7 +0,0 @@ -.edit-site-sidebar-navigation-subtitle { - color: $gray-100; - text-transform: uppercase; - font-weight: 500; - font-size: 11px; - padding: $grid-unit-20 0 0 $grid-unit-20; -} diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js index 98fb77139090ee..9e035759ea9ad6 100644 --- a/packages/edit-site/src/components/sidebar/index.js +++ b/packages/edit-site/src/components/sidebar/index.js @@ -14,6 +14,8 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; import SidebarNavigationScreenMain from '../sidebar-navigation-screen-main'; import SidebarNavigationScreenTemplates from '../sidebar-navigation-screen-templates'; import SidebarNavigationScreenTemplate from '../sidebar-navigation-screen-template'; +import SidebarNavigationScreenPatterns from '../sidebar-navigation-screen-patterns'; +import SidebarNavigationScreenPattern from '../sidebar-navigation-screen-pattern'; import useSyncPathWithURL, { getPathFromURL, } from '../sync-state-with-url/use-sync-path-with-url'; @@ -22,7 +24,7 @@ import SidebarNavigationScreenNavigationMenu from '../sidebar-navigation-screen- import SidebarNavigationScreenGlobalStyles from '../sidebar-navigation-screen-global-styles'; import SidebarNavigationScreenTemplatesBrowse from '../sidebar-navigation-screen-templates-browse'; import SaveHub from '../save-hub'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import SidebarNavigationScreenPages from '../sidebar-navigation-screen-pages'; import SidebarNavigationScreenPage from '../sidebar-navigation-screen-page'; @@ -51,13 +53,19 @@ function SidebarScreens() { <NavigatorScreen path="/page/:postId"> <SidebarNavigationScreenPage /> </NavigatorScreen> - <NavigatorScreen path="/:postType(wp_template|wp_template_part)"> + <NavigatorScreen path="/:postType(wp_template)"> <SidebarNavigationScreenTemplates /> </NavigatorScreen> + <NavigatorScreen path="/patterns"> + <SidebarNavigationScreenPatterns /> + </NavigatorScreen> <NavigatorScreen path="/:postType(wp_template|wp_template_part)/all"> <SidebarNavigationScreenTemplatesBrowse /> </NavigatorScreen> - <NavigatorScreen path="/:postType(wp_template|wp_template_part)/:postId"> + <NavigatorScreen path="/:postType(wp_template_part|wp_block)/:postId"> + <SidebarNavigationScreenPattern /> + </NavigatorScreen> + <NavigatorScreen path="/:postType(wp_template)/:postId"> <SidebarNavigationScreenTemplate /> </NavigatorScreen> </> @@ -76,9 +84,7 @@ function Sidebar() { > <SidebarScreens /> </NavigatorProvider> - <div className="edit-site-sidebar__footer"> - <SaveHub /> - </div> + <SaveHub /> </> ); } diff --git a/packages/edit-site/src/components/sidebar/style.scss b/packages/edit-site/src/components/sidebar/style.scss index 08f6da48da6667..9a3644cc830d56 100644 --- a/packages/edit-site/src/components/sidebar/style.scss +++ b/packages/edit-site/src/components/sidebar/style.scss @@ -5,6 +5,9 @@ .components-navigator-screen { @include custom-scrollbars-on-hover(transparent, $gray-700); scrollbar-gutter: stable; + display: flex; + flex-direction: column; + height: 100%; } } @@ -15,10 +18,6 @@ padding: $canvas-padding 0; } -.edit-site-sidebar__content.edit-site-sidebar__content { - overflow-x: unset; -} - .edit-site-sidebar__content > div { // This matches the logo padding padding: 0 $grid-unit-15; diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js index b477c5b14f1622..a8a728cfb37edb 100644 --- a/packages/edit-site/src/components/site-hub/index.js +++ b/packages/edit-site/src/components/site-hub/index.js @@ -19,42 +19,54 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; import { forwardRef } from '@wordpress/element'; -import { search } from '@wordpress/icons'; -import { privateApis as commandsPrivateApis } from '@wordpress/commands'; +import { search, external } from '@wordpress/icons'; +import { store as commandsStore } from '@wordpress/commands'; +import { displayShortcut } from '@wordpress/keycodes'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; import SiteIcon from '../site-icon'; -import { unlock } from '../../private-apis'; - -const { store: commandsStore } = unlock( commandsPrivateApis ); +import { unlock } from '../../lock-unlock'; const HUB_ANIMATION_DURATION = 0.3; -const SiteHub = forwardRef( ( props, ref ) => { - const { canvasMode, dashboardLink } = useSelect( ( select ) => { - const { getCanvasMode, getSettings } = unlock( - select( editSiteStore ) - ); +const SiteHub = forwardRef( ( { isTransparent, ...restProps }, ref ) => { + const { canvasMode, dashboardLink, homeUrl, siteTitle } = useSelect( + ( select ) => { + const { getCanvasMode, getSettings } = unlock( + select( editSiteStore ) + ); + + const { + getSite, + getUnstableBase, // Site index. + } = select( coreStore ); - return { - canvasMode: getCanvasMode(), - dashboardLink: - getSettings().__experimentalDashboardLink || 'index.php', - }; - }, [] ); + return { + canvasMode: getCanvasMode(), + dashboardLink: + getSettings().__experimentalDashboardLink || 'index.php', + homeUrl: getUnstableBase()?.home, + siteTitle: getSite()?.title, + }; + }, + [] + ); const { open: openCommandCenter } = useDispatch( commandsStore ); const disableMotion = useReducedMotion(); - const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); + const { + setCanvasMode, + __experimentalSetPreviewDeviceType: setPreviewDeviceType, + } = unlock( useDispatch( editSiteStore ) ); const { clearSelectedBlock } = useDispatch( blockEditorStore ); const isBackToDashboardButton = canvasMode === 'view'; const siteIconButtonProps = isBackToDashboardButton ? { href: dashboardLink, - label: __( 'Go back to the Dashboard' ), + label: __( 'Go to the Dashboard' ), } : { href: dashboardLink, // We need to keep the `href` here so the component doesn't remount as a `<button>` and break the animation. @@ -64,22 +76,20 @@ const SiteHub = forwardRef( ( props, ref ) => { event.preventDefault(); if ( canvasMode === 'edit' ) { clearSelectedBlock(); + setPreviewDeviceType( 'Desktop' ); setCanvasMode( 'view' ); } }, }; - const siteTitle = useSelect( - ( select ) => - select( coreStore ).getEntityRecord( 'root', 'site' )?.title, - [] - ); - return ( <motion.div ref={ ref } - { ...props } - className={ classnames( 'edit-site-site-hub', props.className ) } + { ...restProps } + className={ classnames( + 'edit-site-site-hub', + restProps.className + ) } initial={ false } transition={ { type: 'tween', @@ -87,14 +97,23 @@ const SiteHub = forwardRef( ( props, ref ) => { ease: 'easeOut', } } > - <HStack justify="space-between" alignment="center"> + <HStack + justify="space-between" + alignment="center" + className="edit-site-site-hub__container" + > <HStack justify="flex-start" className="edit-site-site-hub__text-content" spacing="0" > <motion.div - className="edit-site-site-hub__view-mode-toggle-container" + className={ classnames( + 'edit-site-site-hub__view-mode-toggle-container', + { + 'has-transparent-background': isTransparent, + } + ) } layout transition={ { type: 'tween', @@ -138,7 +157,10 @@ const SiteHub = forwardRef( ( props, ref ) => { exit={ { opacity: 0, } } - className="edit-site-site-hub__site-title" + className={ classnames( + 'edit-site-site-hub__site-title', + { 'is-transparent': isTransparent } + ) } transition={ { type: 'tween', duration: disableMotion ? 0 : 0.2, @@ -149,13 +171,29 @@ const SiteHub = forwardRef( ( props, ref ) => { { decodeEntities( siteTitle ) } </motion.div> </AnimatePresence> + { canvasMode === 'view' && ( + <Button + href={ homeUrl } + target="_blank" + label={ __( 'View site (opens in a new tab)' ) } + aria-label={ __( + 'View site (opens in a new tab)' + ) } + icon={ external } + className="edit-site-site-hub__site-view-link" + /> + ) } </HStack> { canvasMode === 'view' && ( <Button - className="edit-site-site-hub_toggle-command-center" + className={ classnames( + 'edit-site-site-hub_toggle-command-center', + { 'is-transparent': isTransparent } + ) } icon={ search } onClick={ () => openCommandCenter() } - label={ __( 'Open command center' ) } + label={ __( 'Open command palette' ) } + shortcut={ displayShortcut.primary( 'k' ) } /> ) } </HStack> diff --git a/packages/edit-site/src/components/site-hub/style.scss b/packages/edit-site/src/components/site-hub/style.scss index 90a9c37011cf57..49e5304d1688fc 100644 --- a/packages/edit-site/src/components/site-hub/style.scss +++ b/packages/edit-site/src/components/site-hub/style.scss @@ -3,6 +3,39 @@ align-items: center; justify-content: space-between; gap: $grid-unit-10; + + .edit-site-site-hub__container { + gap: 0; + } + + .edit-site-site-hub__site-title, + .edit-site-site-hub_toggle-command-center { + transition: opacity ease 0.1s; + + &.is-transparent { + opacity: 0 !important; + } + } + + .edit-site-site-hub__site-view-link { + flex-grow: 0; + margin-right: var(--wp-admin-border-width-focus); + @include break-mobile() { + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + &:focus { + opacity: 1; + } + svg { + fill: $gray-200; + } + } + &:hover { + .edit-site-site-hub__site-view-link { + opacity: 1; + } + } } .edit-site-site-hub__post-type { @@ -14,6 +47,10 @@ width: $header-height; flex-shrink: 0; background: $gray-900; + + &.has-transparent-background { + background: transparent; + } } .edit-site-site-hub__text-content { @@ -29,12 +66,14 @@ .edit-site-site-hub__site-title { margin-left: $grid-unit-05; + flex-grow: 1; + color: $gray-200; } .edit-site-site-hub_toggle-command-center { - color: $white; + color: $gray-200; &:hover { - color: $white; + color: $gray-100; } } diff --git a/packages/edit-site/src/components/start-template-options/style.scss b/packages/edit-site/src/components/start-template-options/style.scss index 7515861099b380..53cd38a8c9bfad 100644 --- a/packages/edit-site/src/components/start-template-options/style.scss +++ b/packages/edit-site/src/components/start-template-options/style.scss @@ -29,9 +29,6 @@ $actions-height: 92px; column-count: 2; column-gap: $grid-unit-30; - // Small top padding required to avoid cutting off the visible outline when hovering items - padding-top: $border-width-focus-fallback; - @include break-medium() { column-count: 3; } diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index c86d6df65045a2..b6931b8e656653 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -36,7 +36,7 @@ import { ENTER, SPACE } from '@wordpress/keycodes'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; import EditorCanvasContainer from '../editor-canvas-container'; const { ExperimentalBlockEditorProvider, useGlobalStyle } = unlock( diff --git a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js index 4acd381fca85f0..19518f650c0be3 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js @@ -10,7 +10,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); @@ -31,8 +31,13 @@ export default function useInitEditedEntityFromURL() { }; }, [] ); - const { setTemplate, setTemplatePart, setPage } = - useDispatch( editSiteStore ); + const { + setEditedEntity, + setTemplate, + setTemplatePart, + setPage, + setNavigationMenu, + } = useDispatch( editSiteStore ); useEffect( () => { if ( postType && postId ) { @@ -43,6 +48,12 @@ export default function useInitEditedEntityFromURL() { case 'wp_template_part': setTemplatePart( postId ); break; + case 'wp_navigation': + setNavigationMenu( postId ); + break; + case 'wp_block': + setEditedEntity( postType, postId ); + break; default: setPage( { context: { postType, postId }, @@ -68,8 +79,10 @@ export default function useInitEditedEntityFromURL() { postType, homepageId, isRequestingSite, + setEditedEntity, setPage, setTemplate, setTemplatePart, + setNavigationMenu, ] ); } diff --git a/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js b/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js index a541ec652fee86..9f0c8dd9a0e11f 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js @@ -9,7 +9,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useLocation, useHistory } = unlock( routerPrivateApis ); @@ -58,10 +58,7 @@ export default function useSyncCanvasModeWithURL() { useEffect( () => { currentCanvasInUrl.current = canvasInUrl; - if ( - canvasInUrl === undefined && - currentCanvasMode.current !== 'view' - ) { + if ( canvasInUrl !== 'edit' && currentCanvasMode.current !== 'view' ) { setCanvasMode( 'view' ); } else if ( canvasInUrl === 'edit' && diff --git a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js index 04cdef425716fd..86928c1920a948 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js @@ -8,7 +8,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useLocation, useHistory } = unlock( routerPrivateApis ); @@ -18,6 +18,7 @@ export function getPathFromURL( urlParams ) { // Compute the navigator path based on the URL params. if ( urlParams?.postType && urlParams?.postId ) { switch ( urlParams.postType ) { + case 'wp_block': case 'wp_template': case 'wp_template_part': case 'page': @@ -35,6 +36,12 @@ export function getPathFromURL( urlParams ) { return path; } +function isSubset( subset, superset ) { + return Object.entries( subset ).every( ( [ key, value ] ) => { + return superset[ key ] === value; + } ); +} + export default function useSyncPathWithURL() { const history = useHistory(); const { params: urlParams } = useLocation(); @@ -43,67 +50,77 @@ export default function useSyncPathWithURL() { params: navigatorParams, goTo, } = useNavigator(); - const currentUrlParams = useRef( urlParams ); - const currentPath = useRef( navigatorLocation.path ); const isMounting = useRef( true ); - useEffect( () => { - // The navigatorParams are only initially filled properly when the - // navigator screens mount. so we ignore the first synchronisation. - if ( isMounting.current ) { - isMounting.current = false; - return; - } - - function updateUrlParams( newUrlParams ) { - if ( - Object.entries( newUrlParams ).every( ( [ key, value ] ) => { - return currentUrlParams.current[ key ] === value; - } ) - ) { + useEffect( + () => { + // The navigatorParams are only initially filled properly when the + // navigator screens mount. so we ignore the first synchronisation. + if ( isMounting.current ) { + isMounting.current = false; return; } - const updatedParams = { - ...currentUrlParams.current, - ...newUrlParams, - }; - currentUrlParams.current = updatedParams; - history.push( updatedParams ); - } - if ( navigatorParams?.postType && navigatorParams?.postId ) { - updateUrlParams( { - postType: navigatorParams?.postType, - postId: navigatorParams?.postId, - path: undefined, - } ); - } else if ( - navigatorLocation.path.startsWith( '/page/' ) && - navigatorParams?.postId - ) { - updateUrlParams( { - postType: 'page', - postId: navigatorParams?.postId, - path: undefined, - } ); - } else { - updateUrlParams( { - postType: undefined, - postId: undefined, - path: - navigatorLocation.path === '/' - ? undefined - : navigatorLocation.path, - } ); - } - }, [ navigatorLocation?.path, navigatorParams, history ] ); + function updateUrlParams( newUrlParams ) { + if ( isSubset( newUrlParams, urlParams ) ) { + return; + } + const updatedParams = { + ...urlParams, + ...newUrlParams, + }; + history.push( updatedParams ); + } - useEffect( () => { - currentUrlParams.current = urlParams; - const path = getPathFromURL( urlParams ); - if ( currentPath.current !== path ) { - currentPath.current = path; - goTo( path ); - } - }, [ urlParams, goTo ] ); + if ( navigatorParams?.postType && navigatorParams?.postId ) { + updateUrlParams( { + postType: navigatorParams?.postType, + postId: navigatorParams?.postId, + path: undefined, + } ); + } else if ( + navigatorLocation.path.startsWith( '/page/' ) && + navigatorParams?.postId + ) { + updateUrlParams( { + postType: 'page', + postId: navigatorParams?.postId, + path: undefined, + } ); + } else if ( navigatorLocation.path === '/patterns' ) { + updateUrlParams( { + postType: undefined, + postId: undefined, + canvas: undefined, + path: navigatorLocation.path, + } ); + } else { + updateUrlParams( { + postType: undefined, + postId: undefined, + categoryType: undefined, + categoryId: undefined, + path: + navigatorLocation.path === '/' + ? undefined + : navigatorLocation.path, + } ); + } + }, + // Trigger only when navigator changes to prevent infinite loops. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ navigatorLocation?.path, navigatorParams ] + ); + + useEffect( + () => { + const path = getPathFromURL( urlParams ); + if ( navigatorLocation.path !== path ) { + goTo( path ); + } + }, + // Trigger only when URL changes to prevent infinite loops. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ urlParams ] + ); } diff --git a/packages/edit-site/src/components/table/index.js b/packages/edit-site/src/components/table/index.js new file mode 100644 index 00000000000000..0afe655876faac --- /dev/null +++ b/packages/edit-site/src/components/table/index.js @@ -0,0 +1,33 @@ +export default function Table( { data, columns } ) { + return ( + <div className="edit-site-table-wrapper"> + <table className="edit-site-table"> + <thead> + <tr> + { columns.map( ( column ) => ( + <th key={ column.header }>{ column.header }</th> + ) ) } + </tr> + </thead> + <tbody> + { data.map( ( row, rowIndex ) => ( + <tr key={ rowIndex }> + { columns.map( ( column, columnIndex ) => ( + <td + style={ { + maxWidth: column.maxWidth + ? column.maxWidth + : undefined, + } } + key={ columnIndex } + > + { column.cell( row ) } + </td> + ) ) } + </tr> + ) ) } + </tbody> + </table> + </div> + ); +} diff --git a/packages/edit-site/src/components/table/style.scss b/packages/edit-site/src/components/table/style.scss new file mode 100644 index 00000000000000..85c741575d5a44 --- /dev/null +++ b/packages/edit-site/src/components/table/style.scss @@ -0,0 +1,38 @@ +.edit-site-table-wrapper { + width: 100%; + padding: $grid-unit-40; +} + +.edit-site-table { + width: 100%; + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + position: relative; + a { + text-decoration: none; + } + th { + text-align: left; + font-weight: normal; + padding: 0 $grid-unit-20 $grid-unit-20; + color: $gray-700; + } + td { + padding: $grid-unit-20; + } + td, + th { + vertical-align: center; + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + text-align: right; + } + } + tr { + border-bottom: 1px solid $gray-100; + } +} diff --git a/packages/edit-site/src/components/template-actions/index.js b/packages/edit-site/src/components/template-actions/index.js new file mode 100644 index 00000000000000..bce2a008077c66 --- /dev/null +++ b/packages/edit-site/src/components/template-actions/index.js @@ -0,0 +1,144 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + DropdownMenu, + MenuGroup, + MenuItem, + __experimentalConfirmDialog as ConfirmDialog, +} from '@wordpress/components'; +import { moreVertical } from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; +import isTemplateRemovable from '../../utils/is-template-removable'; +import isTemplateRevertable from '../../utils/is-template-revertable'; +import RenameMenuItem from './rename-menu-item'; + +export default function TemplateActions( { + postType, + postId, + className, + toggleProps, + onRemove, +} ) { + const template = useSelect( + ( select ) => + select( coreStore ).getEntityRecord( 'postType', postType, postId ), + [ postType, postId ] + ); + const { removeTemplate, revertTemplate } = useDispatch( editSiteStore ); + const { saveEditedEntityRecord } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const isRemovable = isTemplateRemovable( template ); + const isRevertable = isTemplateRevertable( template ); + + if ( ! isRemovable && ! isRevertable ) { + return null; + } + + async function revertAndSaveTemplate() { + try { + await revertTemplate( template, { allowUndo: false } ); + await saveEditedEntityRecord( + 'postType', + template.type, + template.id + ); + + createSuccessNotice( + sprintf( + /* translators: The template/part's name. */ + __( '"%s" reverted.' ), + decodeEntities( template.title.rendered ) + ), + { + type: 'snackbar', + id: 'edit-site-template-reverted', + } + ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while reverting the entity.' ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + + return ( + <DropdownMenu + icon={ moreVertical } + label={ __( 'Actions' ) } + className={ className } + toggleProps={ toggleProps } + > + { ( { onClose } ) => ( + <MenuGroup> + { isRemovable && ( + <> + <RenameMenuItem + template={ template } + onClose={ onClose } + /> + <DeleteMenuItem + onRemove={ () => { + removeTemplate( template ); + onRemove?.(); + onClose(); + } } + isTemplate={ template.type === 'wp_template' } + /> + </> + ) } + { isRevertable && ( + <MenuItem + info={ __( + 'Use the template as supplied by the theme.' + ) } + onClick={ () => { + revertAndSaveTemplate(); + onClose(); + } } + > + { __( 'Clear customizations' ) } + </MenuItem> + ) } + </MenuGroup> + ) } + </DropdownMenu> + ); +} + +function DeleteMenuItem( { onRemove, isTemplate } ) { + const [ isModalOpen, setIsModalOpen ] = useState( false ); + return ( + <> + <MenuItem isDestructive onClick={ () => setIsModalOpen( true ) }> + { __( 'Delete' ) } + </MenuItem> + <ConfirmDialog + isOpen={ isModalOpen } + onConfirm={ onRemove } + onCancel={ () => setIsModalOpen( false ) } + confirmButtonText={ __( 'Delete' ) } + > + { isTemplate + ? __( 'Are you sure you want to delete this template?' ) + : __( + 'Are you sure you want to delete this template part?' + ) } + </ConfirmDialog> + </> + ); +} diff --git a/packages/edit-site/src/components/template-actions/rename-menu-item.js b/packages/edit-site/src/components/template-actions/rename-menu-item.js new file mode 100644 index 00000000000000..9f897fcd2e9433 --- /dev/null +++ b/packages/edit-site/src/components/template-actions/rename-menu-item.js @@ -0,0 +1,120 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { + Button, + MenuItem, + Modal, + TextControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import { decodeEntities } from '@wordpress/html-entities'; + +export default function RenameMenuItem( { template, onClose } ) { + const title = decodeEntities( template.title.rendered ); + const [ editedTitle, setEditedTitle ] = useState( title ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + const { + editEntityRecord, + __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits, + } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + if ( template.type === 'wp_template' && ! template.is_custom ) { + return null; + } + + async function onTemplateRename( event ) { + event.preventDefault(); + + try { + await editEntityRecord( 'postType', template.type, template.id, { + title: editedTitle, + } ); + + // Update state before saving rerenders the list. + setEditedTitle( '' ); + setIsModalOpen( false ); + onClose(); + + // Persist edited entity. + await saveSpecifiedEntityEdits( + 'postType', + template.type, + template.id, + [ 'title' ], // Only save title to avoid persisting other edits. + { + throwOnError: true, + } + ); + + createSuccessNotice( __( 'Entity renamed.' ), { + type: 'snackbar', + } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while renaming the entity.' ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + + return ( + <> + <MenuItem + onClick={ () => { + setIsModalOpen( true ); + setEditedTitle( title ); + } } + > + { __( 'Rename' ) } + </MenuItem> + { isModalOpen && ( + <Modal + title={ __( 'Rename' ) } + onRequestClose={ () => { + setIsModalOpen( false ); + } } + overlayClassName="edit-site-list__rename-modal" + > + <form onSubmit={ onTemplateRename }> + <VStack spacing="5"> + <TextControl + __nextHasNoMarginBottom + label={ __( 'Name' ) } + value={ editedTitle } + onChange={ setEditedTitle } + required + /> + + <HStack justify="right"> + <Button + variant="tertiary" + onClick={ () => { + setIsModalOpen( false ); + } } + > + { __( 'Cancel' ) } + </Button> + + <Button variant="primary" type="submit"> + { __( 'Save' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js b/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js index a671faf756cd40..e8a80ee4e299ae 100644 --- a/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js +++ b/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js @@ -4,10 +4,9 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { MenuItem } from '@wordpress/components'; -import { createBlock, serialize } from '@wordpress/blocks'; +import { createBlock } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import { useState } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; import { store as noticesStore } from '@wordpress/notices'; import { symbolFilled } from '@wordpress/icons'; @@ -16,18 +15,11 @@ import { symbolFilled } from '@wordpress/icons'; */ import CreateTemplatePartModal from '../create-template-part-modal'; import { store as editSiteStore } from '../../store'; -import { - useExistingTemplateParts, - getUniqueTemplatePartTitle, - getCleanTemplatePartSlug, -} from '../../utils/template-part-create'; export default function ConvertToTemplatePart( { clientIds, blocks } ) { const [ isModalOpen, setIsModalOpen ] = useState( false ); const { replaceBlocks } = useDispatch( blockEditorStore ); - const { saveEntityRecord } = useDispatch( coreStore ); const { createSuccessNotice } = useDispatch( noticesStore ); - const existingTemplateParts = useExistingTemplateParts(); const { canCreate } = useSelect( ( select ) => { const { supportsTemplatePartsMode } = @@ -41,23 +33,7 @@ export default function ConvertToTemplatePart( { clientIds, blocks } ) { return null; } - const onConvert = async ( { title, area } ) => { - const uniqueTitle = getUniqueTemplatePartTitle( - title, - existingTemplateParts - ); - const cleanSlug = getCleanTemplatePartSlug( uniqueTitle ); - - const templatePart = await saveEntityRecord( - 'postType', - 'wp_template_part', - { - slug: cleanSlug, - title: uniqueTitle, - content: serialize( blocks ), - area, - } - ); + const onConvert = async ( templatePart ) => { replaceBlocks( clientIds, createBlock( 'core/template-part', { @@ -80,14 +56,17 @@ export default function ConvertToTemplatePart( { clientIds, blocks } ) { onClick={ () => { setIsModalOpen( true ); } } + aria-expanded={ isModalOpen } + aria-haspopup="dialog" > - { __( 'Create Template part' ) } + { __( 'Create template part' ) } </MenuItem> { isModalOpen && ( <CreateTemplatePartModal closeModal={ () => { setIsModalOpen( false ); } } + blocks={ blocks } onCreate={ onConvert } /> ) } diff --git a/packages/edit-site/src/components/welcome-guide/editor.js b/packages/edit-site/src/components/welcome-guide/editor.js index c7a1072bf79057..6b07228911bf81 100644 --- a/packages/edit-site/src/components/welcome-guide/editor.js +++ b/packages/edit-site/src/components/welcome-guide/editor.js @@ -30,9 +30,9 @@ export default function WelcomeGuideEditor() { return ( <Guide - className="edit-site-welcome-guide" + className="edit-site-welcome-guide guide-editor" contentLabel={ __( 'Welcome to the site editor' ) } - finishButtonText={ __( 'Get Started' ) } + finishButtonText={ __( 'Get started' ) } onFinish={ () => toggle( 'core/edit-site', 'welcomeGuide' ) } pages={ [ { diff --git a/packages/edit-site/src/components/welcome-guide/index.js b/packages/edit-site/src/components/welcome-guide/index.js index 4ad4d7d0eecefd..37804f1698c763 100644 --- a/packages/edit-site/src/components/welcome-guide/index.js +++ b/packages/edit-site/src/components/welcome-guide/index.js @@ -3,12 +3,16 @@ */ import WelcomeGuideEditor from './editor'; import WelcomeGuideStyles from './styles'; +import WelcomeGuidePage from './page'; +import WelcomeGuideTemplate from './template'; export default function WelcomeGuide() { return ( <> <WelcomeGuideEditor /> <WelcomeGuideStyles /> + <WelcomeGuidePage /> + <WelcomeGuideTemplate /> </> ); } diff --git a/packages/edit-site/src/components/welcome-guide/page.js b/packages/edit-site/src/components/welcome-guide/page.js new file mode 100644 index 00000000000000..adb64a8033e999 --- /dev/null +++ b/packages/edit-site/src/components/welcome-guide/page.js @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { Guide } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; + +export default function WelcomeGuidePage() { + const { toggle } = useDispatch( preferencesStore ); + + const isVisible = useSelect( ( select ) => { + const isPageActive = !! select( preferencesStore ).get( + 'core/edit-site', + 'welcomeGuidePage' + ); + const isEditorActive = !! select( preferencesStore ).get( + 'core/edit-site', + 'welcomeGuide' + ); + const { hasPageContentFocus } = select( editSiteStore ); + return isPageActive && ! isEditorActive && hasPageContentFocus(); + }, [] ); + + if ( ! isVisible ) { + return null; + } + + const heading = __( 'Editing a page' ); + + return ( + <Guide + className="edit-site-welcome-guide guide-page" + contentLabel={ heading } + finishButtonText={ __( 'Continue' ) } + onFinish={ () => toggle( 'core/edit-site', 'welcomeGuidePage' ) } + pages={ [ + { + image: ( + <video + className="edit-site-welcome-guide__video" + autoPlay + loop + muted + width="312" + height="240" + > + <source + src="https://s.w.org/images/block-editor/editing-your-page.mp4" + type="video/mp4" + /> + </video> + ), + content: ( + <> + <h1 className="edit-site-welcome-guide__heading"> + { heading } + </h1> + <p className="edit-site-welcome-guide__text"> + { __( + 'It’s now possible to edit page content in the site editor. To customise other parts of the page like the header and footer switch to editing the template using the settings sidebar.' + ) } + </p> + </> + ), + }, + ] } + /> + ); +} diff --git a/packages/edit-site/src/components/welcome-guide/style.scss b/packages/edit-site/src/components/welcome-guide/style.scss index 78cc70d0b426c5..e9e8b46a8aff2d 100644 --- a/packages/edit-site/src/components/welcome-guide/style.scss +++ b/packages/edit-site/src/components/welcome-guide/style.scss @@ -1,8 +1,22 @@ .edit-site-welcome-guide { width: 312px; - &__image { + &.guide-editor .edit-site-welcome-guide__image + &.guide-styles .edit-site-welcome-guide__image { background: #00a0d2; + } + + &.guide-page .edit-site-welcome-guide__video { + border-right: #3858e9 $grid-unit-20 solid; + border-top: #3858e9 $grid-unit-20 solid; + } + + &.guide-template .edit-site-welcome-guide__video { + border-left: #3858e9 $grid-unit-20 solid; + border-top: #3858e9 $grid-unit-20 solid; + } + + &__image { margin: 0 0 $grid-unit-20; > img { display: block; diff --git a/packages/edit-site/src/components/welcome-guide/styles.js b/packages/edit-site/src/components/welcome-guide/styles.js index 6cc664c6373fa1..e6ec75ed7243f3 100644 --- a/packages/edit-site/src/components/welcome-guide/styles.js +++ b/packages/edit-site/src/components/welcome-guide/styles.js @@ -34,11 +34,13 @@ export default function WelcomeGuideStyles() { return null; } + const welcomeLabel = __( 'Welcome to Styles' ); + return ( <Guide - className="edit-site-welcome-guide" - contentLabel={ __( 'Welcome to styles' ) } - finishButtonText={ __( 'Get Started' ) } + className="edit-site-welcome-guide guide-styles" + contentLabel={ welcomeLabel } + finishButtonText={ __( 'Get started' ) } onFinish={ () => toggle( 'core/edit-site', 'welcomeGuideStyles' ) } pages={ [ { @@ -51,7 +53,7 @@ export default function WelcomeGuideStyles() { content: ( <> <h1 className="edit-site-welcome-guide__heading"> - { __( 'Welcome to Styles' ) } + { welcomeLabel } </h1> <p className="edit-site-welcome-guide__text"> { __( diff --git a/packages/edit-site/src/components/welcome-guide/template.js b/packages/edit-site/src/components/welcome-guide/template.js new file mode 100644 index 00000000000000..f0c02c09d1124a --- /dev/null +++ b/packages/edit-site/src/components/welcome-guide/template.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { Guide } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../store'; + +export default function WelcomeGuideTemplate() { + const { toggle } = useDispatch( preferencesStore ); + + const isVisible = useSelect( ( select ) => { + const isTemplateActive = !! select( preferencesStore ).get( + 'core/edit-site', + 'welcomeGuideTemplate' + ); + const isEditorActive = !! select( preferencesStore ).get( + 'core/edit-site', + 'welcomeGuide' + ); + const { isPage, hasPageContentFocus } = select( editSiteStore ); + return ( + isTemplateActive && + ! isEditorActive && + isPage() && + ! hasPageContentFocus() + ); + }, [] ); + + if ( ! isVisible ) { + return null; + } + + const heading = __( 'Editing a template' ); + + return ( + <Guide + className="edit-site-welcome-guide guide-template" + contentLabel={ heading } + finishButtonText={ __( 'Continue' ) } + onFinish={ () => + toggle( 'core/edit-site', 'welcomeGuideTemplate' ) + } + pages={ [ + { + image: ( + <video + className="edit-site-welcome-guide__video" + autoPlay + loop + muted + width="312" + height="240" + > + <source + src="https://s.w.org/images/block-editor/editing-your-template.mp4" + type="video/mp4" + /> + </video> + ), + content: ( + <> + <h1 className="edit-site-welcome-guide__heading"> + { heading } + </h1> + <p className="edit-site-welcome-guide__text"> + { __( + 'Note that the same template can be used by multiple pages, so any changes made here may affect other pages on the site. To switch back to editing the page content click the ‘Back’ button in the toolbar.' + ) } + </p> + </> + ), + }, + ] } + /> + ); +} diff --git a/packages/edit-site/src/hooks/commands/use-common-commands.js b/packages/edit-site/src/hooks/commands/use-common-commands.js new file mode 100644 index 00000000000000..16d07132ad7c74 --- /dev/null +++ b/packages/edit-site/src/hooks/commands/use-common-commands.js @@ -0,0 +1,330 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { trash, backup, help, styles, external, brush } from '@wordpress/icons'; +import { useCommandLoader, useCommand } from '@wordpress/commands'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { store as coreStore } from '@wordpress/core-data'; +import { useViewportMatch } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import getIsListPage from '../../utils/get-is-list-page'; + +const { useGlobalStylesReset } = unlock( blockEditorPrivateApis ); +const { useHistory, useLocation } = unlock( routerPrivateApis ); + +function useGlobalStylesOpenStylesCommands() { + const { openGeneralSidebar, setCanvasMode } = unlock( + useDispatch( editSiteStore ) + ); + const { params } = useLocation(); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const isEditorPage = ! getIsListPage( params, isMobileViewport ); + const { getCanvasMode } = unlock( useSelect( editSiteStore ) ); + const history = useHistory(); + + const isBlockBasedTheme = useSelect( ( select ) => { + return select( coreStore ).getCurrentTheme().is_block_theme; + }, [] ); + + const commands = useMemo( () => { + if ( ! isBlockBasedTheme ) { + return []; + } + + return [ + { + name: 'core/edit-site/open-styles', + label: __( 'Open styles' ), + callback: ( { close } ) => { + close(); + if ( ! isEditorPage ) { + history.push( { + path: '/wp_global_styles', + canvas: 'edit', + } ); + } + if ( isEditorPage && getCanvasMode() !== 'edit' ) { + setCanvasMode( 'edit' ); + } + openGeneralSidebar( 'edit-site/global-styles' ); + }, + icon: styles, + }, + ]; + }, [ + history, + openGeneralSidebar, + setCanvasMode, + isEditorPage, + getCanvasMode, + isBlockBasedTheme, + ] ); + + return { + isLoading: false, + commands, + }; +} + +function useGlobalStylesToggleWelcomeGuideCommands() { + const { openGeneralSidebar, setCanvasMode } = unlock( + useDispatch( editSiteStore ) + ); + const { params } = useLocation(); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const isEditorPage = ! getIsListPage( params, isMobileViewport ); + const { getCanvasMode } = unlock( useSelect( editSiteStore ) ); + const { set } = useDispatch( preferencesStore ); + + const history = useHistory(); + const isBlockBasedTheme = useSelect( ( select ) => { + return select( coreStore ).getCurrentTheme().is_block_theme; + }, [] ); + + const commands = useMemo( () => { + if ( ! isBlockBasedTheme ) { + return []; + } + + return [ + { + name: 'core/edit-site/toggle-styles-welcome-guide', + label: __( 'Learn about styles' ), + callback: ( { close } ) => { + close(); + if ( ! isEditorPage ) { + history.push( { + path: '/wp_global_styles', + canvas: 'edit', + } ); + } + if ( isEditorPage && getCanvasMode() !== 'edit' ) { + setCanvasMode( 'edit' ); + } + openGeneralSidebar( 'edit-site/global-styles' ); + set( 'core/edit-site', 'welcomeGuideStyles', true ); + // sometimes there's a focus loss that happens after some time + // that closes the modal, we need to force reopening it. + setTimeout( () => { + set( 'core/edit-site', 'welcomeGuideStyles', true ); + }, 500 ); + }, + icon: help, + }, + ]; + }, [ + history, + openGeneralSidebar, + setCanvasMode, + isEditorPage, + getCanvasMode, + isBlockBasedTheme, + set, + ] ); + + return { + isLoading: false, + commands, + }; +} + +function useGlobalStylesResetCommands() { + const [ canReset, onReset ] = useGlobalStylesReset(); + const commands = useMemo( () => { + if ( ! canReset ) { + return []; + } + + return [ + { + name: 'core/edit-site/reset-global-styles', + label: __( 'Reset styles to defaults' ), + icon: trash, + callback: ( { close } ) => { + close(); + onReset(); + }, + }, + ]; + }, [ canReset, onReset ] ); + + return { + isLoading: false, + commands, + }; +} + +function useGlobalStylesOpenCssCommands() { + const { openGeneralSidebar, setEditorCanvasContainerView, setCanvasMode } = + unlock( useDispatch( editSiteStore ) ); + const { params } = useLocation(); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const isListPage = getIsListPage( params, isMobileViewport ); + const isEditorPage = ! isListPage; + const history = useHistory(); + const { canEditCSS } = useSelect( ( select ) => { + const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = + select( coreStore ); + + const globalStylesId = __experimentalGetCurrentGlobalStylesId(); + const globalStyles = globalStylesId + ? getEntityRecord( 'root', 'globalStyles', globalStylesId ) + : undefined; + + return { + canEditCSS: + !! globalStyles?._links?.[ 'wp:action-edit-css' ] ?? false, + }; + }, [] ); + const { getCanvasMode } = unlock( useSelect( editSiteStore ) ); + + const commands = useMemo( () => { + if ( ! canEditCSS ) { + return []; + } + + return [ + { + name: 'core/edit-site/open-styles-css', + label: __( 'Customize CSS' ), + icon: brush, + callback: ( { close } ) => { + close(); + if ( ! isEditorPage ) { + history.push( { + path: '/wp_global_styles', + canvas: 'edit', + } ); + } + if ( isEditorPage && getCanvasMode() !== 'edit' ) { + setCanvasMode( 'edit' ); + } + openGeneralSidebar( 'edit-site/global-styles' ); + setEditorCanvasContainerView( 'global-styles-css' ); + }, + }, + ]; + }, [ + history, + openGeneralSidebar, + setEditorCanvasContainerView, + canEditCSS, + isEditorPage, + getCanvasMode, + setCanvasMode, + ] ); + return { + isLoading: false, + commands, + }; +} + +function useGlobalStylesOpenRevisionsCommands() { + const { openGeneralSidebar, setEditorCanvasContainerView, setCanvasMode } = + unlock( useDispatch( editSiteStore ) ); + const { getCanvasMode } = unlock( useSelect( editSiteStore ) ); + const { params } = useLocation(); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const isEditorPage = ! getIsListPage( params, isMobileViewport ); + const history = useHistory(); + const hasRevisions = useSelect( + ( select ) => + select( coreStore ).getCurrentThemeGlobalStylesRevisions()?.length, + [] + ); + const commands = useMemo( () => { + if ( ! hasRevisions ) { + return []; + } + + return [ + { + name: 'core/edit-site/open-global-styles-revisions', + label: __( 'Style revisions' ), + icon: backup, + callback: ( { close } ) => { + close(); + if ( ! isEditorPage ) { + history.push( { + path: '/wp_global_styles', + canvas: 'edit', + } ); + } + if ( isEditorPage && getCanvasMode() !== 'edit' ) { + setCanvasMode( 'edit' ); + } + openGeneralSidebar( 'edit-site/global-styles' ); + setEditorCanvasContainerView( 'global-styles-revisions' ); + }, + }, + ]; + }, [ + hasRevisions, + history, + openGeneralSidebar, + setEditorCanvasContainerView, + isEditorPage, + getCanvasMode, + setCanvasMode, + ] ); + + return { + isLoading: false, + commands, + }; +} + +export function useCommonCommands() { + const homeUrl = useSelect( ( select ) => { + const { + getUnstableBase, // Site index. + } = select( coreStore ); + + return getUnstableBase()?.home; + }, [] ); + + useCommand( { + name: 'core/edit-site/view-site', + label: __( 'View site' ), + callback: ( { close } ) => { + close(); + window.open( homeUrl, '_blank' ); + }, + icon: external, + } ); + + useCommandLoader( { + name: 'core/edit-site/open-styles', + hook: useGlobalStylesOpenStylesCommands, + } ); + + useCommandLoader( { + name: 'core/edit-site/toggle-styles-welcome-guide', + hook: useGlobalStylesToggleWelcomeGuideCommands, + } ); + + useCommandLoader( { + name: 'core/edit-site/reset-global-styles', + hook: useGlobalStylesResetCommands, + } ); + + useCommandLoader( { + name: 'core/edit-site/open-styles-css', + hook: useGlobalStylesOpenCssCommands, + } ); + + useCommandLoader( { + name: 'core/edit-site/open-styles-revisions', + hook: useGlobalStylesOpenRevisionsCommands, + } ); +} diff --git a/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js b/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js index 4aef33ac2b1cfc..76c7eea5137439 100644 --- a/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js +++ b/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js @@ -1,11 +1,27 @@ /** * WordPress dependencies */ -import { useDispatch } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { trash, backup } from '@wordpress/icons'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { __, sprintf, isRTL } from '@wordpress/i18n'; +import { + trash, + rotateLeft, + rotateRight, + layout, + page, + drawerLeft, + drawerRight, + blockDefault, + code, + keyboard, + listView, +} from '@wordpress/icons'; import { useCommandLoader } from '@wordpress/commands'; +import { decodeEntities } from '@wordpress/html-entities'; import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { store as interfaceStore } from '@wordpress/interface'; +import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies @@ -14,66 +30,354 @@ import { store as editSiteStore } from '../../store'; import useEditedEntityRecord from '../../components/use-edited-entity-record'; import isTemplateRemovable from '../../utils/is-template-removable'; import isTemplateRevertable from '../../utils/is-template-revertable'; -import { unlock } from '../../private-apis'; +import { KEYBOARD_SHORTCUT_HELP_MODAL_NAME } from '../../components/keyboard-shortcut-help-modal'; +import { PREFERENCES_MODAL_NAME } from '../../components/preferences-modal'; +import { unlock } from '../../lock-unlock'; const { useHistory } = unlock( routerPrivateApis ); -function useEditModeCommandLoader() { +function usePageContentFocusCommands() { + const { record: template } = useEditedEntityRecord(); + const { isPage, canvasMode, hasPageContentFocus } = useSelect( + ( select ) => ( { + isPage: select( editSiteStore ).isPage(), + canvasMode: unlock( select( editSiteStore ) ).getCanvasMode(), + hasPageContentFocus: select( editSiteStore ).hasPageContentFocus(), + } ), + [] + ); + const { setHasPageContentFocus } = useDispatch( editSiteStore ); + + if ( ! isPage || canvasMode !== 'edit' ) { + return { isLoading: false, commands: [] }; + } + + const commands = []; + + if ( hasPageContentFocus ) { + commands.push( { + name: 'core/switch-to-template-focus', + /* translators: %1$s: template title */ + label: sprintf( + 'Edit template: %s', + decodeEntities( template.title ) + ), + icon: layout, + callback: ( { close } ) => { + setHasPageContentFocus( false ); + close(); + }, + } ); + } else { + commands.push( { + name: 'core/switch-to-page-focus', + label: __( 'Back to page' ), + icon: page, + callback: ( { close } ) => { + setHasPageContentFocus( true ); + close(); + }, + } ); + } + + return { isLoading: false, commands }; +} + +function useEditorModeCommands() { + const { switchEditorMode } = useDispatch( editSiteStore ); + const { canvasMode, editorMode } = useSelect( + ( select ) => ( { + canvasMode: unlock( select( editSiteStore ) ).getCanvasMode(), + editorMode: select( editSiteStore ).getEditorMode(), + } ), + [] + ); + + if ( canvasMode !== 'edit' || editorMode !== 'text' ) { + return { isLoading: false, commands: [] }; + } + + const commands = []; + + if ( editorMode === 'text' ) { + commands.push( { + name: 'core/exit-code-editor', + label: __( 'Exit code editor' ), + icon: code, + callback: ( { close } ) => { + switchEditorMode( 'visual' ); + close(); + }, + } ); + } + + return { isLoading: false, commands }; +} + +function useManipulateDocumentCommands() { + const { isLoaded, record: template } = useEditedEntityRecord(); const { removeTemplate, revertTemplate } = useDispatch( editSiteStore ); const history = useHistory(); - const { isLoaded, record: template } = useEditedEntityRecord(); - const isRemovable = - isLoaded && !! template && isTemplateRemovable( template ); - const isRevertable = - isLoaded && !! template && isTemplateRevertable( template ); + const hasPageContentFocus = useSelect( + ( select ) => select( editSiteStore ).hasPageContentFocus(), + [] + ); + + if ( ! isLoaded ) { + return { isLoading: true, commands: [] }; + } const commands = []; - if ( isRemovable ) { + + if ( isTemplateRevertable( template ) && ! hasPageContentFocus ) { const label = template.type === 'wp_template' - ? __( 'Delete template' ) - : __( 'Delete template part' ); + ? /* translators: %1$s: template title */ + sprintf( + 'Reset template: %s', + decodeEntities( template.title ) + ) + : /* translators: %1$s: template part title */ + sprintf( + 'Reset template part: %s', + decodeEntities( template.title ) + ); + commands.push( { + name: 'core/reset-template', + label, + icon: isRTL() ? rotateRight : rotateLeft, + callback: ( { close } ) => { + revertTemplate( template ); + close(); + }, + } ); + } + + if ( isTemplateRemovable( template ) && ! hasPageContentFocus ) { + const label = + template.type === 'wp_template' + ? /* translators: %1$s: template title */ + sprintf( + 'Delete template: %s', + decodeEntities( template.title ) + ) + : /* translators: %1$s: template part title */ + sprintf( + 'Delete template part: %s', + decodeEntities( template.title ) + ); + const path = + template.type === 'wp_template' + ? '/wp_template' + : '/wp_template_part/all'; commands.push( { name: 'core/remove-template', label, icon: trash, - context: 'site-editor-edit', callback: ( { close } ) => { removeTemplate( template ); // Navigate to the template list history.push( { - path: '/' + template.type, + path, } ); close(); }, } ); } - if ( isRevertable ) { - const label = - template.type === 'wp_template' - ? __( 'Reset template' ) - : __( 'Reset template part' ); + + return { + isLoading: ! isLoaded, + commands, + }; +} + +function useEditUICommands() { + const { + openGeneralSidebar, + closeGeneralSidebar, + toggleDistractionFree, + setIsListViewOpened, + switchEditorMode, + } = useDispatch( editSiteStore ); + const { + canvasMode, + editorMode, + activeSidebar, + showBlockBreadcrumbs, + isListViewOpen, + isDistractionFree, + } = useSelect( ( select ) => { + const { isListViewOpened, getEditorMode } = select( editSiteStore ); + return { + canvasMode: unlock( select( editSiteStore ) ).getCanvasMode(), + editorMode: getEditorMode(), + activeSidebar: select( interfaceStore ).getActiveComplementaryArea( + editSiteStore.name + ), + showBlockBreadcrumbs: select( preferencesStore ).get( + 'core/edit-site', + 'showBlockBreadcrumbs' + ), + isListViewOpen: isListViewOpened(), + isDistractionFree: select( preferencesStore ).get( + editSiteStore.name, + 'distractionFree' + ), + }; + }, [] ); + const { openModal } = useDispatch( interfaceStore ); + const { toggle } = useDispatch( preferencesStore ); + const { createInfoNotice } = useDispatch( noticesStore ); + + if ( canvasMode !== 'edit' ) { + return { isLoading: false, commands: [] }; + } + + const commands = []; + + commands.push( { + name: 'core/open-settings-sidebar', + label: __( 'Toggle settings sidebar' ), + icon: isRTL() ? drawerLeft : drawerRight, + callback: ( { close } ) => { + close(); + if ( activeSidebar === 'edit-site/template' ) { + closeGeneralSidebar(); + } else { + openGeneralSidebar( 'edit-site/template' ); + } + }, + } ); + + commands.push( { + name: 'core/open-block-inspector', + label: __( 'Toggle block inspector' ), + icon: blockDefault, + callback: ( { close } ) => { + close(); + if ( activeSidebar === 'edit-site/block-inspector' ) { + closeGeneralSidebar(); + } else { + openGeneralSidebar( 'edit-site/block-inspector' ); + } + }, + } ); + + commands.push( { + name: 'core/toggle-spotlight-mode', + label: __( 'Toggle spotlight mode' ), + callback: ( { close } ) => { + toggle( 'core/edit-site', 'focusMode' ); + close(); + }, + } ); + + commands.push( { + name: 'core/toggle-distraction-free', + label: __( 'Toggle distraction free' ), + callback: ( { close } ) => { + toggleDistractionFree(); + close(); + }, + } ); + + commands.push( { + name: 'core/toggle-top-toolbar', + label: __( 'Toggle top toolbar' ), + callback: ( { close } ) => { + toggle( 'core/edit-site', 'fixedToolbar' ); + if ( isDistractionFree ) { + toggleDistractionFree(); + } + close(); + }, + } ); + + if ( editorMode === 'visual' ) { commands.push( { - name: 'core/reset-template', - label, - icon: backup, + name: 'core/toggle-code-editor', + label: __( 'Open code editor' ), + icon: code, callback: ( { close } ) => { - revertTemplate( template ); + switchEditorMode( 'text' ); close(); }, } ); } + commands.push( { + name: 'core/open-preferences', + label: __( 'Editor preferences' ), + callback: () => { + openModal( PREFERENCES_MODAL_NAME ); + }, + } ); + + commands.push( { + name: 'core/open-shortcut-help', + label: __( 'Keyboard shortcuts' ), + icon: keyboard, + callback: () => { + openModal( KEYBOARD_SHORTCUT_HELP_MODAL_NAME ); + }, + } ); + + commands.push( { + name: 'core/toggle-breadcrumbs', + label: showBlockBreadcrumbs + ? __( 'Hide block breadcrumbs' ) + : __( 'Show block breadcrumbs' ), + callback: ( { close } ) => { + toggle( 'core/edit-site', 'showBlockBreadcrumbs' ); + close(); + createInfoNotice( + showBlockBreadcrumbs + ? __( 'Breadcrumbs hidden.' ) + : __( 'Breadcrumbs visible.' ), + { + id: 'core/edit-site/toggle-breadcrumbs/notice', + type: 'snackbar', + } + ); + }, + } ); + + commands.push( { + name: 'core/toggle-list-view', + label: __( 'Toggle list view' ), + icon: listView, + callback: ( { close } ) => { + setIsListViewOpened( ! isListViewOpen ); + close(); + }, + } ); + return { - isLoading: ! isLoaded, + isLoading: false, commands, }; } export function useEditModeCommands() { useCommandLoader( { - name: 'core/edit-site/manipulate-document', - hook: useEditModeCommandLoader, + name: 'core/exit-code-editor', + hook: useEditorModeCommands, + context: 'site-editor-edit', + } ); + + useCommandLoader( { + name: 'core/edit-site/page-content-focus', + hook: usePageContentFocusCommands, context: 'site-editor-edit', } ); + + useCommandLoader( { + name: 'core/edit-site/manipulate-document', + hook: useManipulateDocumentCommands, + } ); + + useCommandLoader( { + name: 'core/edit-site/edit-ui', + hook: useEditUICommands, + } ); } diff --git a/packages/edit-site/src/hooks/index.js b/packages/edit-site/src/hooks/index.js index 513634c55b8f01..4e871f4e3824eb 100644 --- a/packages/edit-site/src/hooks/index.js +++ b/packages/edit-site/src/hooks/index.js @@ -4,3 +4,4 @@ import './components'; import './push-changes-to-global-styles'; import './template-part-edit'; +import './navigation-menu-edit'; diff --git a/packages/edit-site/src/hooks/navigation-menu-edit.js b/packages/edit-site/src/hooks/navigation-menu-edit.js new file mode 100644 index 00000000000000..d99ee57e0d953a --- /dev/null +++ b/packages/edit-site/src/hooks/navigation-menu-edit.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { BlockControls, useBlockEditingMode } from '@wordpress/block-editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { ToolbarButton } from '@wordpress/components'; +import { addFilter } from '@wordpress/hooks'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import { useLink } from '../components/routes/link'; +import { unlock } from '../lock-unlock'; + +const { useLocation } = unlock( routerPrivateApis ); + +function NavigationMenuEdit( { attributes } ) { + const { ref } = attributes; + const { params } = useLocation(); + const blockEditingMode = useBlockEditingMode(); + const navigationMenu = useSelect( + ( select ) => { + return select( coreStore ).getEntityRecord( + 'postType', + 'wp_navigation', + // Ideally this should be an official public API. + ref + ); + }, + [ ref ] + ); + + const linkProps = useLink( + { + postId: navigationMenu?.id, + postType: navigationMenu?.type, + canvas: 'edit', + }, + { + // this applies to Navigation Menus as well. + fromTemplateId: params.postId, + } + ); + + // A non-default setting for block editing mode indicates that the + // editor should restrict "editing" actions. Therefore the `Edit` button + // should not be displayed. + if ( ! navigationMenu || blockEditingMode !== 'default' ) { + return null; + } + + return ( + <BlockControls group="other"> + <ToolbarButton + { ...linkProps } + onClick={ ( event ) => { + linkProps.onClick( event ); + } } + > + { __( 'Edit' ) } + </ToolbarButton> + </BlockControls> + ); +} + +export const withEditBlockControls = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { attributes, name } = props; + const isDisplayed = name === 'core/navigation' && attributes.ref; + + return ( + <> + <BlockEdit { ...props } /> + { isDisplayed && ( + <NavigationMenuEdit attributes={ attributes } /> + ) } + </> + ); + }, + 'withEditBlockControls' +); + +addFilter( + 'editor.BlockEdit', + 'core/edit-site/navigation-edit-button', + withEditBlockControls +); diff --git a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js index 5ee0efcd6fa7c3..bd751ef4b312b3 100644 --- a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js +++ b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { get, set } from 'lodash'; - /** * WordPress dependencies */ @@ -12,12 +7,14 @@ import { InspectorAdvancedControls, store as blockEditorStore, privateApis as blockEditorPrivateApis, + useBlockEditingMode, } from '@wordpress/block-editor'; import { BaseControl, Button } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { - __EXPERIMENTAL_STYLE_PROPERTY as STYLE_PROPERTY, + __EXPERIMENTAL_STYLE_PROPERTY, getBlockType, + hasBlockSupport, } from '@wordpress/blocks'; import { useContext, useMemo, useCallback } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; @@ -27,14 +24,28 @@ import { store as noticesStore } from '@wordpress/notices'; * Internal dependencies */ import { useSupportedStyles } from '../../components/global-styles/hooks'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; + +const { + cleanEmptyObject, + GlobalStylesContext, + __experimentalUseGlobalBehaviors: useGlobalBehaviors, + __experimentalUseHasBehaviorsPanel: useHasBehaviorsPanel, +} = unlock( blockEditorPrivateApis ); -const { GlobalStylesContext } = unlock( blockEditorPrivateApis ); +// Block Gap is a special case and isn't defined within the blocks +// style properties config. We'll add it here to allow it to be pushed +// to global styles as well. +const STYLE_PROPERTY = { + ...__EXPERIMENTAL_STYLE_PROPERTY, + blockGap: { value: [ 'spacing', 'blockGap' ] }, +}; // TODO: Temporary duplication of constant in @wordpress/block-editor. Can be // removed by moving PushChangesToGlobalStylesControl to // @wordpress/block-editor. const STYLE_PATH_TO_CSS_VAR_INFIX = { + 'border.color': 'color', 'color.background': 'color', 'color.text': 'color', 'elements.link.color.text': 'color', @@ -76,6 +87,7 @@ const STYLE_PATH_TO_CSS_VAR_INFIX = { 'elements.h6.typography.fontFamily': 'font-family', 'elements.h6.color.gradient': 'gradient', 'color.gradient': 'gradient', + blockGap: 'spacing', 'typography.fontSize': 'font-size', 'typography.fontFamily': 'font-family', }; @@ -84,6 +96,7 @@ const STYLE_PATH_TO_CSS_VAR_INFIX = { // removed by moving PushChangesToGlobalStylesControl to // @wordpress/block-editor. const STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE = { + 'border.color': 'borderColor', 'color.background': 'backgroundColor', 'color.text': 'textColor', 'color.gradient': 'gradient', @@ -91,30 +104,177 @@ const STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE = { 'typography.fontFamily': 'fontFamily', }; -function useChangesToPush( name, attributes ) { +const SUPPORTED_STYLES = [ 'border', 'color', 'spacing', 'typography' ]; + +const getValueFromObjectPath = ( object, path ) => { + let value = object; + path.forEach( ( fieldName ) => { + value = value?.[ fieldName ]; + } ); + return value; +}; + +const flatBorderProperties = [ 'borderColor', 'borderWidth', 'borderStyle' ]; +const sides = [ 'top', 'right', 'bottom', 'left' ]; + +function getBorderStyleChanges( border, presetColor, userStyle ) { + if ( ! border && ! presetColor ) { + return []; + } + + const changes = [ + ...getFallbackBorderStyleChange( 'top', border, userStyle ), + ...getFallbackBorderStyleChange( 'right', border, userStyle ), + ...getFallbackBorderStyleChange( 'bottom', border, userStyle ), + ...getFallbackBorderStyleChange( 'left', border, userStyle ), + ]; + + // Handle a flat border i.e. all sides the same, CSS shorthand. + const { color: customColor, style, width } = border || {}; + const hasColorOrWidth = presetColor || customColor || width; + + if ( hasColorOrWidth && ! style ) { + // Global Styles need individual side configurations to overcome + // theme.json configurations which are per side as well. + sides.forEach( ( side ) => { + // Only add fallback border-style if global styles don't already + // have something set. + if ( ! userStyle?.[ side ]?.style ) { + changes.push( { + path: [ 'border', side, 'style' ], + value: 'solid', + } ); + } + } ); + } + + return changes; +} + +function getFallbackBorderStyleChange( side, border, globalBorderStyle ) { + if ( ! border?.[ side ] || globalBorderStyle?.[ side ]?.style ) { + return []; + } + + const { color, style, width } = border[ side ]; + const hasColorOrWidth = color || width; + + if ( ! hasColorOrWidth || style ) { + return []; + } + + return [ { path: [ 'border', side, 'style' ], value: 'solid' } ]; +} + +function useChangesToPush( name, attributes, userConfig ) { const supports = useSupportedStyles( name ); + const blockUserConfig = userConfig?.styles?.blocks?.[ name ]; + + return useMemo( () => { + const changes = supports.flatMap( ( key ) => { + if ( ! STYLE_PROPERTY[ key ] ) { + return []; + } + const { value: path } = STYLE_PROPERTY[ key ]; + const presetAttributeKey = path.join( '.' ); + const presetAttributeValue = + attributes[ + STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE[ presetAttributeKey ] + ]; + const value = presetAttributeValue + ? `var:preset|${ STYLE_PATH_TO_CSS_VAR_INFIX[ presetAttributeKey ] }|${ presetAttributeValue }` + : getValueFromObjectPath( attributes.style, path ); + + // Links only have a single support entry but have two element + // style properties, color and hover color. The following check + // will add the hover color to the changes if required. + if ( key === 'linkColor' ) { + const linkChanges = value ? [ { path, value } ] : []; + const hoverPath = [ + 'elements', + 'link', + ':hover', + 'color', + 'text', + ]; + const hoverValue = getValueFromObjectPath( + attributes.style, + hoverPath + ); - return useMemo( - () => - supports.flatMap( ( key ) => { - if ( ! STYLE_PROPERTY[ key ] ) { - return []; + if ( hoverValue ) { + linkChanges.push( { path: hoverPath, value: hoverValue } ); } - const { value: path } = STYLE_PROPERTY[ key ]; - const presetAttributeKey = path.join( '.' ); - const presetAttributeValue = - attributes[ - STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE[ - presetAttributeKey - ] - ]; - const value = presetAttributeValue - ? `var:preset|${ STYLE_PATH_TO_CSS_VAR_INFIX[ presetAttributeKey ] }|${ presetAttributeValue }` - : get( attributes.style, path ); - return value ? [ { path, value } ] : []; - } ), - [ supports, name, attributes ] - ); + + return linkChanges; + } + + // The shorthand border styles can't be mapped directly as global + // styles requires longhand config. + if ( flatBorderProperties.includes( key ) && value ) { + // The shorthand config path is included to clear the block attribute. + const borderChanges = [ { path, value } ]; + sides.forEach( ( side ) => { + const currentPath = [ ...path ]; + currentPath.splice( -1, 0, side ); + borderChanges.push( { path: currentPath, value } ); + } ); + return borderChanges; + } + + return value ? [ { path, value } ] : []; + } ); + + // To ensure display of a visible border, global styles require a + // default border style if a border color or width is present. + getBorderStyleChanges( + attributes.style?.border, + attributes.borderColor, + blockUserConfig?.border + ).forEach( ( change ) => changes.push( change ) ); + + return changes; + }, [ supports, attributes, blockUserConfig ] ); +} + +/** + * Sets the value at path of object. + * If a portion of path doesn’t exist, it’s created. + * Arrays are created for missing index properties while objects are created + * for all other missing properties. + * + * This function intentionally mutates the input object. + * + * Inspired by _.set(). + * + * @see https://lodash.com/docs/4.17.15#set + * + * @todo Needs to be deduplicated with its copy in `@wordpress/core-data`. + * + * @param {Object} object Object to modify + * @param {Array} path Path of the property to set. + * @param {*} value Value to set. + */ +function setNestedValue( object, path, value ) { + if ( ! object || typeof object !== 'object' ) { + return object; + } + + path.reduce( ( acc, key, idx ) => { + if ( acc[ key ] === undefined ) { + if ( Number.isInteger( path[ idx + 1 ] ) ) { + acc[ key ] = []; + } else { + acc[ key ] = {}; + } + } + if ( idx === path.length - 1 ) { + acc[ key ] = value; + } + return acc[ key ]; + }, object ); + + return object; } function cloneDeep( object ) { @@ -126,61 +286,125 @@ function PushChangesToGlobalStylesControl( { attributes, setAttributes, } ) { - const changes = useChangesToPush( name, attributes ); + const hasBehaviorsPanel = useHasBehaviorsPanel( attributes, name, { + blockSupportOnly: true, + } ); const { user: userConfig, setUserConfig } = useContext( GlobalStylesContext ); + const changes = useChangesToPush( name, attributes, userConfig ); + const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( blockEditorStore ); const { createSuccessNotice } = useDispatch( noticesStore ); + const { inheritedBehaviors, setBehavior } = useGlobalBehaviors( name ); + + const userHasEditedBehaviors = + attributes.hasOwnProperty( 'behaviors' ) && hasBehaviorsPanel; + const pushChanges = useCallback( () => { - if ( changes.length === 0 ) { + if ( changes.length === 0 && ! userHasEditedBehaviors ) { return; } - const { style: blockStyles } = attributes; + if ( changes.length > 0 ) { + const { style: blockStyles } = attributes; - const newBlockStyles = cloneDeep( blockStyles ); - const newUserConfig = cloneDeep( userConfig ); + const newBlockStyles = cloneDeep( blockStyles ); + const newUserConfig = cloneDeep( userConfig ); - for ( const { path, value } of changes ) { - set( newBlockStyles, path, undefined ); - set( newUserConfig, [ 'styles', 'blocks', name, ...path ], value ); - } + for ( const { path, value } of changes ) { + setNestedValue( newBlockStyles, path, undefined ); + setNestedValue( + newUserConfig, + [ 'styles', 'blocks', name, ...path ], + value + ); + } - // @wordpress/core-data doesn't support editing multiple entity types in - // a single undo level. So for now, we disable @wordpress/core-data undo - // tracking and implement our own Undo button in the snackbar - // notification. - __unstableMarkNextChangeAsNotPersistent(); - setAttributes( { style: newBlockStyles } ); - setUserConfig( () => newUserConfig, { undoIgnore: true } ); + const newBlockAttributes = { + borderColor: undefined, + backgroundColor: undefined, + textColor: undefined, + gradient: undefined, + fontSize: undefined, + fontFamily: undefined, + style: cleanEmptyObject( newBlockStyles ), + }; - createSuccessNotice( - sprintf( - // translators: %s: Title of the block e.g. 'Heading'. - __( '%s styles applied.' ), - getBlockType( name ).title - ), - { - type: 'snackbar', - actions: [ - { - label: __( 'Undo' ), - onClick() { - __unstableMarkNextChangeAsNotPersistent(); - setAttributes( { style: blockStyles } ); - setUserConfig( () => userConfig, { - undoIgnore: true, - } ); + // @wordpress/core-data doesn't support editing multiple entity types in + // a single undo level. So for now, we disable @wordpress/core-data undo + // tracking and implement our own Undo button in the snackbar + // notification. + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( newBlockAttributes ); + setUserConfig( () => newUserConfig, { undoIgnore: true } ); + createSuccessNotice( + sprintf( + // translators: %s: Title of the block e.g. 'Heading'. + __( '%s styles applied.' ), + getBlockType( name ).title + ), + { + type: 'snackbar', + actions: [ + { + label: __( 'Undo' ), + onClick() { + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( attributes ); + setUserConfig( () => userConfig, { + undoIgnore: true, + } ); + }, }, - }, - ], - } - ); - }, [ changes, attributes, userConfig, name ] ); + ], + } + ); + } + + if ( userHasEditedBehaviors ) { + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( { behaviors: undefined } ); + setBehavior( attributes.behaviors ); + createSuccessNotice( + sprintf( + // translators: %s: Title of the block e.g. 'Heading'. + __( '%s behaviors applied.' ), + getBlockType( name ).title + ), + { + type: 'snackbar', + actions: [ + { + label: __( 'Undo' ), + onClick() { + __unstableMarkNextChangeAsNotPersistent(); + setBehavior( inheritedBehaviors ); + setUserConfig( () => userConfig, { + undoIgnore: true, + } ); + }, + }, + ], + } + ); + } + }, [ + __unstableMarkNextChangeAsNotPersistent, + attributes, + changes, + createSuccessNotice, + inheritedBehaviors, + name, + setAttributes, + setBehavior, + setUserConfig, + userConfig, + userHasEditedBehaviors, + ] ); return ( <BaseControl @@ -188,7 +412,7 @@ function PushChangesToGlobalStylesControl( { help={ sprintf( // translators: %s: Title of the block e.g. 'Heading'. __( - 'Apply this block’s typography, spacing, dimensions, and color styles to all %s blocks.' + 'Apply this block’s typography, spacing, dimensions, color styles, and behaviors to all %s blocks.' ), getBlockType( name ).title ) } @@ -198,7 +422,7 @@ function PushChangesToGlobalStylesControl( { </BaseControl.VisualLabel> <Button variant="primary" - disabled={ changes.length === 0 } + disabled={ changes.length === 0 && ! userHasEditedBehaviors } onClick={ pushChanges } > { __( 'Apply globally' ) } @@ -208,15 +432,23 @@ function PushChangesToGlobalStylesControl( { } const withPushChangesToGlobalStyles = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => - ( + ( BlockEdit ) => ( props ) => { + const blockEditingMode = useBlockEditingMode(); + const supportsStyles = SUPPORTED_STYLES.some( ( feature ) => + hasBlockSupport( props.name, feature ) + ); + + return ( <> <BlockEdit { ...props } /> - <InspectorAdvancedControls> - <PushChangesToGlobalStylesControl { ...props } /> - </InspectorAdvancedControls> + { blockEditingMode === 'default' && supportsStyles && ( + <InspectorAdvancedControls> + <PushChangesToGlobalStylesControl { ...props } /> + </InspectorAdvancedControls> + ) } </> - ) + ); + } ); addFilter( diff --git a/packages/edit-site/src/hooks/template-part-edit.js b/packages/edit-site/src/hooks/template-part-edit.js index e59cf49acefc20..07fb717b85af18 100644 --- a/packages/edit-site/src/hooks/template-part-edit.js +++ b/packages/edit-site/src/hooks/template-part-edit.js @@ -14,7 +14,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; * Internal dependencies */ import { useLink } from '../components/routes/link'; -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js index e696a441bb10da..f391e21d911733 100644 --- a/packages/edit-site/src/index.js +++ b/packages/edit-site/src/index.js @@ -43,7 +43,7 @@ export function initializeEditor( id, settings ) { fetchLinkSuggestions( search, searchOptions, settings ); settings.__experimentalFetchRichUrlData = fetchUrlData; - dispatch( blocksStore ).__experimentalReapplyBlockTypeFilters(); + dispatch( blocksStore ).reapplyBlockTypeFilters(); const coreBlocks = __experimentalGetCoreBlocks().filter( ( { name } ) => name !== 'core/freeform' ); @@ -60,12 +60,16 @@ export function initializeEditor( id, settings ) { // We dispatch actions and update the store synchronously before rendering // so that we won't trigger unnecessary re-renders with useEffect. dispatch( preferencesStore ).setDefaults( 'core/edit-site', { + allowRightClickOverrides: true, editorMode: 'visual', fixedToolbar: false, focusMode: false, + distractionFree: false, keepCaretInsideBlock: false, welcomeGuide: true, welcomeGuideStyles: true, + welcomeGuidePage: true, + welcomeGuideTemplate: true, showListViewByDefault: false, showBlockBreadcrumbs: true, } ); @@ -106,3 +110,4 @@ export { default as PluginSidebar } from './components/sidebar-edit-mode/plugin- export { default as PluginSidebarMoreMenuItem } from './components/header-edit-mode/plugin-sidebar-more-menu-item'; export { default as PluginMoreMenuItem } from './components/header-edit-mode/plugin-more-menu-item'; export { default as PluginTemplateSettingPanel } from './components/plugin-template-setting-panel'; +export { store } from './store'; diff --git a/packages/edit-site/src/private-apis.js b/packages/edit-site/src/lock-unlock.js similarity index 100% rename from packages/edit-site/src/private-apis.js rename to packages/edit-site/src/lock-unlock.js diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index d1f41d5a62aca5..0ad521a9c9a54e 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -12,6 +12,7 @@ import { store as interfaceStore } from '@wordpress/interface'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { speak } from '@wordpress/a11y'; import { store as preferencesStore } from '@wordpress/preferences'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -140,11 +141,18 @@ export const removeTemplate = throw lastError; } + // Depending on how the entity was retrieved it's title might be + // an object or simple string. + const templateTitle = + typeof template.title === 'string' + ? template.title + : template.title?.rendered; + registry.dispatch( noticesStore ).createSuccessNotice( sprintf( /* translators: The template/part's name. */ __( '"%s" deleted.' ), - template.title.rendered + decodeEntities( templateTitle ) ), { type: 'snackbar', id: 'site-editor-template-deleted-success' } ); @@ -175,6 +183,37 @@ export function setTemplatePart( templatePartId ) { }; } +/** + * Action that sets a navigation menu. + * + * @param {string} navigationMenuId The Navigation Menu Post ID. + * + * @return {Object} Action object. + */ +export function setNavigationMenu( navigationMenuId ) { + return { + type: 'SET_EDITED_POST', + postType: 'wp_navigation', + id: navigationMenuId, + }; +} + +/** + * Action that sets an edited entity. + * + * @param {string} postType The entity's post type. + * @param {string} postId The entity's ID. + * + * @return {Object} Action object. + */ +export function setEditedEntity( postType, postId ) { + return { + type: 'SET_EDITED_POST', + postType, + id: postId, + }; +} + /** * @deprecated */ @@ -334,12 +373,20 @@ export function updateSettings( settings ) { * @param {boolean} isOpen If true, opens the list view. If false, closes it. * It does not toggle the state, but sets it directly. */ -export function setIsListViewOpened( isOpen ) { - return { - type: 'SET_IS_LIST_VIEW_OPENED', - isOpen, +export const setIsListViewOpened = + ( isOpen ) => + ( { dispatch, registry } ) => { + const isDistractionFree = registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ); + if ( isDistractionFree && isOpen ) { + dispatch.toggleDistractionFree(); + } + dispatch( { + type: 'SET_IS_LIST_VIEW_OPENED', + isOpen, + } ); }; -} /** * Sets whether the save view panel should be open. @@ -494,7 +541,13 @@ export const revertTemplate = */ export const openGeneralSidebar = ( name ) => - ( { registry } ) => { + ( { dispatch, registry } ) => { + const isDistractionFree = registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ); + if ( isDistractionFree ) { + dispatch.toggleDistractionFree(); + } registry .dispatch( interfaceStore ) .enableComplementaryArea( editSiteStoreName, name ); @@ -513,7 +566,7 @@ export const closeGeneralSidebar = export const switchEditorMode = ( mode ) => - ( { registry } ) => { + ( { dispatch, registry } ) => { registry .dispatch( 'core/preferences' ) .set( 'core/edit-site', 'editorMode', mode ); @@ -526,6 +579,74 @@ export const switchEditorMode = if ( mode === 'visual' ) { speak( __( 'Visual editor selected' ), 'assertive' ); } else if ( mode === 'text' ) { + const isDistractionFree = registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ); + if ( isDistractionFree ) { + dispatch.toggleDistractionFree(); + } speak( __( 'Code editor selected' ), 'assertive' ); } }; + +/** + * Sets whether or not the editor allows only page content to be edited. + * + * @param {boolean} hasPageContentFocus True to allow only page content to be + * edited, false to allow template to be + * edited. + */ +export const setHasPageContentFocus = + ( hasPageContentFocus ) => + ( { dispatch, registry } ) => { + if ( hasPageContentFocus ) { + registry.dispatch( blockEditorStore ).clearSelectedBlock(); + } + dispatch( { + type: 'SET_HAS_PAGE_CONTENT_FOCUS', + hasPageContentFocus, + } ); + }; + +/** + * Action that toggles Distraction free mode. + * Distraction free mode expects there are no sidebars, as due to the + * z-index values set, you can't close sidebars. + */ +export const toggleDistractionFree = + () => + ( { dispatch, registry } ) => { + const isDistractionFree = registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ); + if ( ! isDistractionFree ) { + registry.batch( () => { + registry + .dispatch( preferencesStore ) + .set( 'core/edit-site', 'fixedToolbar', false ); + dispatch.setIsInserterOpened( false ); + dispatch.setIsListViewOpened( false ); + dispatch.closeGeneralSidebar(); + } ); + } + registry.batch( () => { + registry + .dispatch( preferencesStore ) + .set( + 'core/edit-site', + 'distractionFree', + ! isDistractionFree + ); + registry + .dispatch( noticesStore ) + .createInfoNotice( + isDistractionFree + ? __( 'Distraction free off.' ) + : __( 'Distraction free on.' ), + { + id: 'core/edit-site/distraction-free-mode/notice', + type: 'snackbar', + } + ); + } ); + }; diff --git a/packages/edit-site/src/store/index.js b/packages/edit-site/src/store/index.js index b2ef96d76a8e4c..dff8d41db828c2 100644 --- a/packages/edit-site/src/store/index.js +++ b/packages/edit-site/src/store/index.js @@ -12,7 +12,7 @@ import * as privateActions from './private-actions'; import * as selectors from './selectors'; import * as privateSelectors from './private-selectors'; import { STORE_NAME } from './constants'; -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; export const storeConfig = { reducer, diff --git a/packages/edit-site/src/store/private-actions.js b/packages/edit-site/src/store/private-actions.js index 952c1852ae305c..f3dd4c10cec43e 100644 --- a/packages/edit-site/src/store/private-actions.js +++ b/packages/edit-site/src/store/private-actions.js @@ -11,21 +11,29 @@ import { store as preferencesStore } from '@wordpress/preferences'; */ export const setCanvasMode = ( mode ) => - ( { registry, dispatch } ) => { + ( { registry, dispatch, select } ) => { registry.dispatch( blockEditorStore ).__unstableSetEditorMode( 'edit' ); dispatch( { type: 'SET_CANVAS_MODE', mode, } ); // Check if the block list view should be open by default. + // If `distractionFree` mode is enabled, the block list view should not be open. if ( mode === 'edit' && registry .select( preferencesStore ) - .get( 'core/edit-site', 'showListViewByDefault' ) + .get( 'core/edit-site', 'showListViewByDefault' ) && + ! registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ) ) { dispatch.setIsListViewOpened( true ); } + // Switch focus away from editing the template when switching to view mode. + if ( mode === 'view' && select.isPage() ) { + dispatch.setHasPageContentFocus( true ); + } }; /** diff --git a/packages/edit-site/src/store/reducer.js b/packages/edit-site/src/store/reducer.js index a46d215f905074..4b4689e26c561e 100644 --- a/packages/edit-site/src/store/reducer.js +++ b/packages/edit-site/src/store/reducer.js @@ -157,6 +157,26 @@ function editorCanvasContainerView( state = undefined, action ) { return state; } +/** + * Reducer used to track whether the editor allows only page content to be + * edited. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function hasPageContentFocus( state = false, action ) { + switch ( action.type ) { + case 'SET_EDITED_POST': + return !! action.context?.postId; + case 'SET_HAS_PAGE_CONTENT_FOCUS': + return action.hasPageContentFocus; + } + + return state; +} + export default combineReducers( { deviceType, settings, @@ -166,4 +186,5 @@ export default combineReducers( { saveViewPanel, canvasMode, editorCanvasContainerView, + hasPageContentFocus, } ); diff --git a/packages/edit-site/src/store/selectors.js b/packages/edit-site/src/store/selectors.js index 583f37b55241bd..20e4e141e254af 100644 --- a/packages/edit-site/src/store/selectors.js +++ b/packages/edit-site/src/store/selectors.js @@ -12,6 +12,7 @@ import deprecated from '@wordpress/deprecated'; import { uploadMedia } from '@wordpress/media-utils'; import { Platform } from '@wordpress/element'; import { store as preferencesStore } from '@wordpress/preferences'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -106,6 +107,10 @@ export const getSettings = createSelector( ...state.settings, outlineMode: true, focusMode: !! __unstableGetPreference( state, 'focusMode' ), + isDistractionFree: !! __unstableGetPreference( + state, + 'distractionFree' + ), hasFixedToolbar: !! __unstableGetPreference( state, 'fixedToolbar' @@ -118,6 +123,10 @@ export const getSettings = createSelector( state, 'showIconLabels' ), + allowRightClickOverrides: !! __unstableGetPreference( + state, + 'allowRightClickOverrides' + ), __experimentalSetIsInserterOpened: setIsInserterOpen, __experimentalReusableBlocks: getReusableBlocks( state ), __experimentalPreferPatternsOnRoot: @@ -142,6 +151,7 @@ export const getSettings = createSelector( getCanUserCreateMedia( state ), state.settings, __unstableGetPreference( state, 'focusMode' ), + __unstableGetPreference( state, 'distractionFree' ), __unstableGetPreference( state, 'fixedToolbar' ), __unstableGetPreference( state, 'keepCaretInsideBlock' ), __unstableGetPreference( state, 'showIconLabels' ), @@ -224,11 +234,35 @@ export function isInserterOpened( state ) { * * @return {Object} The root client ID, index to insert at and starting filter value. */ -export function __experimentalGetInsertionPoint( state ) { - const { rootClientId, insertionIndex, filterValue } = - state.blockInserterPanel; - return { rootClientId, insertionIndex, filterValue }; -} +export const __experimentalGetInsertionPoint = createRegistrySelector( + ( select ) => ( state ) => { + if ( typeof state.blockInserterPanel === 'object' ) { + const { rootClientId, insertionIndex, filterValue } = + state.blockInserterPanel; + return { rootClientId, insertionIndex, filterValue }; + } + + if ( hasPageContentFocus( state ) ) { + const [ postContentClientId ] = + select( blockEditorStore ).__experimentalGetGlobalBlocksByName( + 'core/post-content' + ); + if ( postContentClientId ) { + return { + rootClientId: postContentClientId, + insertionIndex: undefined, + filterValue: undefined, + }; + } + } + + return { + rootClientId: undefined, + insertionIndex: undefined, + filterValue: undefined, + }; + } +); /** * Returns the current opened/closed state of the list view panel. @@ -321,3 +355,27 @@ export function isNavigationOpened() { version: '6.4', } ); } + +/** + * Whether or not the editor has a page loaded into it. + * + * @see setPage + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether or not the editor has a page loaded into it. + */ +export function isPage( state ) { + return !! state.editedPost.context?.postId; +} + +/** + * Whether or not the editor allows only page content to be edited. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether or not focus is on editing page content. + */ +export function hasPageContentFocus( state ) { + return isPage( state ) ? state.hasPageContentFocus : false; +} diff --git a/packages/edit-site/src/store/test/actions.js b/packages/edit-site/src/store/test/actions.js index 2df1cc72b66115..345fcddbbba3b2 100644 --- a/packages/edit-site/src/store/test/actions.js +++ b/packages/edit-site/src/store/test/actions.js @@ -13,6 +13,7 @@ import { store as preferencesStore } from '@wordpress/preferences'; * Internal dependencies */ import { store as editSiteStore } from '..'; +import { setHasPageContentFocus } from '../actions'; const ENTITY_TYPES = { wp_template: { @@ -214,5 +215,123 @@ describe( 'actions', () => { false ); } ); + it( 'should turn off distraction free mode when opening the list view', () => { + const registry = createRegistryWithStores(); + registry + .dispatch( preferencesStore ) + .set( 'core/edit-site', 'distractionFree', true ); + registry.dispatch( editSiteStore ).setIsListViewOpened( true ); + expect( + registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ) + ).toBe( false ); + } ); + } ); + + describe( 'openGeneralSidebar', () => { + it( 'should turn off distraction free mode when opening a general sidebar', () => { + const registry = createRegistryWithStores(); + registry + .dispatch( preferencesStore ) + .set( 'core/edit-site', 'distractionFree', true ); + + registry + .dispatch( editSiteStore ) + .openGeneralSidebar( 'edit-site/global-styles' ); + expect( + registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ) + ).toBe( false ); + } ); + } ); + + describe( 'switchEditorMode', () => { + it( 'should turn off distraction free mode when switching to code editor', () => { + const registry = createRegistryWithStores(); + registry + .dispatch( preferencesStore ) + .set( 'core/edit-site', 'distractionFree', true ); + registry.dispatch( editSiteStore ).switchEditorMode( 'visual' ); + expect( + registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ) + ).toBe( true ); + registry.dispatch( editSiteStore ).switchEditorMode( 'text' ); + expect( + registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ) + ).toBe( false ); + } ); + } ); + + describe( 'toggleDistractionFree', () => { + it( 'should properly update settings to prevent layout corruption when enabling distraction free mode', () => { + const registry = createRegistryWithStores(); + // Enable everything that shouldn't be enabled in distraction free mode. + registry + .dispatch( preferencesStore ) + .set( 'core/edit-site', 'fixedToolbar', true ); + registry.dispatch( editSiteStore ).setIsListViewOpened( true ); + registry + .dispatch( editSiteStore ) + .openGeneralSidebar( 'edit-site/global-styles' ); + // Initial state is falsy. + registry.dispatch( editSiteStore ).toggleDistractionFree(); + expect( + registry + .select( preferencesStore ) + .get( 'core/edit-site', 'fixedToolbar' ) + ).toBe( false ); + expect( registry.select( editSiteStore ).isListViewOpened() ).toBe( + false + ); + expect( registry.select( editSiteStore ).isInserterOpened() ).toBe( + false + ); + expect( + registry + .select( interfaceStore ) + .getActiveComplementaryArea( editSiteStore.name ) + ).toBeNull(); + expect( + registry + .select( preferencesStore ) + .get( 'core/edit-site', 'distractionFree' ) + ).toBe( true ); + } ); + } ); + + describe( 'setHasPageContentFocus', () => { + it( 'toggles the page content lock on', () => { + const dispatch = jest.fn(); + const clearSelectedBlock = jest.fn(); + const registry = { + dispatch: () => ( { clearSelectedBlock } ), + }; + setHasPageContentFocus( true )( { dispatch, registry } ); + expect( clearSelectedBlock ).toHaveBeenCalled(); + expect( dispatch ).toHaveBeenCalledWith( { + type: 'SET_HAS_PAGE_CONTENT_FOCUS', + hasPageContentFocus: true, + } ); + } ); + + it( 'toggles the page content lock off', () => { + const dispatch = jest.fn(); + const clearSelectedBlock = jest.fn(); + const registry = { + dispatch: () => ( { clearSelectedBlock } ), + }; + setHasPageContentFocus( false )( { dispatch, registry } ); + expect( clearSelectedBlock ).not.toHaveBeenCalled(); + expect( dispatch ).toHaveBeenCalledWith( { + type: 'SET_HAS_PAGE_CONTENT_FOCUS', + hasPageContentFocus: false, + } ); + } ); } ); } ); diff --git a/packages/edit-site/src/store/test/reducer.js b/packages/edit-site/src/store/test/reducer.js index f6ce205ad63533..fc06faf925b386 100644 --- a/packages/edit-site/src/store/test/reducer.js +++ b/packages/edit-site/src/store/test/reducer.js @@ -11,9 +11,10 @@ import { editedPost, blockInserterPanel, listViewPanel, + hasPageContentFocus, } from '../reducer'; -import { setIsInserterOpened, setIsListViewOpened } from '../actions'; +import { setIsInserterOpened } from '../actions'; describe( 'state', () => { describe( 'settings()', () => { @@ -94,13 +95,19 @@ describe( 'state', () => { it( 'should close the inserter when opening the list view panel', () => { expect( - blockInserterPanel( true, setIsListViewOpened( true ) ) + blockInserterPanel( true, { + type: 'SET_IS_LIST_VIEW_OPENED', + isOpen: true, + } ) ).toBe( false ); } ); it( 'should not change the state when closing the list view panel', () => { expect( - blockInserterPanel( true, setIsListViewOpened( false ) ) + blockInserterPanel( true, { + type: 'SET_IS_LIST_VIEW_OPENED', + isOpen: false, + } ) ).toBe( true ); } ); } ); @@ -115,12 +122,19 @@ describe( 'state', () => { } ); it( 'should set the open state of the list view panel', () => { - expect( listViewPanel( false, setIsListViewOpened( true ) ) ).toBe( - true - ); - expect( listViewPanel( true, setIsListViewOpened( false ) ) ).toBe( - false - ); + // registry.dispatch( editSiteStore ).toggleFeature( 'name' ); + expect( + listViewPanel( false, { + type: 'SET_IS_LIST_VIEW_OPENED', + isOpen: true, + } ) + ).toBe( true ); + expect( + listViewPanel( true, { + type: 'SET_IS_LIST_VIEW_OPENED', + isOpen: false, + } ) + ).toBe( false ); } ); it( 'should close the list view when opening the inserter panel', () => { @@ -135,4 +149,47 @@ describe( 'state', () => { ); } ); } ); + + describe( 'hasPageContentFocus()', () => { + it( 'defaults to false', () => { + expect( hasPageContentFocus( undefined, {} ) ).toBe( false ); + } ); + + it( 'becomes false when editing a template', () => { + expect( + hasPageContentFocus( true, { + type: 'SET_EDITED_POST', + postType: 'wp_template', + } ) + ).toBe( false ); + } ); + + it( 'becomes true when editing a page', () => { + expect( + hasPageContentFocus( false, { + type: 'SET_EDITED_POST', + postType: 'wp_template', + context: { + postType: 'page', + postId: 123, + }, + } ) + ).toBe( true ); + } ); + + it( 'can be set', () => { + expect( + hasPageContentFocus( false, { + type: 'SET_HAS_PAGE_CONTENT_FOCUS', + hasPageContentFocus: true, + } ) + ).toBe( true ); + expect( + hasPageContentFocus( true, { + type: 'SET_HAS_PAGE_CONTENT_FOCUS', + hasPageContentFocus: false, + } ) + ).toBe( false ); + } ); + } ); } ); diff --git a/packages/edit-site/src/store/test/selectors.js b/packages/edit-site/src/store/test/selectors.js index 223bcd1f0ba041..f974e7a8725130 100644 --- a/packages/edit-site/src/store/test/selectors.js +++ b/packages/edit-site/src/store/test/selectors.js @@ -15,6 +15,8 @@ import { isInserterOpened, isListViewOpened, __unstableGetPreference, + isPage, + hasPageContentFocus, } from '../selectors'; describe( 'selectors', () => { @@ -72,9 +74,11 @@ describe( 'selectors', () => { }; const setInserterOpened = () => {}; expect( getSettings( state, setInserterOpened ) ).toEqual( { + allowRightClickOverrides: false, outlineMode: true, focusMode: false, hasFixedToolbar: false, + isDistractionFree: false, keepCaretInsideBlock: false, showIconLabels: false, __experimentalSetIsInserterOpened: setInserterOpened, @@ -96,10 +100,12 @@ describe( 'selectors', () => { const setInserterOpened = () => {}; expect( getSettings( state, setInserterOpened ) ).toEqual( { + allowRightClickOverrides: false, outlineMode: true, key: 'value', focusMode: true, hasFixedToolbar: true, + isDistractionFree: false, keepCaretInsideBlock: false, showIconLabels: false, __experimentalSetIsInserterOpened: setInserterOpened, @@ -145,4 +151,59 @@ describe( 'selectors', () => { expect( isListViewOpened( state ) ).toBe( false ); } ); } ); + + describe( 'isPage', () => { + it( 'returns true if the edited post type is a page', () => { + const state = { + editedPost: { + postType: 'wp_template', + context: { postType: 'page', postId: 123 }, + }, + }; + expect( isPage( state ) ).toBe( true ); + } ); + + it( 'returns false if the edited post type is a template', () => { + const state = { + editedPost: { + postType: 'wp_template', + }, + }; + expect( isPage( state ) ).toBe( false ); + } ); + } ); + + describe( 'hasPageContentFocus', () => { + it( 'returns true if locked and the edited post type is a page', () => { + const state = { + editedPost: { + postType: 'wp_template', + context: { postType: 'page', postId: 123 }, + }, + hasPageContentFocus: true, + }; + expect( hasPageContentFocus( state ) ).toBe( true ); + } ); + + it( 'returns false if not locked and the edited post type is a page', () => { + const state = { + editedPost: { + postType: 'wp_template', + context: { postType: 'page', postId: 123 }, + }, + hasPageContentFocus: false, + }; + expect( hasPageContentFocus( state ) ).toBe( false ); + } ); + + it( 'returns false if locked and the edited post type is a template', () => { + const state = { + editedPost: { + postType: 'wp_template', + }, + hasPageContentFocus: true, + }; + expect( hasPageContentFocus( state ) ).toBe( false ); + } ); + } ); } ); diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 3be15cd02d2599..accb42997f5efc 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -2,16 +2,21 @@ @import "./components/add-new-template/style.scss"; @import "./components/block-editor/style.scss"; -@import "./components/canvas-spinner/style.scss"; +@import "./components/canvas-loader/style.scss"; @import "./components/code-editor/style.scss"; @import "./components/global-styles/style.scss"; @import "./components/global-styles/screen-revisions/style.scss"; @import "./components/header-edit-mode/style.scss"; @import "./components/header-edit-mode/document-actions/style.scss"; @import "./components/list/style.scss"; +@import "./components/page/style.scss"; +@import "./components/page-patterns/style.scss"; +@import "./components/table/style.scss"; @import "./components/sidebar-edit-mode/style.scss"; +@import "./components/sidebar-edit-mode/page-panels/style.scss"; @import "./components/sidebar-edit-mode/settings-header/style.scss"; -@import "./components/sidebar-edit-mode/template-card/style.scss"; +@import "./components/sidebar-edit-mode/sidebar-card/style.scss"; +@import "./components/sidebar-edit-mode/template-panel/style.scss"; @import "./components/editor/style.scss"; @import "./components/create-template-part-modal/style.scss"; @import "./components/secondary-sidebar/style.scss"; @@ -25,10 +30,14 @@ @import "./components/sidebar-button/style.scss"; @import "./components/sidebar-navigation-item/style.scss"; @import "./components/sidebar-navigation-screen/style.scss"; -@import "./components/sidebar-navigation-screen-pages/style.scss"; +@import "./components/sidebar-navigation-screen-details-footer/style.scss"; +@import "./components/sidebar-navigation-screen-global-styles/style.scss"; +@import "./components/sidebar-navigation-screen-navigation-menu/style.scss"; +@import "./components/sidebar-navigation-screen-page/style.scss"; +@import "components/sidebar-navigation-screen-details-panel/style.scss"; +@import "./components/sidebar-navigation-screen-pattern/style.scss"; +@import "./components/sidebar-navigation-screen-patterns/style.scss"; @import "./components/sidebar-navigation-screen-template/style.scss"; -@import "./components/sidebar-navigation-screen-templates/style.scss"; -@import "./components/sidebar-navigation-subtitle/style.scss"; @import "./components/site-hub/style.scss"; @import "./components/sidebar-navigation-screen-navigation-menus/style.scss"; @import "./components/site-icon/style.scss"; @@ -37,24 +46,21 @@ @import "./components/resizable-frame/style.scss"; @import "./hooks/push-changes-to-global-styles/style.scss"; -html #wpadminbar { +body.js #wpadminbar { display: none; } -html #wpbody { +body.js #wpbody { padding-top: 0; } -// In order to use mix-blend-mode, this element needs to have an explicitly set background-color. -// We scope it to .wp-toolbar to be wp-admin only, to prevent bleed into other implementations. -html.wp-toolbar { - background: $white; - padding-top: 0; +body.js.appearance_page_gutenberg-template-parts, +body.js.site-editor-php { + @include wp-admin-reset(".edit-site"); } -body.appearance_page_gutenberg-template-parts, -body.site-editor-php { - @include wp-admin-reset(".edit-site"); +body.js.site-editor-php { + background: $gray-900; } .edit-site, @@ -77,6 +83,11 @@ body.site-editor-php { top: 0; } + .no-js & { + min-height: 0; + position: static; + } + .interface-interface-skeleton { top: 0; } diff --git a/packages/edit-site/src/utils/constants.js b/packages/edit-site/src/utils/constants.js new file mode 100644 index 00000000000000..ed88c3e14c9473 --- /dev/null +++ b/packages/edit-site/src/utils/constants.js @@ -0,0 +1,5 @@ +export const FOCUSABLE_ENTITIES = [ + 'wp_template_part', + 'wp_navigation', + 'wp_block', +]; diff --git a/packages/edit-site/src/utils/get-is-list-page.js b/packages/edit-site/src/utils/get-is-list-page.js index 3e7049985cc92a..600e686618bf94 100644 --- a/packages/edit-site/src/utils/get-is-list-page.js +++ b/packages/edit-site/src/utils/get-is-list-page.js @@ -1,11 +1,24 @@ /** * Returns if the params match the list page route. * - * @param {Object} params The url params. - * @param {string} params.path The current path. + * @param {Object} params The url params. + * @param {string} params.path The current path. + * @param {string} [params.categoryType] The current category type. + * @param {string} [params.categoryId] The current category id. + * @param {boolean} isMobileViewport Is mobile viewport. * * @return {boolean} Is list page or not. */ -export default function getIsListPage( { path } ) { - return path === '/wp_template/all' || path === '/wp_template_part/all'; +export default function getIsListPage( + { path, categoryType, categoryId }, + isMobileViewport +) { + return ( + path === '/wp_template/all' || + path === '/wp_template_part/all' || + ( path === '/patterns' && + // Don't treat "/patterns" without categoryType and categoryId as a + // list page in mobile because the sidebar covers the whole page. + ( ! isMobileViewport || ( !! categoryType && !! categoryId ) ) ) + ); } diff --git a/packages/edit-site/src/utils/is-previewing-theme.js b/packages/edit-site/src/utils/is-previewing-theme.js index 69388b67212a20..1a71c441f9925e 100644 --- a/packages/edit-site/src/utils/is-previewing-theme.js +++ b/packages/edit-site/src/utils/is-previewing-theme.js @@ -5,14 +5,13 @@ import { getQueryArg } from '@wordpress/url'; export function isPreviewingTheme() { return ( - window?.__experimentalEnableThemePreviews && - getQueryArg( window.location.href, 'theme_preview' ) !== undefined + getQueryArg( window.location.href, 'wp_theme_preview' ) !== undefined ); } export function currentlyPreviewingTheme() { if ( isPreviewingTheme() ) { - return getQueryArg( window.location.href, 'theme_preview' ); + return getQueryArg( window.location.href, 'wp_theme_preview' ); } return null; } diff --git a/packages/edit-site/src/utils/template-part-create.js b/packages/edit-site/src/utils/template-part-create.js index 3c0fa14cbf7ad1..b81a98d15684a7 100644 --- a/packages/edit-site/src/utils/template-part-create.js +++ b/packages/edit-site/src/utils/template-part-create.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { kebabCase } from 'lodash'; +import { paramCase as kebabCase } from 'change-case'; /** * WordPress dependencies diff --git a/packages/edit-site/src/utils/use-activate-theme.js b/packages/edit-site/src/utils/use-activate-theme.js index 6d59a18e385ba5..b476056feb6b06 100644 --- a/packages/edit-site/src/utils/use-activate-theme.js +++ b/packages/edit-site/src/utils/use-activate-theme.js @@ -6,7 +6,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { unlock } from '../private-apis'; +import { unlock } from '../lock-unlock'; import { isPreviewingTheme, currentlyPreviewingTheme, @@ -29,9 +29,10 @@ export function useActivateTheme() { 'themes.php?action=activate&stylesheet=' + currentlyPreviewingTheme() + '&_wpnonce=' + - window.BLOCK_THEME_ACTIVATE_NONCE; + window.WP_BLOCK_THEME_ACTIVATE_NONCE; await window.fetch( activationURL ); - const { theme_preview: themePreview, ...params } = location.params; + const { wp_theme_preview: themePreview, ...params } = + location.params; history.replace( params ); } }; diff --git a/packages/edit-site/src/utils/use-debounced-input.js b/packages/edit-site/src/utils/use-debounced-input.js new file mode 100644 index 00000000000000..26cd6c0da0e0a9 --- /dev/null +++ b/packages/edit-site/src/utils/use-debounced-input.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { useDebounce } from '@wordpress/compose'; + +export default function useDebouncedInput( defaultValue = '' ) { + const [ input, setInput ] = useState( defaultValue ); + const [ debouncedInput, setDebouncedState ] = useState( defaultValue ); + + const setDebouncedInput = useDebounce( setDebouncedState, 250 ); + + useEffect( () => { + setDebouncedInput( input ); + }, [ input ] ); + + return [ input, setInput, debouncedInput ]; +} diff --git a/packages/edit-widgets/CHANGELOG.md b/packages/edit-widgets/CHANGELOG.md index 8f7535bc9e6f4d..995ba74ef66eeb 100644 --- a/packages/edit-widgets/CHANGELOG.md +++ b/packages/edit-widgets/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 5.17.0 (2023-08-16) + +## 5.16.0 (2023-08-10) + +## 5.15.0 (2023-07-20) + +## 5.14.0 (2023-07-05) + +## 5.13.0 (2023-06-23) + +## 5.12.0 (2023-06-07) + ## 5.11.0 (2023-05-24) ## 5.10.0 (2023-05-10) diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json index 026942e286d283..0e4fb70d95f2cd 100644 --- a/packages/edit-widgets/package.json +++ b/packages/edit-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-widgets", - "version": "5.11.0", + "version": "5.17.0", "description": "Widgets Page module for WordPress..", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -46,6 +46,7 @@ "@wordpress/keycodes": "file:../keycodes", "@wordpress/media-utils": "file:../media-utils", "@wordpress/notices": "file:../notices", + "@wordpress/patterns": "file:../patterns", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/edit-widgets/src/components/header/index.js b/packages/edit-widgets/src/components/header/index.js index e2691f9c74c436..d6f12bff4d5235 100644 --- a/packages/edit-widgets/src/components/header/index.js +++ b/packages/edit-widgets/src/components/header/index.js @@ -23,7 +23,7 @@ import RedoButton from './undo-redo/redo'; import MoreMenu from '../more-menu'; import useLastSelectedWidgetArea from '../../hooks/use-last-selected-widget-area'; import { store as editWidgetsStore } from '../../store'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { useShouldContextualToolbarShow } = unlock( blockEditorPrivateApis ); diff --git a/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/style.scss b/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/style.scss index 425a53fb23a635..e00df74d97d911 100644 --- a/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/style.scss +++ b/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/style.scss @@ -3,11 +3,6 @@ margin: 0 0 2rem 0; } - &__main-shortcuts .edit-widgets-keyboard-shortcut-help-modal__shortcut-list { - // Push the shortcut to be flush with top modal header. - margin-top: -$grid-unit-30 -$border-width; - } - &__section-title { font-size: 0.9rem; font-weight: 600; diff --git a/packages/edit-widgets/src/components/keyboard-shortcuts/index.js b/packages/edit-widgets/src/components/keyboard-shortcuts/index.js index 7add778094c524..dff0ac57f78c12 100644 --- a/packages/edit-widgets/src/components/keyboard-shortcuts/index.js +++ b/packages/edit-widgets/src/components/keyboard-shortcuts/index.js @@ -70,7 +70,7 @@ function KeyboardShortcuts() { } ); useShortcut( - 'core/edit-widgets//transform-heading-to-paragraph', + 'core/edit-widgets/transform-heading-to-paragraph', ( event ) => handleTextLevelShortcut( event, 0 ) ); @@ -79,7 +79,7 @@ function KeyboardShortcuts() { //the hook will execute the same way every time //eslint-disable-next-line react-hooks/rules-of-hooks useShortcut( - `core/edit-widgets//transform-paragraph-to-heading-${ level }`, + `core/edit-widgets/transform-paragraph-to-heading-${ level }`, ( event ) => handleTextLevelShortcut( event, level ) ); } ); @@ -180,7 +180,7 @@ function KeyboardShortcutsRegister() { } ); registerShortcut( { - name: `core/edit-widgets//transform-heading-to-paragraph`, + name: 'core/edit-widgets/transform-heading-to-paragraph', category: 'block-library', description: __( 'Transform heading to paragraph.' ), keyCombination: { @@ -191,7 +191,7 @@ function KeyboardShortcutsRegister() { [ 1, 2, 3, 4, 5, 6 ].forEach( ( level ) => { registerShortcut( { - name: `core/edit-widgets//transform-paragraph-to-heading-${ level }`, + name: `core/edit-widgets/transform-paragraph-to-heading-${ level }`, category: 'block-library', description: __( 'Transform paragraph to heading.' ), keyCombination: { diff --git a/packages/edit-widgets/src/components/layout/index.js b/packages/edit-widgets/src/components/layout/index.js index 3a73bcda1a2660..54338d10fb8503 100644 --- a/packages/edit-widgets/src/components/layout/index.js +++ b/packages/edit-widgets/src/components/layout/index.js @@ -2,7 +2,6 @@ * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { Popover } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; import { PluginArea } from '@wordpress/plugins'; import { store as noticesStore } from '@wordpress/notices'; @@ -39,7 +38,6 @@ function Layout( { blockEditorSettings } ) { > <Interface blockEditorSettings={ blockEditorSettings } /> <Sidebar /> - <Popover.Slot /> <PluginArea onError={ onPluginAreaError } /> <UnsavedChangesWarning /> <WelcomeGuide /> diff --git a/packages/edit-widgets/src/components/sidebar/index.js b/packages/edit-widgets/src/components/sidebar/index.js index 087f781f69a5ba..27af07831e1eed 100644 --- a/packages/edit-widgets/src/components/sidebar/index.js +++ b/packages/edit-widgets/src/components/sidebar/index.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { useEffect, Platform } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; +import { isRTL, __, sprintf } from '@wordpress/i18n'; import { ComplementaryArea, store as interfaceStore, @@ -17,7 +17,7 @@ import { store as blockEditorStore, } from '@wordpress/block-editor'; -import { cog } from '@wordpress/icons'; +import { drawerLeft, drawerRight } from '@wordpress/icons'; import { Button } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -168,7 +168,7 @@ export default function Sidebar() { closeLabel={ __( 'Close Settings' ) } scope="core/edit-widgets" identifier={ currentArea } - icon={ cog } + icon={ isRTL() ? drawerLeft : drawerRight } isActiveByDefault={ SIDEBAR_ACTIVE_BY_DEFAULT } > { currentArea === WIDGET_AREAS_IDENTIFIER && ( diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js index 386d4730e4dab4..f12774e36cf232 100644 --- a/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js +++ b/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js @@ -6,7 +6,6 @@ import { BlockTools, BlockSelectionClearer, WritingFlow, - ObserveTyping, __unstableEditorStyles as EditorStyles, } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; @@ -43,9 +42,7 @@ export default function WidgetAreasBlockEditorContent( { <EditorStyles styles={ styles } /> <BlockSelectionClearer> <WritingFlow> - <ObserveTyping> - <BlockList className="edit-widgets-main-block-list" /> - </ObserveTyping> + <BlockList className="edit-widgets-main-block-list" /> </WritingFlow> </BlockSelectionClearer> </BlockTools> diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js index 042cd0dc9c6177..899cc02766480f 100644 --- a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js +++ b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js @@ -11,11 +11,10 @@ import { } from '@wordpress/core-data'; import { useMemo } from '@wordpress/element'; import { - BlockEditorKeyboardShortcuts, CopyHandler, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; -import { ReusableBlocksMenuItems } from '@wordpress/reusable-blocks'; +import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns'; import { ShortcutProvider } from '@wordpress/keyboard-shortcuts'; import { store as preferencesStore } from '@wordpress/preferences'; @@ -27,10 +26,10 @@ import { buildWidgetAreasPostId, KIND, POST_TYPE } from '../../store/utils'; import useLastSelectedWidgetArea from '../../hooks/use-last-selected-widget-area'; import { store as editWidgetsStore } from '../../store'; import { ALLOW_REUSABLE_BLOCKS } from '../../constants'; -import { unlock } from '../../private-apis'; +import { unlock } from '../../lock-unlock'; const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); - +const { PatternsMenuItems } = unlock( editPatternsPrivateApis ); export default function WidgetAreasBlockEditorProvider( { blockEditorSettings, children, @@ -100,7 +99,6 @@ export default function WidgetAreasBlockEditorProvider( { return ( <ShortcutProvider> - <BlockEditorKeyboardShortcuts.Register /> <KeyboardShortcuts.Register /> <SlotFillProvider> <ExperimentalBlockEditorProvider @@ -112,7 +110,7 @@ export default function WidgetAreasBlockEditorProvider( { { ...props } > <CopyHandler>{ children }</CopyHandler> - <ReusableBlocksMenuItems rootClientId={ widgetAreaId } /> + <PatternsMenuItems rootClientId={ widgetAreaId } /> </ExperimentalBlockEditorProvider> </SlotFillProvider> </ShortcutProvider> diff --git a/packages/edit-widgets/src/index.js b/packages/edit-widgets/src/index.js index bae80786b64140..eb87d22fefef9e 100644 --- a/packages/edit-widgets/src/index.js +++ b/packages/edit-widgets/src/index.js @@ -70,7 +70,7 @@ export function initializeEditor( id, settings ) { themeStyles: true, } ); - dispatch( blocksStore ).__experimentalReapplyBlockTypeFilters(); + dispatch( blocksStore ).reapplyBlockTypeFilters(); registerCoreBlocks( coreBlocks ); registerLegacyWidgetBlock(); if ( process.env.IS_GUTENBERG_PLUGIN ) { @@ -112,7 +112,6 @@ export function reinitializeEditor() { * Function to register an individual block. * * @param {Object} block The block to be registered. - * */ const registerBlock = ( block ) => { if ( ! block ) { @@ -124,3 +123,5 @@ const registerBlock = ( block ) => { } registerBlockType( name, settings ); }; + +export { store } from './store'; diff --git a/packages/edit-widgets/src/private-apis.js b/packages/edit-widgets/src/lock-unlock.js similarity index 100% rename from packages/edit-widgets/src/private-apis.js rename to packages/edit-widgets/src/lock-unlock.js diff --git a/packages/edit-widgets/src/store/selectors.js b/packages/edit-widgets/src/store/selectors.js index 80bcddfead7b3f..b7a07000ec84b0 100644 --- a/packages/edit-widgets/src/store/selectors.js +++ b/packages/edit-widgets/src/store/selectors.js @@ -19,6 +19,11 @@ import { } from './utils'; import { STORE_NAME as editWidgetsStoreName } from './constants'; +const EMPTY_INSERTION_POINT = { + rootClientId: undefined, + insertionIndex: undefined, +}; + /** * Returns all API widgets. * @@ -254,8 +259,11 @@ export function isInserterOpened( state ) { * @return {Object} The root client ID and index to insert at. */ export function __experimentalGetInsertionPoint( state ) { - const { rootClientId, insertionIndex } = state.blockInserterPanel; - return { rootClientId, insertionIndex }; + if ( typeof state.blockInserterPanel === 'boolean' ) { + return EMPTY_INSERTION_POINT; + } + + return state.blockInserterPanel; } /** diff --git a/packages/edit-widgets/src/style.scss b/packages/edit-widgets/src/style.scss index 2e78903d72c714..ae850c3bb78fee 100644 --- a/packages/edit-widgets/src/style.scss +++ b/packages/edit-widgets/src/style.scss @@ -11,14 +11,8 @@ @import "./components/widget-areas-block-editor-content/style.scss"; @import "./components/secondary-sidebar/style.scss"; -// In order to use mix-blend-mode, this element needs to have an explicitly set background-color -// We scope it to .wp-toolbar to be wp-admin only, to prevent bleed into other implementations -html.wp-toolbar { - background: $white; -} - -body.appearance_page_gutenberg-widgets, -body.widgets-php { +body.js.appearance_page_gutenberg-widgets, +body.js.widgets-php { @include wp-admin-reset( ".blocks-widgets-container" ); } diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index bfe34d734cd585..2a7d933ad5c667 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 13.17.0 (2023-08-16) + +## 13.16.0 (2023-08-10) + +## 13.15.0 (2023-07-20) + +## 13.14.0 (2023-07-05) + +## 13.13.0 (2023-06-23) + +## 13.12.0 (2023-06-07) + ## 13.11.0 (2023-05-24) ## 13.10.0 (2023-05-10) diff --git a/packages/editor/package.json b/packages/editor/package.json index fd0f4f52166a3d..ba7cba3fa03e5e 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/editor", - "version": "13.11.0", + "version": "13.17.0", "description": "Enhanced block editor for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -52,6 +52,7 @@ "@wordpress/keycodes": "file:../keycodes", "@wordpress/media-utils": "file:../media-utils", "@wordpress/notices": "file:../notices", + "@wordpress/patterns": "file:../patterns", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", "@wordpress/reusable-blocks": "file:../reusable-blocks", @@ -61,11 +62,10 @@ "@wordpress/wordcount": "file:../wordcount", "classnames": "^2.3.1", "date-fns": "^2.28.0", - "escape-html": "^1.0.3", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "rememo": "^4.0.2", - "remove-accents": "^0.4.2" + "remove-accents": "^0.5.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/editor/src/components/editor-help/index.native.js b/packages/editor/src/components/editor-help/index.native.js index fb802ba7560cde..5aeee5fba49b1c 100644 --- a/packages/editor/src/components/editor-help/index.native.js +++ b/packages/editor/src/components/editor-help/index.native.js @@ -102,7 +102,7 @@ function EditorHelpTopics( { close, isVisible, onClose, showSupport } ) { contentStyle={ styles.contentContainer } testID="editor-help-modal" > - <SafeAreaView> + <SafeAreaView style={ styles.safeAreaContainer }> <BottomSheet.NavigationContainer animate main diff --git a/packages/editor/src/components/editor-help/style.scss b/packages/editor/src/components/editor-help/style.scss index 8058f00381da95..529a9e4deef7bf 100644 --- a/packages/editor/src/components/editor-help/style.scss +++ b/packages/editor/src/components/editor-help/style.scss @@ -19,7 +19,12 @@ color: $dark-primary; } +.safeAreaContainer { + flex: 1; +} + .navigationContainer { + flex: 1; overflow: hidden; } diff --git a/packages/editor/src/components/editor-help/test/index.native.js b/packages/editor/src/components/editor-help/test/index.native.js index c300f3db8d7ba6..1695590a769d34 100644 --- a/packages/editor/src/components/editor-help/test/index.native.js +++ b/packages/editor/src/components/editor-help/test/index.native.js @@ -35,10 +35,11 @@ it( 'navigates back from help topic detail screen', async () => { const backButton = screen.getAllByLabelText( 'Go back' ); fireEvent.press( backButton[ backButton.length - 1 ] ); - // Currently logs `act` warning due to https://github.com/callstack/react-native-testing-library/issues/379 const text = 'Each block has its own settings. To find them, tap on a block. Its settings will appear on the toolbar at the bottom of the screen.'; - await waitForElementToBeRemoved( () => screen.getByText( text ) ); + await waitForElementToBeRemoved( () => + screen.getByText( text, { hidden: true } ) + ); expect( screen.queryByText( text ) ).toBeNull(); } ); diff --git a/packages/editor/src/components/entities-saved-states/entity-record-item.js b/packages/editor/src/components/entities-saved-states/entity-record-item.js index 1e3ddcbc696686..70ca2b83c4dfdf 100644 --- a/packages/editor/src/components/entities-saved-states/entity-record-item.js +++ b/packages/editor/src/components/entities-saved-states/entity-record-item.js @@ -1,11 +1,9 @@ /** * WordPress dependencies */ -import { CheckboxControl, Button, PanelRow } from '@wordpress/components'; +import { CheckboxControl, PanelRow } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { useCallback } from '@wordpress/element'; -import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; @@ -14,27 +12,8 @@ import { decodeEntities } from '@wordpress/html-entities'; */ import { store as editorStore } from '../../store'; -export default function EntityRecordItem( { - record, - checked, - onChange, - closePanel, -} ) { +export default function EntityRecordItem( { record, checked, onChange } ) { const { name, kind, title, key } = record; - const parentBlockId = useSelect( ( select ) => { - // Get entity's blocks. - const { blocks = [] } = select( coreStore ).getEditedEntityRecord( - kind, - name, - key - ); - // Get parents of the entity's first block. - const parents = select( blockEditorStore ).getBlockParents( - blocks[ 0 ]?.clientId - ); - // Return closest parent block's clientId. - return parents[ parents.length - 1 ]; - }, [] ); // Handle templates that might use default descriptive titles. const entityRecordTitle = useSelect( @@ -55,25 +34,6 @@ export default function EntityRecordItem( { [ name, kind, title, key ] ); - const isSelected = useSelect( - ( select ) => { - const selectedBlockId = - select( blockEditorStore ).getSelectedBlockClientId(); - return selectedBlockId === parentBlockId; - }, - [ parentBlockId ] - ); - const isSelectedText = isSelected ? __( 'Selected' ) : __( 'Select' ); - const { selectBlock } = useDispatch( blockEditorStore ); - const selectParentBlock = useCallback( - () => selectBlock( parentBlockId ), - [ parentBlockId ] - ); - const selectAndDismiss = useCallback( () => { - selectBlock( parentBlockId ); - closePanel(); - }, [ parentBlockId ] ); - return ( <PanelRow> <CheckboxControl @@ -87,24 +47,6 @@ export default function EntityRecordItem( { checked={ checked } onChange={ onChange } /> - { parentBlockId ? ( - <> - <Button - onClick={ selectParentBlock } - className="entities-saved-states__find-entity" - disabled={ isSelected } - > - { isSelectedText } - </Button> - <Button - onClick={ selectAndDismiss } - className="entities-saved-states__find-entity-small" - disabled={ isSelected } - > - { isSelectedText } - </Button> - </> - ) : null } </PanelRow> ); } diff --git a/packages/editor/src/components/entities-saved-states/entity-type-list.js b/packages/editor/src/components/entities-saved-states/entity-type-list.js index 6a323f8db5dfb2..dffac536a1d220 100644 --- a/packages/editor/src/components/entities-saved-states/entity-type-list.js +++ b/packages/editor/src/components/entities-saved-states/entity-type-list.js @@ -31,7 +31,6 @@ export default function EntityTypeList( { list, unselectedEntities, setUnselectedEntities, - closePanel, } ) { const count = list.length; const firstRecord = list[ 0 ]; @@ -73,7 +72,6 @@ export default function EntityTypeList( { onChange={ ( value ) => setUnselectedEntities( record, value ) } - closePanel={ closePanel } /> ); } ) } diff --git a/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js b/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js new file mode 100644 index 00000000000000..e630c60b8c633d --- /dev/null +++ b/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +const TRANSLATED_SITE_PROPERTIES = { + title: __( 'Title' ), + description: __( 'Tagline' ), + site_logo: __( 'Logo' ), + site_icon: __( 'Icon' ), + show_on_front: __( 'Show on front' ), + page_on_front: __( 'Page on front' ), + posts_per_page: __( 'Maximum posts per page' ), + default_comment_status: __( 'Allow comments on new posts' ), +}; + +export const useIsDirty = () => { + const { editedEntities, siteEdits } = useSelect( ( select ) => { + const { __experimentalGetDirtyEntityRecords, getEntityRecordEdits } = + select( coreStore ); + + return { + editedEntities: __experimentalGetDirtyEntityRecords(), + siteEdits: getEntityRecordEdits( 'root', 'site' ), + }; + }, [] ); + + const dirtyEntityRecords = useMemo( () => { + // Remove site object and decouple into its edited pieces. + const editedEntitiesWithoutSite = editedEntities.filter( + ( record ) => ! ( record.kind === 'root' && record.name === 'site' ) + ); + + const editedSiteEntities = []; + for ( const property in siteEdits ) { + editedSiteEntities.push( { + kind: 'root', + name: 'site', + title: TRANSLATED_SITE_PROPERTIES[ property ] || property, + property, + } ); + } + + return [ ...editedEntitiesWithoutSite, ...editedSiteEntities ]; + }, [ editedEntities, siteEdits ] ); + + // Unchecked entities to be ignored by save function. + const [ unselectedEntities, _setUnselectedEntities ] = useState( [] ); + + const setUnselectedEntities = ( + { kind, name, key, property }, + checked + ) => { + if ( checked ) { + _setUnselectedEntities( + unselectedEntities.filter( + ( elt ) => + elt.kind !== kind || + elt.name !== name || + elt.key !== key || + elt.property !== property + ) + ); + } else { + _setUnselectedEntities( [ + ...unselectedEntities, + { kind, name, key, property }, + ] ); + } + }; + + const isDirty = dirtyEntityRecords.length - unselectedEntities.length > 0; + + return { + dirtyEntityRecords, + isDirty, + setUnselectedEntities, + unselectedEntities, + }; +}; diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index 29bef117243036..07097969fffc8c 100644 --- a/packages/editor/src/components/entities-saved-states/index.js +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -2,28 +2,19 @@ * WordPress dependencies */ import { Button, Flex, FlexItem } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { useState, useCallback, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { useCallback, useRef } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { __experimentalUseDialog as useDialog } from '@wordpress/compose'; import { store as noticesStore } from '@wordpress/notices'; -import { getQueryArg } from '@wordpress/url'; /** * Internal dependencies */ import EntityTypeList from './entity-type-list'; - -const TRANSLATED_SITE_PROPERTIES = { - title: __( 'Title' ), - description: __( 'Tagline' ), - site_logo: __( 'Logo' ), - site_icon: __( 'Icon' ), - show_on_front: __( 'Show on front' ), - page_on_front: __( 'Page on front' ), -}; +import { useIsDirty } from './hooks/use-is-dirty'; const PUBLISH_ON_SAVE_ENTITIES = [ { @@ -36,56 +27,26 @@ function identity( values ) { return values; } -function isPreviewingTheme() { +export default function EntitiesSavedStates( { close } ) { + const isDirtyProps = useIsDirty(); return ( - window?.__experimentalEnableThemePreviews && - getQueryArg( window.location.href, 'theme_preview' ) !== undefined + <EntitiesSavedStatesExtensible close={ close } { ...isDirtyProps } /> ); } -function currentlyPreviewingTheme() { - if ( isPreviewingTheme() ) { - return getQueryArg( window.location.href, 'theme_preview' ); - } - return null; -} - -export default function EntitiesSavedStates( { close, onSave = identity } ) { +export function EntitiesSavedStatesExtensible( { + additionalPrompt = undefined, + close, + onSave = identity, + saveEnabled: saveEnabledProp = undefined, + saveLabel = __( 'Save' ), + + dirtyEntityRecords, + isDirty, + setUnselectedEntities, + unselectedEntities, +} ) { const saveButtonRef = useRef(); - const { getTheme } = useSelect( coreStore ); - const theme = getTheme( currentlyPreviewingTheme() ); - const { dirtyEntityRecords } = useSelect( ( select ) => { - const dirtyRecords = - select( coreStore ).__experimentalGetDirtyEntityRecords(); - - // Remove site object and decouple into its edited pieces. - const dirtyRecordsWithoutSite = dirtyRecords.filter( - ( record ) => ! ( record.kind === 'root' && record.name === 'site' ) - ); - - const siteEdits = select( coreStore ).getEntityRecordEdits( - 'root', - 'site' - ); - - const siteEditsAsEntities = []; - for ( const property in siteEdits ) { - siteEditsAsEntities.push( { - kind: 'root', - name: 'site', - title: TRANSLATED_SITE_PROPERTIES[ property ] || property, - property, - } ); - } - const dirtyRecordsWithSiteItems = [ - ...dirtyRecordsWithoutSite, - ...siteEditsAsEntities, - ]; - - return { - dirtyEntityRecords: dirtyRecordsWithSiteItems, - }; - }, [] ); const { editEntityRecord, saveEditedEntityRecord, @@ -122,32 +83,9 @@ export default function EntitiesSavedStates( { close, onSave = identity } ) { ...Object.values( contentSavables ), ].filter( Array.isArray ); - // Unchecked entities to be ignored by save function. - const [ unselectedEntities, _setUnselectedEntities ] = useState( [] ); - - const setUnselectedEntities = ( - { kind, name, key, property }, - checked - ) => { - if ( checked ) { - _setUnselectedEntities( - unselectedEntities.filter( - ( elt ) => - elt.kind !== kind || - elt.name !== name || - elt.key !== key || - elt.property !== property - ) - ); - } else { - _setUnselectedEntities( [ - ...unselectedEntities, - { kind, name, key, property }, - ] ); - } - }; + const saveEnabled = saveEnabledProp ?? isDirty; - const saveCheckedEntitiesAndActivate = () => { + const saveCheckedEntities = () => { const saveNoticeId = 'site-editor-save-success'; removeNotice( saveNoticeId ); const entitiesToSave = dirtyEntityRecords.filter( @@ -227,18 +165,6 @@ export default function EntitiesSavedStates( { close, onSave = identity } ) { onClose: () => dismissPanel(), } ); - const isDirty = dirtyEntityRecords.length - unselectedEntities.length > 0; - const activateSaveEnabled = isPreviewingTheme() || isDirty; - - let activateSaveLabel; - if ( isPreviewingTheme() && isDirty ) { - activateSaveLabel = __( 'Activate & Save' ); - } else if ( isPreviewingTheme() ) { - activateSaveLabel = __( 'Activate' ); - } else { - activateSaveLabel = __( 'Save' ); - } - return ( <div ref={ saveDialogRef } @@ -251,11 +177,11 @@ export default function EntitiesSavedStates( { close, onSave = identity } ) { as={ Button } ref={ saveButtonRef } variant="primary" - disabled={ ! activateSaveEnabled } - onClick={ saveCheckedEntitiesAndActivate } + disabled={ ! saveEnabled } + onClick={ saveCheckedEntities } className="editor-entities-saved-states__save-button" > - { activateSaveLabel } + { saveLabel } </FlexItem> <FlexItem isBlock @@ -269,14 +195,7 @@ export default function EntitiesSavedStates( { close, onSave = identity } ) { <div className="entities-saved-states__text-prompt"> <strong>{ __( 'Are you ready to save?' ) }</strong> - { isPreviewingTheme() && ( - <p> - { sprintf( - 'Saving your changes will change your active theme to %1$s.', - theme?.name?.rendered - ) } - </p> - ) } + { additionalPrompt } { isDirty && ( <p> { __( @@ -291,7 +210,6 @@ export default function EntitiesSavedStates( { close, onSave = identity } ) { <EntityTypeList key={ list[ 0 ].name } list={ list } - closePanel={ dismissPanel } unselectedEntities={ unselectedEntities } setUnselectedEntities={ setUnselectedEntities } /> diff --git a/packages/editor/src/components/entities-saved-states/style.scss b/packages/editor/src/components/entities-saved-states/style.scss index 4a2c8700245ed7..99bb7394b510e5 100644 --- a/packages/editor/src/components/entities-saved-states/style.scss +++ b/packages/editor/src/components/entities-saved-states/style.scss @@ -1,18 +1,3 @@ -.entities-saved-states__find-entity { - display: none; - - @include break-medium() { - display: block; - } -} -.entities-saved-states__find-entity-small { - display: block; - - @include break-medium() { - display: none; - } -} - .entities-saved-states__panel-header { box-sizing: border-box; background: $white; diff --git a/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js b/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js new file mode 100644 index 00000000000000..04b6b4e566ef1f --- /dev/null +++ b/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { useIsDirty } from '../hooks/use-is-dirty'; + +jest.mock( '@wordpress/data', () => { + return { + useSelect: jest.fn().mockImplementation( ( fn ) => { + const select = () => { + return { + __experimentalGetDirtyEntityRecords: jest + .fn() + .mockReturnValue( [ + { + kind: 'root', + name: 'site', + title: 'title', + property: 'property', + }, + { + kind: 'postType', + name: 'post', + title: 'title', + property: 'property', + }, + ] ), + getEntityRecordEdits: jest.fn().mockReturnValue( { + title: 'My Site', + } ), + }; + }; + return fn( select ); + } ), + }; +} ); + +jest.mock( '@wordpress/core-data', () => { + return { + store: { + __experimentalGetDirtyEntityRecords: jest.fn(), + getEntityRecordEdits: jest.fn(), + }, + }; +} ); + +describe( 'useIsDirty', () => { + it( 'should calculate dirtyEntityRecords', () => { + const { result } = renderHook( () => useIsDirty() ); + expect( result.current.dirtyEntityRecords ).toEqual( [ + { + kind: 'postType', + name: 'post', + property: 'property', + title: 'title', + }, + { kind: 'root', name: 'site', property: 'title', title: 'Title' }, + ] ); + } ); + it( 'should return `isDirty: true` when there are changes', () => { + const { result } = renderHook( () => useIsDirty() ); + expect( result.current.isDirty ).toBeTruthy(); + } ); + it( 'should return `isDirty: false` when there are NO changes', async () => { + const { result } = renderHook( () => useIsDirty() ); + act( () => { + result.current.setUnselectedEntities( + { + kind: 'postType', + name: 'post', + key: 'key', + property: 'property', + }, + false + ); + } ); + act( () => { + result.current.setUnselectedEntities( + { + kind: 'root', + name: 'site', + key: 'key', + property: 'property', + }, + false + ); + } ); + expect( result.current.isDirty ).toBeFalsy(); + } ); +} ); diff --git a/packages/editor/src/components/global-keyboard-shortcuts/index.js b/packages/editor/src/components/global-keyboard-shortcuts/index.js new file mode 100644 index 00000000000000..4b45fe449123f4 --- /dev/null +++ b/packages/editor/src/components/global-keyboard-shortcuts/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { useShortcut } from '@wordpress/keyboard-shortcuts'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; + +export default function EditorKeyboardShortcuts() { + const { redo, undo, savePost } = useDispatch( editorStore ); + const { isEditedPostDirty, isPostSavingLocked } = useSelect( editorStore ); + + useShortcut( 'core/editor/undo', ( event ) => { + undo(); + event.preventDefault(); + } ); + + useShortcut( 'core/editor/redo', ( event ) => { + redo(); + event.preventDefault(); + } ); + + useShortcut( 'core/editor/save', ( event ) => { + event.preventDefault(); + + /** + * Do not save the post if post saving is locked. + */ + if ( isPostSavingLocked() ) { + return; + } + + // TODO: This should be handled in the `savePost` effect in + // considering `isSaveable`. See note on `isEditedPostSaveable` + // selector about dirtiness and meta-boxes. + // + // See: `isEditedPostSaveable` + if ( ! isEditedPostDirty() ) { + return; + } + + savePost(); + } ); + + return null; +} diff --git a/packages/editor/src/components/global-keyboard-shortcuts/save-shortcut.js b/packages/editor/src/components/global-keyboard-shortcuts/save-shortcut.js deleted file mode 100644 index 1843e2d56876ee..00000000000000 --- a/packages/editor/src/components/global-keyboard-shortcuts/save-shortcut.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * WordPress dependencies - */ -import { useShortcut } from '@wordpress/keyboard-shortcuts'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { parse } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { store as editorStore } from '../../store'; - -function SaveShortcut( { resetBlocksOnSave } ) { - const { resetEditorBlocks, savePost } = useDispatch( editorStore ); - const { isEditedPostDirty, getPostEdits, isPostSavingLocked } = - useSelect( editorStore ); - - useShortcut( 'core/editor/save', ( event ) => { - event.preventDefault(); - - /** - * Do not save the post if post saving is locked. - */ - if ( isPostSavingLocked() ) { - return; - } - - // TODO: This should be handled in the `savePost` effect in - // considering `isSaveable`. See note on `isEditedPostSaveable` - // selector about dirtiness and meta-boxes. - // - // See: `isEditedPostSaveable` - if ( ! isEditedPostDirty() ) { - return; - } - - // The text editor requires that editor blocks are updated for a - // save to work correctly. Usually this happens when the textarea - // for the code editors blurs, but the shortcut can be used without - // blurring the textarea. - if ( resetBlocksOnSave ) { - const postEdits = getPostEdits(); - if ( postEdits.content && typeof postEdits.content === 'string' ) { - const blocks = parse( postEdits.content ); - resetEditorBlocks( blocks ); - } - } - - savePost(); - } ); - - return null; -} - -export default SaveShortcut; diff --git a/packages/editor/src/components/global-keyboard-shortcuts/text-editor-shortcuts.js b/packages/editor/src/components/global-keyboard-shortcuts/text-editor-shortcuts.js deleted file mode 100644 index 33eb32000de132..00000000000000 --- a/packages/editor/src/components/global-keyboard-shortcuts/text-editor-shortcuts.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Internal dependencies - */ -import SaveShortcut from './save-shortcut'; - -export default function TextEditorGlobalKeyboardShortcuts() { - return <SaveShortcut resetBlocksOnSave />; -} diff --git a/packages/editor/src/components/global-keyboard-shortcuts/visual-editor-shortcuts.js b/packages/editor/src/components/global-keyboard-shortcuts/visual-editor-shortcuts.js deleted file mode 100644 index 5e5875f275845f..00000000000000 --- a/packages/editor/src/components/global-keyboard-shortcuts/visual-editor-shortcuts.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * WordPress dependencies - */ -import { useShortcut } from '@wordpress/keyboard-shortcuts'; -import { useDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import SaveShortcut from './save-shortcut'; -import { store as editorStore } from '../../store'; - -function VisualEditorGlobalKeyboardShortcuts() { - const { redo, undo } = useDispatch( editorStore ); - - useShortcut( 'core/editor/undo', ( event ) => { - undo(); - event.preventDefault(); - } ); - - useShortcut( 'core/editor/redo', ( event ) => { - redo(); - event.preventDefault(); - } ); - - return <SaveShortcut />; -} - -export default VisualEditorGlobalKeyboardShortcuts; diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 3c1521f756554a..39b562806c109a 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -1,3 +1,8 @@ +/** + * Internal dependencies + */ +import EditorKeyboardShortcuts from './global-keyboard-shortcuts'; + // Block Creation Components. export * from './autocompleters'; @@ -5,14 +10,14 @@ export * from './autocompleters'; export { default as AutosaveMonitor } from './autosave-monitor'; export { default as DocumentOutline } from './document-outline'; export { default as DocumentOutlineCheck } from './document-outline/check'; -export { default as VisualEditorGlobalKeyboardShortcuts } from './global-keyboard-shortcuts/visual-editor-shortcuts'; -export { default as TextEditorGlobalKeyboardShortcuts } from './global-keyboard-shortcuts/text-editor-shortcuts'; +export { EditorKeyboardShortcuts }; export { default as EditorKeyboardShortcutsRegister } from './global-keyboard-shortcuts/register-shortcuts'; export { default as EditorHistoryRedo } from './editor-history/redo'; export { default as EditorHistoryUndo } from './editor-history/undo'; export { default as EditorNotices } from './editor-notices'; export { default as EditorSnackbars } from './editor-snackbars'; export { default as EntitiesSavedStates } from './entities-saved-states'; +export { useIsDirty as useEntitiesSavedStatesIsDirty } from './entities-saved-states/hooks/use-is-dirty'; export { default as ErrorBoundary } from './error-boundary'; export { default as LocalAutosaveMonitor } from './local-autosave-monitor'; export { default as PageAttributesCheck } from './page-attributes/check'; @@ -50,6 +55,10 @@ export { default as PostSlugCheck } from './post-slug/check'; export { default as PostSticky } from './post-sticky'; export { default as PostStickyCheck } from './post-sticky/check'; export { default as PostSwitchToDraftButton } from './post-switch-to-draft-button'; +export { + default as PostSyncStatus, + PostSyncStatusModal, +} from './post-sync-status'; export { default as PostTaxonomies } from './post-taxonomies'; export { FlatTermSelector as PostTaxonomiesFlatTermSelector } from './post-taxonomies/flat-term-selector'; export { HierarchicalTermSelector as PostTaxonomiesHierarchicalTermSelector } from './post-taxonomies/hierarchical-term-selector'; @@ -79,3 +88,5 @@ export { default as CharacterCount } from './character-count'; export { default as EditorProvider } from './provider'; export * from './deprecated'; +export const VisualEditorGlobalKeyboardShortcuts = EditorKeyboardShortcuts; +export const TextEditorGlobalKeyboardShortcuts = EditorKeyboardShortcuts; diff --git a/packages/editor/src/components/media-categories/index.js b/packages/editor/src/components/media-categories/index.js index 8bd3f5947ff851..345f5c5c86e2c2 100644 --- a/packages/editor/src/components/media-categories/index.js +++ b/packages/editor/src/components/media-categories/index.js @@ -20,27 +20,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** @typedef {import('@wordpress/block-editor').InserterMediaRequest} InserterMediaRequest */ /** @typedef {import('@wordpress/block-editor').InserterMediaItem} InserterMediaItem */ -/** - * Interface for inserter media category labels. - * - * @typedef {Object} InserterMediaCategoryLabels - * @property {string} name General name of the media category. It's used in the inserter media items list. - * @property {string} [search_items='Search'] Label for searching items. Default is ‘Search Posts’ / ‘Search Pages’. - */ -/** - * Interface for inserter media category. - * - * @typedef {Object} InserterMediaCategory - * @property {string} name The name of the media category, that should be unique among all media categories. - * @property {InserterMediaCategoryLabels} labels Labels for the media category. - * @property {('image'|'audio'|'video')} mediaType The media type of the media category. - * @property {(InserterMediaRequest) => Promise<InserterMediaItem[]>} fetch The function to fetch media items for the category. - * @property {(InserterMediaItem) => string} [getReportUrl] If the media category supports reporting media items, this function should return - * the report url for the media item. It accepts the `InserterMediaItem` as an argument. - * @property {boolean} [isExternalResource] If the media category is an external resource, this should be set to true. - * This is used to avoid making a request to the external resource when the user - * opens the inserter for the first time. - */ +/** @typedef {import('@wordpress/block-editor').InserterMediaCategory} InserterMediaCategory */ const getExternalLink = ( url, text ) => `<a ${ getExternalLinkAttributes( url ) }>${ text }</a>`; diff --git a/packages/editor/src/components/page-attributes/order.js b/packages/editor/src/components/page-attributes/order.js index f226de5a47366d..416636d14bdbf8 100644 --- a/packages/editor/src/components/page-attributes/order.js +++ b/packages/editor/src/components/page-attributes/order.js @@ -7,8 +7,7 @@ import { FlexBlock, __experimentalNumberControl as NumberControl, } from '@wordpress/components'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; +import { useSelect, useDispatch } from '@wordpress/data'; import { useState } from '@wordpress/element'; /** @@ -17,17 +16,25 @@ import { useState } from '@wordpress/element'; import PostTypeSupportCheck from '../post-type-support-check'; import { store as editorStore } from '../../store'; -export const PageAttributesOrder = ( { onUpdateOrder, order = 0 } ) => { +function PageAttributesOrder() { + const order = useSelect( + ( select ) => + select( editorStore ).getEditedPostAttribute( 'menu_order' ) ?? 0, + [] + ); + const { editPost } = useDispatch( editorStore ); const [ orderInput, setOrderInput ] = useState( null ); const setUpdatedOrder = ( value ) => { setOrderInput( value ); const newOrder = Number( value ); if ( Number.isInteger( newOrder ) && value.trim?.() !== '' ) { - onUpdateOrder( Number( value ) ); + editPost( { menu_order: newOrder } ); } }; - const value = orderInput === null ? order : orderInput; + + const value = orderInput ?? order; + return ( <Flex> <FlexBlock> @@ -43,27 +50,12 @@ export const PageAttributesOrder = ( { onUpdateOrder, order = 0 } ) => { </FlexBlock> </Flex> ); -}; +} -function PageAttributesOrderWithChecks( props ) { +export default function PageAttributesOrderWithChecks() { return ( <PostTypeSupportCheck supportKeys="page-attributes"> - <PageAttributesOrder { ...props } /> + <PageAttributesOrder /> </PostTypeSupportCheck> ); } - -export default compose( [ - withSelect( ( select ) => { - return { - order: select( editorStore ).getEditedPostAttribute( 'menu_order' ), - }; - } ), - withDispatch( ( dispatch ) => ( { - onUpdateOrder( order ) { - dispatch( editorStore ).editPost( { - menu_order: order, - } ); - }, - } ) ), -] )( PageAttributesOrderWithChecks ); diff --git a/packages/editor/src/components/page-attributes/test/order.js b/packages/editor/src/components/page-attributes/test/order.js index 01b0bd269e07cf..245cbbb8fc71db 100644 --- a/packages/editor/src/components/page-attributes/test/order.js +++ b/packages/editor/src/components/page-attributes/test/order.js @@ -4,10 +4,43 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; + /** * Internal dependencies */ -import { PageAttributesOrder } from '../order'; +import PageAttributesOrder from '../order'; + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); +jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { + useDispatch: jest.fn(), +} ) ); + +function setupDataMock( order = 0 ) { + useSelect.mockImplementation( ( mapSelect ) => + mapSelect( () => ( { + getPostType: () => null, + getEditedPostAttribute: ( attr ) => { + switch ( attr ) { + case 'menu_order': + return order; + default: + return null; + } + }, + } ) ) + ); + + const editPost = jest.fn(); + useDispatch.mockImplementation( () => ( { + editPost, + } ) ); + + return editPost; +} describe( 'PageAttributesOrder', () => { /** @@ -22,9 +55,9 @@ describe( 'PageAttributesOrder', () => { it( 'should reject invalid input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock(); - render( <PageAttributesOrder onUpdateOrder={ onUpdateOrder } /> ); + render( <PageAttributesOrder /> ); const input = screen.getByRole( 'spinbutton', { name: 'Order' } ); await user.type( input, 'bad', typeOptions ); @@ -33,31 +66,29 @@ describe( 'PageAttributesOrder', () => { await user.type( input, '+', typeOptions ); await user.type( input, ' ', typeOptions ); - expect( onUpdateOrder ).not.toHaveBeenCalled(); + expect( editPost ).not.toHaveBeenCalled(); } ); it( 'should update with zero input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock( 4 ); - render( - <PageAttributesOrder order={ 4 } onUpdateOrder={ onUpdateOrder } /> - ); + render( <PageAttributesOrder /> ); const input = screen.getByRole( 'spinbutton', { name: 'Order' } ); await user.type( input, '0', typeOptions ); - expect( onUpdateOrder ).toHaveBeenCalledWith( 0 ); + expect( editPost ).toHaveBeenCalledWith( { menu_order: 0 } ); } ); it( 'should update with valid positive input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock(); - render( <PageAttributesOrder onUpdateOrder={ onUpdateOrder } /> ); + render( <PageAttributesOrder /> ); await user.type( screen.getByRole( 'spinbutton', { name: 'Order' } ), @@ -65,15 +96,15 @@ describe( 'PageAttributesOrder', () => { typeOptions ); - expect( onUpdateOrder ).toHaveBeenCalledWith( 4 ); + expect( editPost ).toHaveBeenCalledWith( { menu_order: 4 } ); } ); it( 'should update with valid negative input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock(); - render( <PageAttributesOrder onUpdateOrder={ onUpdateOrder } /> ); + render( <PageAttributesOrder /> ); await user.type( screen.getByRole( 'spinbutton', { name: 'Order' } ), @@ -81,6 +112,6 @@ describe( 'PageAttributesOrder', () => { typeOptions ); - expect( onUpdateOrder ).toHaveBeenCalledWith( -1 ); + expect( editPost ).toHaveBeenCalledWith( { menu_order: -1 } ); } ); } ); diff --git a/packages/editor/src/components/post-author/test/check.js b/packages/editor/src/components/post-author/test/check.js index 014599500743cc..597ab97ea2a8ad 100644 --- a/packages/editor/src/components/post-author/test/check.js +++ b/packages/editor/src/components/post-author/test/check.js @@ -19,32 +19,38 @@ jest.mock( '@wordpress/data/src/components/use-select', () => { return mock; } ); +function setupUseSelectMock( hasAssignAuthorAction, hasAuthors ) { + useSelect.mockImplementation( ( cb ) => { + return cb( () => ( { + getPostType: () => ( { supports: { author: true } } ), + getEditedPostAttribute: () => {}, + getCurrentPost: () => ( { + _links: { + 'wp:action-assign-author': hasAssignAuthorAction, + }, + } ), + getUsers: () => Array( hasAuthors ? 1 : 0 ).fill( {} ), + } ) ); + } ); +} + describe( 'PostAuthorCheck', () => { it( 'should not render anything if has no authors', () => { - useSelect.mockImplementation( () => ( { - hasAuthors: false, - hasAssignAuthorAction: true, - } ) ); + setupUseSelectMock( false, true ); render( <PostAuthorCheck>authors</PostAuthorCheck> ); expect( screen.queryByText( 'authors' ) ).not.toBeInTheDocument(); } ); it( "should not render anything if doesn't have author action", () => { - useSelect.mockImplementation( () => ( { - hasAuthors: true, - hasAssignAuthorAction: false, - } ) ); + setupUseSelectMock( true, false ); render( <PostAuthorCheck>authors</PostAuthorCheck> ); expect( screen.queryByText( 'authors' ) ).not.toBeInTheDocument(); } ); it( 'should render control', () => { - useSelect.mockImplementation( () => ( { - hasAuthors: true, - hasAssignAuthorAction: true, - } ) ); + setupUseSelectMock( true, true ); render( <PostAuthorCheck>authors</PostAuthorCheck> ); expect( screen.getByText( 'authors' ) ).toBeVisible(); diff --git a/packages/editor/src/components/post-comments/index.js b/packages/editor/src/components/post-comments/index.js index ea774964c9dd19..85d9948575d057 100644 --- a/packages/editor/src/components/post-comments/index.js +++ b/packages/editor/src/components/post-comments/index.js @@ -3,17 +3,23 @@ */ import { __ } from '@wordpress/i18n'; import { CheckboxControl } from '@wordpress/components'; -import { compose } from '@wordpress/compose'; -import { withSelect, withDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; -function PostComments( { commentStatus = 'open', ...props } ) { +function PostComments() { + const commentStatus = useSelect( + ( select ) => + select( editorStore ).getEditedPostAttribute( 'comment_status' ) ?? + 'open', + [] + ); + const { editPost } = useDispatch( editorStore ); const onToggleComments = () => - props.editPost( { + editPost( { comment_status: commentStatus === 'open' ? 'closed' : 'open', } ); @@ -27,16 +33,4 @@ function PostComments( { commentStatus = 'open', ...props } ) { ); } -export default compose( [ - withSelect( ( select ) => { - return { - commentStatus: - select( editorStore ).getEditedPostAttribute( - 'comment_status' - ), - }; - } ), - withDispatch( ( dispatch ) => ( { - editPost: dispatch( editorStore ).editPost, - } ) ), -] )( PostComments ); +export default PostComments; diff --git a/packages/editor/src/components/post-excerpt/check.js b/packages/editor/src/components/post-excerpt/check.js index a94a5badec180c..7d77ba77cd029a 100644 --- a/packages/editor/src/components/post-excerpt/check.js +++ b/packages/editor/src/components/post-excerpt/check.js @@ -3,8 +3,12 @@ */ import PostTypeSupportCheck from '../post-type-support-check'; -function PostExcerptCheck( props ) { - return <PostTypeSupportCheck { ...props } supportKeys="excerpt" />; +function PostExcerptCheck( { children } ) { + return ( + <PostTypeSupportCheck supportKeys="excerpt"> + { children } + </PostTypeSupportCheck> + ); } export default PostExcerptCheck; diff --git a/packages/editor/src/components/post-excerpt/index.js b/packages/editor/src/components/post-excerpt/index.js index 29e5bc0aee0890..47a1d3bf585850 100644 --- a/packages/editor/src/components/post-excerpt/index.js +++ b/packages/editor/src/components/post-excerpt/index.js @@ -3,22 +3,27 @@ */ import { __ } from '@wordpress/i18n'; import { ExternalLink, TextareaControl } from '@wordpress/components'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; -function PostExcerpt( { excerpt, onUpdateExcerpt } ) { +function PostExcerpt() { + const excerpt = useSelect( + ( select ) => select( editorStore ).getEditedPostAttribute( 'excerpt' ), + [] + ); + const { editPost } = useDispatch( editorStore ); + return ( <div className="editor-post-excerpt"> <TextareaControl __nextHasNoMarginBottom label={ __( 'Write an excerpt (optional)' ) } className="editor-post-excerpt__textarea" - onChange={ ( value ) => onUpdateExcerpt( value ) } + onChange={ ( value ) => editPost( { excerpt: value } ) } value={ excerpt } /> <ExternalLink @@ -32,15 +37,4 @@ function PostExcerpt( { excerpt, onUpdateExcerpt } ) { ); } -export default compose( [ - withSelect( ( select ) => { - return { - excerpt: select( editorStore ).getEditedPostAttribute( 'excerpt' ), - }; - } ), - withDispatch( ( dispatch ) => ( { - onUpdateExcerpt( excerpt ) { - dispatch( editorStore ).editPost( { excerpt } ); - }, - } ) ), -] )( PostExcerpt ); +export default PostExcerpt; diff --git a/packages/editor/src/components/post-featured-image/check.js b/packages/editor/src/components/post-featured-image/check.js index 5481deaa81604b..24a9f80058ddcc 100644 --- a/packages/editor/src/components/post-featured-image/check.js +++ b/packages/editor/src/components/post-featured-image/check.js @@ -4,10 +4,12 @@ import PostTypeSupportCheck from '../post-type-support-check'; import ThemeSupportCheck from '../theme-support-check'; -function PostFeaturedImageCheck( props ) { +function PostFeaturedImageCheck( { children } ) { return ( <ThemeSupportCheck supportKeys="post-thumbnails"> - <PostTypeSupportCheck { ...props } supportKeys="thumbnail" /> + <PostTypeSupportCheck supportKeys="thumbnail"> + { children } + </PostTypeSupportCheck> </ThemeSupportCheck> ); } diff --git a/packages/editor/src/components/post-format/check.js b/packages/editor/src/components/post-format/check.js index cc65bde0cdec24..0810c9d613aae4 100644 --- a/packages/editor/src/components/post-format/check.js +++ b/packages/editor/src/components/post-format/check.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -9,17 +9,22 @@ import { withSelect } from '@wordpress/data'; import PostTypeSupportCheck from '../post-type-support-check'; import { store as editorStore } from '../../store'; -function PostFormatCheck( { disablePostFormats, ...props } ) { +function PostFormatCheck( { children } ) { + const disablePostFormats = useSelect( + ( select ) => + select( editorStore ).getEditorSettings().disablePostFormats, + [] + ); + + if ( disablePostFormats ) { + return null; + } + return ( - ! disablePostFormats && ( - <PostTypeSupportCheck { ...props } supportKeys="post-formats" /> - ) + <PostTypeSupportCheck supportKeys="post-formats"> + { children } + </PostTypeSupportCheck> ); } -export default withSelect( ( select ) => { - const editorSettings = select( editorStore ).getEditorSettings(); - return { - disablePostFormats: editorSettings.disablePostFormats, - }; -} )( PostFormatCheck ); +export default PostFormatCheck; diff --git a/packages/editor/src/components/post-last-revision/check.js b/packages/editor/src/components/post-last-revision/check.js index eddb917db76d47..44f96b9cf7acb0 100644 --- a/packages/editor/src/components/post-last-revision/check.js +++ b/packages/editor/src/components/post-last-revision/check.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -9,11 +9,16 @@ import { withSelect } from '@wordpress/data'; import PostTypeSupportCheck from '../post-type-support-check'; import { store as editorStore } from '../../store'; -export function PostLastRevisionCheck( { - lastRevisionId, - revisionsCount, - children, -} ) { +function PostLastRevisionCheck( { children } ) { + const { lastRevisionId, revisionsCount } = useSelect( ( select ) => { + const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = + select( editorStore ); + return { + lastRevisionId: getCurrentPostLastRevisionId(), + revisionsCount: getCurrentPostRevisionsCount(), + }; + }, [] ); + if ( ! lastRevisionId || revisionsCount < 2 ) { return null; } @@ -25,11 +30,4 @@ export function PostLastRevisionCheck( { ); } -export default withSelect( ( select ) => { - const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = - select( editorStore ); - return { - lastRevisionId: getCurrentPostLastRevisionId(), - revisionsCount: getCurrentPostRevisionsCount(), - }; -} )( PostLastRevisionCheck ); +export default PostLastRevisionCheck; diff --git a/packages/editor/src/components/post-last-revision/index.js b/packages/editor/src/components/post-last-revision/index.js index 97434422b30f3d..17df8e8c38d3bf 100644 --- a/packages/editor/src/components/post-last-revision/index.js +++ b/packages/editor/src/components/post-last-revision/index.js @@ -3,7 +3,7 @@ */ import { sprintf, _n } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { backup } from '@wordpress/icons'; import { addQueryArgs } from '@wordpress/url'; @@ -13,7 +13,16 @@ import { addQueryArgs } from '@wordpress/url'; import PostLastRevisionCheck from './check'; import { store as editorStore } from '../../store'; -function LastRevision( { lastRevisionId, revisionsCount } ) { +function LastRevision() { + const { lastRevisionId, revisionsCount } = useSelect( ( select ) => { + const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = + select( editorStore ); + return { + lastRevisionId: getCurrentPostLastRevisionId(), + revisionsCount: getCurrentPostRevisionsCount(), + }; + }, [] ); + return ( <PostLastRevisionCheck> <Button @@ -34,11 +43,4 @@ function LastRevision( { lastRevisionId, revisionsCount } ) { ); } -export default withSelect( ( select ) => { - const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = - select( editorStore ); - return { - lastRevisionId: getCurrentPostLastRevisionId(), - revisionsCount: getCurrentPostRevisionsCount(), - }; -} )( LastRevision ); +export default LastRevision; diff --git a/packages/editor/src/components/post-last-revision/test/check.js b/packages/editor/src/components/post-last-revision/test/check.js index e6a08c738a95a4..6e5210e8ed6bfb 100644 --- a/packages/editor/src/components/post-last-revision/test/check.js +++ b/packages/editor/src/components/post-last-revision/test/check.js @@ -3,38 +3,50 @@ */ import { render, screen } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + /** * Internal dependencies */ -import { PostLastRevisionCheck } from '../check'; +import PostLastRevisionCheck from '../check'; + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); + +function setupDataMock( id, count ) { + useSelect.mockImplementation( ( mapSelect ) => + mapSelect( () => ( { + getCurrentPostLastRevisionId: () => id, + getCurrentPostRevisionsCount: () => count, + getEditedPostAttribute: () => null, + getPostType: () => null, + } ) ) + ); +} describe( 'PostLastRevisionCheck', () => { it( 'should not render anything if the last revision ID is unknown', () => { - render( - <PostLastRevisionCheck revisionsCount={ 2 }> - Children - </PostLastRevisionCheck> - ); + setupDataMock( null, 2 ); + + render( <PostLastRevisionCheck>Children</PostLastRevisionCheck> ); expect( screen.queryByText( 'Children' ) ).not.toBeInTheDocument(); } ); it( 'should not render anything if there is only one revision', () => { - render( - <PostLastRevisionCheck lastRevisionId={ 1 } revisionsCount={ 1 }> - Children - </PostLastRevisionCheck> - ); + setupDataMock( 1, 1 ); + + render( <PostLastRevisionCheck>Children</PostLastRevisionCheck> ); expect( screen.queryByText( 'Children' ) ).not.toBeInTheDocument(); } ); it( 'should render if there are two revisions', () => { - render( - <PostLastRevisionCheck lastRevisionId={ 1 } revisionsCount={ 2 }> - Children - </PostLastRevisionCheck> - ); + setupDataMock( 1, 2 ); + + render( <PostLastRevisionCheck>Children</PostLastRevisionCheck> ); expect( screen.getByText( 'Children' ) ).toBeVisible(); } ); diff --git a/packages/editor/src/components/post-pending-status/check.js b/packages/editor/src/components/post-pending-status/check.js index 54b155e8334a94..347cb6d7272276 100644 --- a/packages/editor/src/components/post-pending-status/check.js +++ b/packages/editor/src/components/post-pending-status/check.js @@ -1,19 +1,24 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; -export function PostPendingStatusCheck( { - hasPublishAction, - isPublished, - children, -} ) { +export function PostPendingStatusCheck( { children } ) { + const { hasPublishAction, isPublished } = useSelect( ( select ) => { + const { isCurrentPostPublished, getCurrentPost } = + select( editorStore ); + return { + hasPublishAction: + getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false, + isPublished: isCurrentPostPublished(), + }; + }, [] ); + if ( isPublished || ! hasPublishAction ) { return null; } @@ -21,15 +26,4 @@ export function PostPendingStatusCheck( { return children; } -export default compose( - withSelect( ( select ) => { - const { isCurrentPostPublished, getCurrentPostType, getCurrentPost } = - select( editorStore ); - return { - hasPublishAction: - getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false, - isPublished: isCurrentPostPublished(), - postType: getCurrentPostType(), - }; - } ) -)( PostPendingStatusCheck ); +export default PostPendingStatusCheck; diff --git a/packages/editor/src/components/post-pending-status/index.js b/packages/editor/src/components/post-pending-status/index.js index a51840d55d7bf8..85557517da6a11 100644 --- a/packages/editor/src/components/post-pending-status/index.js +++ b/packages/editor/src/components/post-pending-status/index.js @@ -3,8 +3,7 @@ */ import { __ } from '@wordpress/i18n'; import { CheckboxControl } from '@wordpress/components'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -12,10 +11,15 @@ import { compose } from '@wordpress/compose'; import PostPendingStatusCheck from './check'; import { store as editorStore } from '../../store'; -export function PostPendingStatus( { status, onUpdateStatus } ) { +export function PostPendingStatus() { + const status = useSelect( + ( select ) => select( editorStore ).getEditedPostAttribute( 'status' ), + [] + ); + const { editPost } = useDispatch( editorStore ); const togglePendingStatus = () => { const updatedStatus = status === 'pending' ? 'draft' : 'pending'; - onUpdateStatus( updatedStatus ); + editPost( { status: updatedStatus } ); }; return ( @@ -30,13 +34,4 @@ export function PostPendingStatus( { status, onUpdateStatus } ) { ); } -export default compose( - withSelect( ( select ) => ( { - status: select( editorStore ).getEditedPostAttribute( 'status' ), - } ) ), - withDispatch( ( dispatch ) => ( { - onUpdateStatus( status ) { - dispatch( editorStore ).editPost( { status } ); - }, - } ) ) -)( PostPendingStatus ); +export default PostPendingStatus; diff --git a/packages/editor/src/components/post-pending-status/test/check.js b/packages/editor/src/components/post-pending-status/test/check.js index 57bd0b12afc6c0..c5a338838dbdc9 100644 --- a/packages/editor/src/components/post-pending-status/test/check.js +++ b/packages/editor/src/components/post-pending-status/test/check.js @@ -3,27 +3,49 @@ */ import { render, screen } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + /** * Internal dependencies */ import { PostPendingStatusCheck } from '../check'; +jest.mock( '@wordpress/data/src/components/use-select', () => { + // This allows us to tweak the returned value on each test. + const mock = jest.fn(); + return mock; +} ); + +function setupUseSelectMock( hasPublishAction ) { + useSelect.mockImplementation( ( cb ) => { + return cb( () => ( { + isCurrentPostPublished: () => false, + getCurrentPost: () => ( { + _links: { + 'wp:action-publish': hasPublishAction, + }, + } ), + } ) ); + } ); +} + describe( 'PostPendingStatusCheck', () => { it( "should not render anything if the user doesn't have the right capabilities", () => { - render( - <PostPendingStatusCheck hasPublishAction={ false }> - status - </PostPendingStatusCheck> - ); + setupUseSelectMock( false ); + + render( <PostPendingStatusCheck>status</PostPendingStatusCheck> ); + expect( screen.queryByText( 'status' ) ).not.toBeInTheDocument(); } ); it( 'should render if the user has the correct capability', () => { - render( - <PostPendingStatusCheck hasPublishAction={ true }> - status - </PostPendingStatusCheck> - ); + setupUseSelectMock( true ); + + render( <PostPendingStatusCheck>status</PostPendingStatusCheck> ); + expect( screen.getByText( 'status' ) ).toBeVisible(); } ); } ); diff --git a/packages/editor/src/components/post-pingbacks/index.js b/packages/editor/src/components/post-pingbacks/index.js index b2274075f1fe4c..d6e1c419ee6f9d 100644 --- a/packages/editor/src/components/post-pingbacks/index.js +++ b/packages/editor/src/components/post-pingbacks/index.js @@ -3,17 +3,23 @@ */ import { __ } from '@wordpress/i18n'; import { CheckboxControl } from '@wordpress/components'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; -function PostPingbacks( { pingStatus = 'open', ...props } ) { +function PostPingbacks() { + const pingStatus = useSelect( + ( select ) => + select( editorStore ).getEditedPostAttribute( 'ping_status' ) ?? + 'open', + [] + ); + const { editPost } = useDispatch( editorStore ); const onTogglePingback = () => - props.editPost( { + editPost( { ping_status: pingStatus === 'open' ? 'closed' : 'open', } ); @@ -27,14 +33,4 @@ function PostPingbacks( { pingStatus = 'open', ...props } ) { ); } -export default compose( [ - withSelect( ( select ) => { - return { - pingStatus: - select( editorStore ).getEditedPostAttribute( 'ping_status' ), - }; - } ), - withDispatch( ( dispatch ) => ( { - editPost: dispatch( editorStore ).editPost, - } ) ), -] )( PostPingbacks ); +export default PostPingbacks; diff --git a/packages/editor/src/components/post-preview-button/index.js b/packages/editor/src/components/post-preview-button/index.js index b6500e8fbd37d4..9a9aa92d210c3d 100644 --- a/packages/editor/src/components/post-preview-button/index.js +++ b/packages/editor/src/components/post-preview-button/index.js @@ -1,16 +1,10 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ -import { Component, createRef, renderToString } from '@wordpress/element'; +import { renderToString } from '@wordpress/element'; import { Button, Path, SVG, VisuallyHidden } from '@wordpress/components'; import { __, _x } from '@wordpress/i18n'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { ifCondition, compose } from '@wordpress/compose'; +import { useSelect, useDispatch } from '@wordpress/data'; import { applyFilters } from '@wordpress/hooks'; import { store as coreStore } from '@wordpress/core-data'; @@ -105,48 +99,40 @@ function writeInterstitialMessage( targetDocument ) { targetDocument.close(); } -export class PostPreviewButton extends Component { - constructor() { - super( ...arguments ); - - this.buttonRef = createRef(); - - this.openPreviewWindow = this.openPreviewWindow.bind( this ); - } - - componentDidUpdate( prevProps ) { - const { previewLink } = this.props; - // This relies on the window being responsible to unset itself when - // navigation occurs or a new preview window is opened, to avoid - // unintentional forceful redirects. - if ( previewLink && ! prevProps.previewLink ) { - this.setPreviewWindowLink( previewLink ); - } - } - - /** - * Sets the preview window's location to the given URL, if a preview window - * exists and is not closed. - * - * @param {string} url URL to assign as preview window location. - */ - setPreviewWindowLink( url ) { - const { previewWindow } = this; - - if ( previewWindow && ! previewWindow.closed ) { - previewWindow.location = url; - if ( this.buttonRef.current ) { - this.buttonRef.current.focus(); - } - } +export default function PostPreviewButton( { + className, + textContent, + forceIsAutosaveable, + role, + onPreview, +} ) { + const { postId, currentPostLink, previewLink, isSaveable, isViewable } = + useSelect( ( select ) => { + const editor = select( editorStore ); + const core = select( coreStore ); + + const postType = core.getPostType( + editor.getCurrentPostType( 'type' ) + ); + + return { + postId: editor.getCurrentPostId(), + currentPostLink: editor.getCurrentPostAttribute( 'link' ), + previewLink: editor.getEditedPostPreviewLink(), + isSaveable: editor.isEditedPostSaveable(), + isViewable: postType?.viewable ?? false, + }; + }, [] ); + + const { __unstableSaveForPreview } = useDispatch( editorStore ); + + if ( ! isViewable ) { + return null; } - getWindowTarget() { - const { postId } = this.props; - return `wp-preview-${ postId }`; - } + const targetId = `wp-preview-${ postId }`; - openPreviewWindow( event ) { + const openPreviewWindow = async ( event ) => { // Our Preview button has its 'href' and 'target' set correctly for a11y // purposes. Unfortunately, though, we can't rely on the default 'click' // handler since sometimes it incorrectly opens a new tab instead of reusing @@ -155,117 +141,48 @@ export class PostPreviewButton extends Component { event.preventDefault(); // Open up a Preview tab if needed. This is where we'll show the preview. - if ( ! this.previewWindow || this.previewWindow.closed ) { - this.previewWindow = window.open( '', this.getWindowTarget() ); - } + const previewWindow = window.open( '', targetId ); // Focus the Preview tab. This might not do anything, depending on the browser's // and user's preferences. // https://html.spec.whatwg.org/multipage/interaction.html#dom-window-focus - this.previewWindow.focus(); - - if ( - // If we don't need to autosave the post before previewing, then we simply - // load the Preview URL in the Preview tab. - ! this.props.isAutosaveable || - // Do not save or overwrite the post, if the post is already locked. - this.props.isPostLocked - ) { - this.setPreviewWindowLink( event.target.href ); - return; - } - - // Request an autosave. This happens asynchronously and causes the component - // to update when finished. - if ( this.props.isDraft ) { - this.props.savePost( { isPreview: true } ); - } else { - this.props.autosave( { isPreview: true } ); - } - - // Display a 'Generating preview' message in the Preview tab while we wait for the - // autosave to finish. - writeInterstitialMessage( this.previewWindow.document ); - } - - render() { - const { previewLink, currentPostLink, isSaveable, role } = this.props; - - // Link to the `?preview=true` URL if we have it, since this lets us see - // changes that were autosaved since the post was last published. Otherwise, - // just link to the post's URL. - const href = previewLink || currentPostLink; - - const classNames = classnames( - { - 'editor-post-preview': ! this.props.className, - }, - this.props.className - ); - - return ( - <Button - variant={ ! this.props.className ? 'tertiary' : undefined } - className={ classNames } - href={ href } - target={ this.getWindowTarget() } - disabled={ ! isSaveable } - onClick={ this.openPreviewWindow } - ref={ this.buttonRef } - role={ role } - > - { this.props.textContent ? ( - this.props.textContent - ) : ( - <> - { _x( 'Preview', 'imperative verb' ) } - <VisuallyHidden as="span"> - { - /* translators: accessibility text */ - __( '(opens in a new tab)' ) - } - </VisuallyHidden> - </> - ) } - </Button> - ); - } + previewWindow.focus(); + + writeInterstitialMessage( previewWindow.document ); + + const link = await __unstableSaveForPreview( { forceIsAutosaveable } ); + + previewWindow.location = link; + + onPreview?.(); + }; + + // Link to the `?preview=true` URL if we have it, since this lets us see + // changes that were autosaved since the post was last published. Otherwise, + // just link to the post's URL. + const href = previewLink || currentPostLink; + + return ( + <Button + variant={ ! className ? 'tertiary' : undefined } + className={ className || 'editor-post-preview' } + href={ href } + target={ targetId } + disabled={ ! isSaveable } + onClick={ openPreviewWindow } + role={ role } + > + { textContent || ( + <> + { _x( 'Preview', 'imperative verb' ) } + <VisuallyHidden as="span"> + { + /* translators: accessibility text */ + __( '(opens in a new tab)' ) + } + </VisuallyHidden> + </> + ) } + </Button> + ); } - -export default compose( [ - withSelect( ( select, { forcePreviewLink, forceIsAutosaveable } ) => { - const { - getCurrentPostId, - getCurrentPostAttribute, - getEditedPostAttribute, - isEditedPostSaveable, - isEditedPostAutosaveable, - getEditedPostPreviewLink, - isPostLocked, - } = select( editorStore ); - const { getPostType } = select( coreStore ); - - const previewLink = getEditedPostPreviewLink(); - const postType = getPostType( getEditedPostAttribute( 'type' ) ); - - return { - postId: getCurrentPostId(), - currentPostLink: getCurrentPostAttribute( 'link' ), - previewLink: - forcePreviewLink !== undefined ? forcePreviewLink : previewLink, - isSaveable: isEditedPostSaveable(), - isAutosaveable: forceIsAutosaveable || isEditedPostAutosaveable(), - isViewable: postType?.viewable ?? false, - isDraft: - [ 'draft', 'auto-draft' ].indexOf( - getEditedPostAttribute( 'status' ) - ) !== -1, - isPostLocked: isPostLocked(), - }; - } ), - withDispatch( ( dispatch ) => ( { - autosave: dispatch( editorStore ).autosave, - savePost: dispatch( editorStore ).savePost, - } ) ), - ifCondition( ( { isViewable } ) => isViewable ), -] )( PostPreviewButton ); diff --git a/packages/editor/src/components/post-preview-button/test/index.js b/packages/editor/src/components/post-preview-button/test/index.js index 184550d25e4aa1..e34c05caa178bd 100644 --- a/packages/editor/src/components/post-preview-button/test/index.js +++ b/packages/editor/src/components/post-preview-button/test/index.js @@ -4,10 +4,39 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; + /** * Internal dependencies */ -import { PostPreviewButton } from '../'; +import PostPreviewButton from '..'; + +jest.useRealTimers(); + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); +jest.mock( '@wordpress/data/src/components/use-dispatch/use-dispatch', () => + jest.fn() +); + +function mockUseSelect( overrides ) { + useSelect.mockImplementation( ( map ) => + map( () => ( { + getPostType: () => ( { viewable: true } ), + getCurrentPostId: () => 123, + getCurrentPostType: () => 'post', + getCurrentPostAttribute: () => undefined, + getEditedPostPreviewLink: () => undefined, + isEditedPostSaveable: () => false, + ...overrides, + } ) ) + ); + useDispatch.mockImplementation( () => ( { + __unstableSaveForPreview: () => Promise.resolve(), + } ) ); +} describe( 'PostPreviewButton', () => { const documentWrite = jest.fn(); @@ -42,33 +71,39 @@ describe( 'PostPreviewButton', () => { } ); it( 'should render with `editor-post-preview` class if no `className` is specified.', () => { + mockUseSelect(); + render( <PostPreviewButton /> ); - expect( screen.getByRole( 'button' ) ).toHaveClass( - 'editor-post-preview' - ); + const button = screen.getByRole( 'button' ); + expect( button ).toHaveClass( 'editor-post-preview' ); } ); it( 'should render with a custom class and not `editor-post-preview` if `className` is specified.', () => { + mockUseSelect(); + render( <PostPreviewButton className="foo-bar" /> ); const button = screen.getByRole( 'button' ); - expect( button ).toHaveClass( 'foo-bar' ); expect( button ).not.toHaveClass( 'editor-post-preview' ); } ); it( 'should render a tertiary button if no classname is specified.', () => { + mockUseSelect(); + render( <PostPreviewButton /> ); - expect( screen.getByRole( 'button' ) ).toHaveClass( 'is-tertiary' ); + const button = screen.getByRole( 'button' ); + expect( button ).toHaveClass( 'is-tertiary' ); } ); it( 'should render the button in its default variant if a custom classname is specified.', () => { + mockUseSelect(); + render( <PostPreviewButton className="foo-bar" /> ); const button = screen.getByRole( 'button' ); - expect( button ).not.toHaveClass( 'is-primary' ); expect( button ).not.toHaveClass( 'is-secondary' ); expect( button ).not.toHaveClass( 'is-tertiary' ); @@ -76,12 +111,13 @@ describe( 'PostPreviewButton', () => { } ); it( 'should render `textContent` if specified.', () => { + mockUseSelect(); + const textContent = 'Foo bar'; render( <PostPreviewButton textContent={ textContent } /> ); const button = screen.getByRole( 'button' ); - expect( button ).toHaveTextContent( textContent ); expect( within( button ).queryByText( 'Preview' ) @@ -92,213 +128,113 @@ describe( 'PostPreviewButton', () => { } ); it( 'should render `Preview` with accessibility text if `textContent` not specified.', () => { + mockUseSelect(); + render( <PostPreviewButton /> ); const button = screen.getByRole( 'button' ); - expect( within( button ).getByText( 'Preview' ) ).toBeVisible(); expect( within( button ).getByText( '(opens in a new tab)' ) ).toBeInTheDocument(); } ); - it( 'should be disabled if post is not saveable.', async () => { - render( <PostPreviewButton isSaveable={ false } postId={ 123 } /> ); + it( 'should be disabled if post is not saveable.', () => { + mockUseSelect( { isEditedPostSaveable: () => false } ); + + render( <PostPreviewButton /> ); expect( screen.getByRole( 'button' ) ).toBeDisabled(); } ); - it( 'should not be disabled if post is saveable.', async () => { - render( <PostPreviewButton isSaveable postId={ 123 } /> ); + it( 'should not be disabled if post is saveable.', () => { + mockUseSelect( { isEditedPostSaveable: () => true } ); + + render( <PostPreviewButton /> ); expect( screen.getByRole( 'button' ) ).toBeEnabled(); } ); - it( 'should set `href` to `previewLink` if `previewLink` is specified.', async () => { + it( 'should set `href` to edited post preview link if specified.', () => { const url = 'https://wordpress.org'; + mockUseSelect( { + getEditedPostPreviewLink: () => url, + isEditedPostSaveable: () => true, + } ); - render( - <PostPreviewButton isSaveable postId={ 123 } previewLink={ url } /> - ); + render( <PostPreviewButton /> ); expect( screen.getByRole( 'link' ) ).toHaveAttribute( 'href', url ); } ); - it( 'should set `href` to `currentPostLink` if `currentPostLink` is specified.', async () => { + it( 'should set `href` to current post link if specified.', () => { const url = 'https://wordpress.org'; + mockUseSelect( { + getCurrentPostAttribute: () => url, + isEditedPostSaveable: () => true, + } ); - render( - <PostPreviewButton - isSaveable - postId={ 123 } - currentPostLink={ url } - /> - ); + render( <PostPreviewButton /> ); expect( screen.getByRole( 'link' ) ).toHaveAttribute( 'href', url ); } ); - it( 'should prioritize `previewLink` if both `previewLink` and `currentPostLink` are specified.', async () => { + it( 'should prioritize preview link if both preview link and link attribute are specified.', () => { const url1 = 'https://wordpress.org'; const url2 = 'https://wordpress.com'; + mockUseSelect( { + getEditedPostPreviewLink: () => url1, + getCurrentPostAttribute: () => url2, + isEditedPostSaveable: () => true, + } ); - render( - <PostPreviewButton - isSaveable - postId={ 123 } - previewLink={ url1 } - currentPostLink={ url2 } - /> - ); + render( <PostPreviewButton /> ); expect( screen.getByRole( 'link' ) ).toHaveAttribute( 'href', url1 ); } ); - it( 'should properly set target to `wp-preview-${ postId }`.', async () => { - const postId = 123; - const url = 'https://wordpress.org'; + it( 'should properly set link target', () => { + mockUseSelect( { + getEditedPostPreviewLink: () => 'https://wordpress.org', + isEditedPostSaveable: () => true, + } ); - render( - <PostPreviewButton - isSaveable - postId={ postId } - previewLink={ url } - /> - ); + render( <PostPreviewButton /> ); expect( screen.getByRole( 'link' ) ).toHaveAttribute( 'target', - `wp-preview-${ postId }` - ); - } ); - - it( 'should save post if `isDraft` is `true`', async () => { - const user = userEvent.setup(); - const url = 'https://wordpress.org'; - const savePost = jest.fn(); - const autosave = jest.fn(); - - render( - <PostPreviewButton - isAutosaveable - isSaveable - isDraft - postId={ 123 } - previewLink={ url } - savePost={ savePost } - autosave={ autosave } - /> - ); - - await user.click( screen.getByRole( 'link' ) ); - - expect( savePost ).toHaveBeenCalledWith( - expect.objectContaining( { isPreview: true } ) - ); - expect( autosave ).not.toHaveBeenCalled(); - } ); - - it( 'should autosave post if `isDraft` is `false`', async () => { - const user = userEvent.setup(); - const url = 'https://wordpress.org'; - const savePost = jest.fn(); - const autosave = jest.fn(); - - render( - <PostPreviewButton - isAutosaveable - isSaveable - isDraft={ false } - postId={ 123 } - previewLink={ url } - savePost={ savePost } - autosave={ autosave } - /> - ); - - await user.click( screen.getByRole( 'link' ) ); - - expect( savePost ).not.toHaveBeenCalled(); - expect( autosave ).toHaveBeenCalledWith( - expect.objectContaining( { isPreview: true } ) + 'wp-preview-123' ); } ); it( 'should open a window with the specified target', async () => { const user = userEvent.setup(); - const postId = 123; - const url = 'https://wordpress.org'; - render( - <PostPreviewButton - isAutosaveable - isSaveable - postId={ postId } - previewLink={ url } - savePost={ jest.fn() } - autosave={ jest.fn() } - /> - ); + mockUseSelect( { + getEditedPostPreviewLink: () => 'https://wordpress.org', + isEditedPostSaveable: () => true, + } ); + + render( <PostPreviewButton /> ); await user.click( screen.getByRole( 'link' ) ); - expect( global.open ).toHaveBeenCalledWith( - '', - `wp-preview-${ postId }` - ); + expect( global.open ).toHaveBeenCalledWith( '', 'wp-preview-123' ); } ); - it( 'should set the location in the window properly', async () => { + it( 'should display a `Generating preview` message while waiting for autosaving', async () => { const user = userEvent.setup(); - const postId = 123; - const url = 'https://wordpress.org'; - - const { rerender } = render( - <PostPreviewButton - isSaveable - postId={ postId } - savePost={ jest.fn() } - autosave={ jest.fn() } - /> - ); - - await user.click( screen.getByRole( 'button' ) ); - expect( setLocation ).toHaveBeenCalledWith( undefined ); + mockUseSelect( { + getEditedPostPreviewLink: () => 'https://wordpress.org', + isEditedPostSaveable: () => true, + } ); - rerender( - <PostPreviewButton - isSaveable - postId={ postId } - previewLink={ url } - savePost={ jest.fn() } - autosave={ jest.fn() } - /> - ); + render( <PostPreviewButton /> ); - expect( setLocation ).toHaveBeenCalledWith( url ); - } ); + await user.click( screen.getByRole( 'link' ) ); - it( 'should display a `Generating preview` message while waiting for autosaving', async () => { - const user = userEvent.setup(); const previewText = 'Generating preview…'; - const url = 'https://wordpress.org'; - const savePost = jest.fn(); - const autosave = jest.fn(); - - render( - <PostPreviewButton - isAutosaveable - isSaveable - isDraft={ false } - postId={ 123 } - previewLink={ url } - savePost={ savePost } - autosave={ autosave } - /> - ); - - await user.click( screen.getByRole( 'link' ) ); expect( documentWrite ).toHaveBeenCalledWith( expect.stringContaining( previewText ) diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js index b4ea3a07258455..b9140b733c9d30 100644 --- a/packages/editor/src/components/post-publish-button/index.js +++ b/packages/editor/src/components/post-publish-button/index.js @@ -102,7 +102,6 @@ export class PostPublishButton extends Component { render() { const { forceIsDirty, - forceIsSaving, hasPublishAction, isBeingScheduled, isOpen, @@ -124,7 +123,6 @@ export class PostPublishButton extends Component { const isButtonDisabled = ( isSaving || - forceIsSaving || ! isSaveable || isPostSavingLocked || ( ! isPublishable && ! forceIsDirty ) ) && @@ -133,7 +131,6 @@ export class PostPublishButton extends Component { const isToggleDisabled = ( isPublished || isSaving || - forceIsSaving || ! isSaveable || ( ! isPublishable && ! forceIsDirty ) ) && ( ! hasNonPostEntityChanges || isSavingNonPostEntityChanges ); @@ -187,7 +184,6 @@ export class PostPublishButton extends Component { : __( 'Publish' ); const buttonChildren = ( <PublishButtonLabel - forceIsSaving={ forceIsSaving } hasNonPostEntityChanges={ hasNonPostEntityChanges } /> ); @@ -231,10 +227,9 @@ export default compose( [ hasNonPostEntityChanges, isSavingNonPostEntityChanges, } = select( editorStore ); - const _isAutoSaving = isAutosavingPost(); return { - isSaving: isSavingPost() || _isAutoSaving, - isAutoSaving: _isAutoSaving, + isSaving: isSavingPost(), + isAutoSaving: isAutosavingPost(), isBeingScheduled: isEditedPostBeingScheduled(), visibility: getEditedPostVisibility(), isSaveable: isEditedPostSaveable(), diff --git a/packages/editor/src/components/post-publish-button/label.js b/packages/editor/src/components/post-publish-button/label.js index 05c7eb0f633e5b..0a0fdd30d8500d 100644 --- a/packages/editor/src/components/post-publish-button/label.js +++ b/packages/editor/src/components/post-publish-button/label.js @@ -44,7 +44,7 @@ export function PublishButtonLabel( { } export default compose( [ - withSelect( ( select, { forceIsSaving } ) => { + withSelect( ( select ) => { const { isCurrentPostPublished, isEditedPostBeingScheduled, @@ -57,7 +57,7 @@ export default compose( [ return { isPublished: isCurrentPostPublished(), isBeingScheduled: isEditedPostBeingScheduled(), - isSaving: forceIsSaving || isSavingPost(), + isSaving: isSavingPost(), isPublishing: isPublishingPost(), hasPublishAction: getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false, diff --git a/packages/editor/src/components/post-publish-button/test/index.js b/packages/editor/src/components/post-publish-button/test/index.js index f1177c0a0288a5..b0a8c3c5129c98 100644 --- a/packages/editor/src/components/post-publish-button/test/index.js +++ b/packages/editor/src/components/post-publish-button/test/index.js @@ -19,16 +19,6 @@ describe( 'PostPublishButton', () => { ).toHaveAttribute( 'aria-disabled', 'true' ); } ); - it( 'should be true if forceIsSaving is true', () => { - render( - <PostPublishButton isPublishable isSaveable forceIsSaving /> - ); - - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'true' ); - } ); - it( 'should be true if post is not publishable and not forceIsDirty', () => { render( <PostPublishButton diff --git a/packages/editor/src/components/post-publish-panel/index.js b/packages/editor/src/components/post-publish-panel/index.js index 8b2f9c67218f18..f07347c6c305f9 100644 --- a/packages/editor/src/components/post-publish-panel/index.js +++ b/packages/editor/src/components/post-publish-panel/index.js @@ -51,7 +51,6 @@ export class PostPublishPanel extends Component { render() { const { forceIsDirty, - forceIsSaving, isBeingScheduled, isPublished, isPublishSidebarEnabled, @@ -87,10 +86,9 @@ export class PostPublishPanel extends Component { <> <div className="editor-post-publish-panel__header-publish-button"> <PostPublishButton - focusOnMount={ true } + focusOnMount onSubmit={ this.onSubmit } forceIsDirty={ forceIsDirty } - forceIsSaving={ forceIsSaving } /> </div> <div className="editor-post-publish-panel__header-cancel-button"> @@ -141,6 +139,7 @@ export default compose( [ isCurrentPostScheduled, isEditedPostBeingScheduled, isEditedPostDirty, + isAutosavingPost, isSavingPost, isSavingNonPostEntityChanges, } = select( editorStore ); @@ -155,7 +154,7 @@ export default compose( [ isDirty: isEditedPostDirty(), isPublished: isCurrentPostPublished(), isPublishSidebarEnabled: isPublishSidebarEnabled(), - isSaving: isSavingPost(), + isSaving: isSavingPost() && ! isAutosavingPost(), isSavingNonPostEntityChanges: isSavingNonPostEntityChanges(), isScheduled: isCurrentPostScheduled(), }; diff --git a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js new file mode 100644 index 00000000000000..0097b3f0ea7419 --- /dev/null +++ b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js @@ -0,0 +1,167 @@ +/** + * WordPress dependencies + */ +import { + PanelBody, + Button, + Spinner, + __unstableMotion as motion, + __unstableAnimatePresence as AnimatePresence, +} from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { upload } from '@wordpress/icons'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; +import { isBlobURL } from '@wordpress/blob'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; + +function flattenBlocks( blocks ) { + const result = []; + + blocks.forEach( ( block ) => { + result.push( block ); + result.push( ...flattenBlocks( block.innerBlocks ) ); + } ); + + return result; +} + +function Image( block ) { + const { selectBlock } = useDispatch( blockEditorStore ); + return ( + <motion.img + tabIndex={ 0 } + role="button" + aria-label={ __( 'Select image block.' ) } + onClick={ () => { + selectBlock( block.clientId ); + } } + onKeyDown={ ( event ) => { + if ( event.key === 'Enter' || event.key === ' ' ) { + selectBlock( block.clientId ); + event.preventDefault(); + } + } } + key={ block.clientId } + alt={ block.attributes.alt } + src={ block.attributes.url } + animate={ { opacity: 1 } } + exit={ { opacity: 0, scale: 0 } } + style={ { + width: '36px', + height: '36px', + objectFit: 'cover', + borderRadius: '2px', + cursor: 'pointer', + } } + whileHover={ { scale: 1.08 } } + /> + ); +} + +export default function PostFormatPanel() { + const [ isUploading, setIsUploading ] = useState( false ); + const { editorBlocks, mediaUpload } = useSelect( + ( select ) => ( { + editorBlocks: select( editorStore ).getEditorBlocks(), + mediaUpload: select( blockEditorStore ).getSettings().mediaUpload, + } ), + [] + ); + const externalImages = flattenBlocks( editorBlocks ).filter( + ( block ) => + block.name === 'core/image' && + block.attributes.url && + ! block.attributes.id + ); + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + + if ( ! mediaUpload || ! externalImages.length ) { + return null; + } + + const panelBodyTitle = [ + __( 'Suggestion:' ), + <span className="editor-post-publish-panel__link" key="label"> + { __( 'External media' ) } + </span>, + ]; + + function uploadImages() { + setIsUploading( true ); + Promise.all( + externalImages.map( ( image ) => + window + .fetch( + image.attributes.url.includes( '?' ) + ? image.attributes.url + : image.attributes.url + '?' + ) + .then( ( response ) => response.blob() ) + .then( + ( blob ) => + new Promise( ( resolve, reject ) => { + mediaUpload( { + filesList: [ blob ], + onFileChange: ( [ media ] ) => { + if ( isBlobURL( media.url ) ) { + return; + } + + updateBlockAttributes( image.clientId, { + id: media.id, + url: media.url, + } ); + resolve(); + }, + onError() { + reject(); + }, + } ); + } ) + ) + ) + ).finally( () => { + setIsUploading( false ); + } ); + } + + return ( + <PanelBody initialOpen={ true } title={ panelBodyTitle }> + <p> + { __( + 'There are some external images in the post which can be uploaded to the media library. Images coming from different domains may not always display correctly, load slowly for visitors, or be removed unexpectedly.' + ) } + </p> + <div + style={ { + display: 'inline-flex', + flexWrap: 'wrap', + gap: '8px', + } } + > + <AnimatePresence> + { externalImages.map( ( image ) => { + return <Image key={ image.clientId } { ...image } />; + } ) } + </AnimatePresence> + { isUploading ? ( + <Spinner /> + ) : ( + <Button + icon={ upload } + variant="primary" + onClick={ uploadImages } + > + { __( 'Upload all' ) } + </Button> + ) } + </div> + </PanelBody> + ); +} diff --git a/packages/editor/src/components/post-publish-panel/prepublish.js b/packages/editor/src/components/post-publish-panel/prepublish.js index 656272bf64bef9..193960b9cc8345 100644 --- a/packages/editor/src/components/post-publish-panel/prepublish.js +++ b/packages/editor/src/components/post-publish-panel/prepublish.js @@ -20,6 +20,7 @@ import MaybeTagsPanel from './maybe-tags-panel'; import MaybePostFormatPanel from './maybe-post-format-panel'; import { store as editorStore } from '../../store'; import MaybeCategoryPanel from './maybe-category-panel'; +import MaybeUploadMedia from './maybe-upload-media'; function PostPublishPanelPrepublish( { children } ) { const { @@ -103,6 +104,7 @@ function PostPublishPanelPrepublish( { children } ) { <span className="components-site-home">{ siteHome }</span> </div> </div> + <MaybeUploadMedia /> { hasPublishAction && ( <> <PanelBody diff --git a/packages/editor/src/components/post-publish-panel/style.scss b/packages/editor/src/components/post-publish-panel/style.scss index adb1e317096a99..cc8fb6f764c3f9 100644 --- a/packages/editor/src/components/post-publish-panel/style.scss +++ b/packages/editor/src/components/post-publish-panel/style.scss @@ -6,7 +6,7 @@ // Ensure the post-publish panel accounts for the header and footer height. min-height: calc(100% - #{$header-height + 84px}); - .components-spinner { + > .components-spinner { display: block; margin: 100px auto 0; } @@ -82,7 +82,7 @@ } .editor-post-publish-panel__footer { - padding: 16px; + padding: $grid-unit-20; } .components-button.editor-post-publish-panel__toggle.is-primary { @@ -94,17 +94,17 @@ } .dashicon { - margin-right: -4px; + margin-right: -$grid-unit-05; } } .editor-post-publish-panel__link { font-weight: 400; - padding-left: 4px; + padding-left: $grid-unit-05; } .editor-post-publish-panel__prepublish { - padding: 16px; + padding: $grid-unit-20; strong { color: $gray-900; @@ -112,8 +112,8 @@ .components-panel__body { background: $white; - margin-left: -16px; - margin-right: -16px; + margin-left: -$grid-unit-20; + margin-right: -$grid-unit-20; } .editor-post-visibility__dialog-legend { @@ -130,21 +130,11 @@ display: flex; align-content: space-between; flex-wrap: wrap; - margin: -5px; - - > * { - flex-grow: 1; - margin: 5px; - } + gap: $grid-unit-20; .components-button { - height: auto; justify-content: center; - padding: 3px 10px 4px; flex: 1; - line-height: 1.6; - text-align: center; - white-space: normal; } .components-clipboard-button { @@ -162,21 +152,18 @@ } input[readonly] { - padding: 10px; - background: $gray-300; + padding: $grid-unit-20; + background: $gray-100; + border-color: $gray-400; overflow: hidden; text-overflow: ellipsis; + height: $button-size; } } .post-publish-panel__postpublish-post-address__copy-button-wrap { flex-shrink: 0; - margin-left: 8px; // margin left for copy button - - // match copy button height to the address field height - .components-button { - height: 38px; - } + margin-left: $grid-unit-20; } .post-publish-panel__postpublish-header { diff --git a/packages/editor/src/components/post-saved-state/index.js b/packages/editor/src/components/post-saved-state/index.js index d3c15b5596e3d9..ed3115d5a6c54a 100644 --- a/packages/editor/src/components/post-saved-state/index.js +++ b/packages/editor/src/components/post-saved-state/index.js @@ -29,14 +29,11 @@ import { store as editorStore } from '../../store'; * @param {Object} props Component props. * @param {?boolean} props.forceIsDirty Whether to force the post to be marked * as dirty. - * @param {?boolean} props.forceIsSaving Whether to force the post to be marked - * as being saved. * @param {?boolean} props.showIconLabels Whether interface buttons show labels instead of icons * @return {import('@wordpress/element').WPComponent} The component. */ export default function PostSavedState( { forceIsDirty, - forceIsSaving, showIconLabels = false, } ) { const [ forceSavedMessage, setForceSavedMessage ] = useState( false ); @@ -47,8 +44,10 @@ export default function PostSavedState( { isDirty, isNew, isPending, + isPublished, isSaveable, isSaving, + isScheduled, hasPublishAction, } = useSelect( ( select ) => { @@ -70,14 +69,14 @@ export default function PostSavedState( { isNew: isEditedPostNew(), isPending: 'pending' === getEditedPostAttribute( 'status' ), isPublished: isCurrentPostPublished(), - isSaving: forceIsSaving || isSavingPost(), + isSaving: isSavingPost(), isSaveable: isEditedPostSaveable(), isScheduled: isCurrentPostScheduled(), hasPublishAction: getCurrentPost()?._links?.[ 'wp:action-publish' ] ?? false, }; }, - [ forceIsDirty, forceIsSaving ] + [ forceIsDirty ] ); const { savePost } = useDispatch( editorStore ); @@ -103,6 +102,10 @@ export default function PostSavedState( { return null; } + if ( isPublished || isScheduled ) { + return null; + } + /* translators: button label text should, if possible, be under 16 characters. */ const label = isPending ? __( 'Save as pending' ) : __( 'Save draft' ); diff --git a/packages/editor/src/components/post-schedule/label.js b/packages/editor/src/components/post-schedule/label.js index b6b4430624763b..e2b511ead762b1 100644 --- a/packages/editor/src/components/post-schedule/label.js +++ b/packages/editor/src/components/post-schedule/label.js @@ -33,7 +33,7 @@ export function getFullPostScheduleLabel( dateAttribute ) { const timezoneAbbreviation = getTimezoneAbbreviation(); const formattedDate = dateI18n( - // translators: If using a space between 'g:i' and 'a', use a non-breaking sapce. + // translators: If using a space between 'g:i' and 'a', use a non-breaking space. _x( 'F j, Y g:i\xa0a', 'post schedule full date format' ), date ); @@ -62,7 +62,7 @@ export function getPostScheduleLabel( return sprintf( // translators: %s: Time of day the post is scheduled for. __( 'Today at %s' ), - // translators: If using a space between 'g:i' and 'a', use a non-breaking sapce. + // translators: If using a space between 'g:i' and 'a', use a non-breaking space. dateI18n( _x( 'g:i\xa0a', 'post schedule time format' ), date ) ); } @@ -74,14 +74,14 @@ export function getPostScheduleLabel( return sprintf( // translators: %s: Time of day the post is scheduled for. __( 'Tomorrow at %s' ), - // translators: If using a space between 'g:i' and 'a', use a non-breaking sapce. + // translators: If using a space between 'g:i' and 'a', use a non-breaking space. dateI18n( _x( 'g:i\xa0a', 'post schedule time format' ), date ) ); } if ( date.getFullYear() === now.getFullYear() ) { return dateI18n( - // translators: If using a space between 'g:i' and 'a', use a non-breaking sapce. + // translators: If using a space between 'g:i' and 'a', use a non-breaking space. _x( 'F j g:i\xa0a', 'post schedule date format without year' ), date ); diff --git a/packages/editor/src/components/post-switch-to-draft-button/index.js b/packages/editor/src/components/post-switch-to-draft-button/index.js index 6521200fde590e..1fb04931bfce14 100644 --- a/packages/editor/src/components/post-switch-to-draft-button/index.js +++ b/packages/editor/src/components/post-switch-to-draft-button/index.js @@ -3,7 +3,6 @@ */ import { Button, - FlexItem, __experimentalConfirmDialog as ConfirmDialog, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -41,7 +40,7 @@ function PostSwitchToDraftButton( { }; return ( - <FlexItem isBlock> + <> <Button className="editor-post-switch-to-draft" onClick={ () => { @@ -49,7 +48,7 @@ function PostSwitchToDraftButton( { } } disabled={ isSaving } variant="secondary" - style={ { width: '100%', display: 'block' } } + style={ { flexGrow: '1', justifyContent: 'center' } } > { __( 'Switch to draft' ) } </Button> @@ -60,7 +59,7 @@ function PostSwitchToDraftButton( { > { alertMessage } </ConfirmDialog> - </FlexItem> + </> ); } diff --git a/packages/editor/src/components/post-sync-status/index.js b/packages/editor/src/components/post-sync-status/index.js new file mode 100644 index 00000000000000..bbc8cd0cfe974f --- /dev/null +++ b/packages/editor/src/components/post-sync-status/index.js @@ -0,0 +1,128 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { + PanelRow, + Modal, + Button, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + ToggleControl, +} from '@wordpress/components'; +import { useEffect, useState } from '@wordpress/element'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +export default function PostSyncStatus() { + const { syncStatus, postType, meta } = useSelect( ( select ) => { + const { getEditedPostAttribute } = select( editorStore ); + return { + syncStatus: getEditedPostAttribute( 'wp_pattern_sync_status' ), + meta: getEditedPostAttribute( 'meta' ), + postType: getEditedPostAttribute( 'type' ), + }; + } ); + + if ( postType !== 'wp_block' ) { + return null; + } + // When the post is first created, the top level wp_pattern_sync_status is not set so get meta value instead. + const currentSyncStatus = + meta?.wp_pattern_sync_status === 'unsynced' ? 'unsynced' : syncStatus; + + return ( + <PanelRow className="edit-post-sync-status"> + <span>{ __( 'Sync status' ) }</span> + <div> + { currentSyncStatus === 'unsynced' + ? __( 'Not synced' ) + : __( 'Fully synced' ) } + </div> + </PanelRow> + ); +} + +export function PostSyncStatusModal() { + const { editPost } = useDispatch( editorStore ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const [ syncType, setSyncType ] = useState( undefined ); + + const { postType, isNewPost } = useSelect( ( select ) => { + const { getEditedPostAttribute, isCleanNewPost } = + select( editorStore ); + return { + postType: getEditedPostAttribute( 'type' ), + isNewPost: isCleanNewPost(), + }; + }, [] ); + + useEffect( () => { + if ( isNewPost && postType === 'wp_block' ) { + setIsModalOpen( true ); + } + // We only want the modal to open when the page is first loaded. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + const setSyncStatus = () => { + editPost( { + meta: { + wp_pattern_sync_status: syncType, + }, + } ); + }; + + if ( postType !== 'wp_block' || ! isNewPost ) { + return null; + } + const { ReusableBlocksRenameHint } = unlock( blockEditorPrivateApis ); + return ( + <> + { isModalOpen && ( + <Modal + title={ __( 'Set pattern sync status' ) } + onRequestClose={ () => { + setIsModalOpen( false ); + } } + overlayClassName="reusable-blocks-menu-items__convert-modal" + > + <form + onSubmit={ ( event ) => { + event.preventDefault(); + setIsModalOpen( false ); + setSyncStatus(); + } } + > + <VStack spacing="5"> + <ReusableBlocksRenameHint /> + <ToggleControl + label={ __( 'Synced' ) } + help={ __( + 'Editing the pattern will update it anywhere it is used.' + ) } + checked={ ! syncType } + onChange={ () => { + setSyncType( + ! syncType ? 'unsynced' : undefined + ); + } } + /> + <HStack justify="right"> + <Button variant="primary" type="submit"> + { __( 'Create' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> + ) } + </> + ); +} diff --git a/packages/editor/src/components/post-sync-status/style.scss b/packages/editor/src/components/post-sync-status/style.scss new file mode 100644 index 00000000000000..90a75c86bf466d --- /dev/null +++ b/packages/editor/src/components/post-sync-status/style.scss @@ -0,0 +1,19 @@ +.edit-post-sync-status { + width: 100%; + position: relative; + justify-content: flex-start; + align-items: flex-start; + + > span { + display: block; + width: 45%; + flex-shrink: 0; + padding: $grid-unit-15 * 0.5 0; + word-break: break-word; + } + + > div { + // Match padding on tertiary buttons for alignment. + padding: $grid-unit-15 * 0.5 0 $grid-unit-15 * 0.5 $grid-unit-15; + } +} diff --git a/packages/editor/src/components/post-taxonomies/flat-term-selector.js b/packages/editor/src/components/post-taxonomies/flat-term-selector.js index b586c07c80d4c9..9aba183a7e4af7 100644 --- a/packages/editor/src/components/post-taxonomies/flat-term-selector.js +++ b/packages/editor/src/components/post-taxonomies/flat-term-selector.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import escapeHtml from 'escape-html'; - /** * WordPress dependencies */ @@ -12,7 +7,6 @@ import { FormTokenField, withFilters } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { useDebounce } from '@wordpress/compose'; -import apiFetch from '@wordpress/api-fetch'; import { speak } from '@wordpress/a11y'; /** @@ -51,28 +45,6 @@ const termNamesToIds = ( names, terms ) => { ); }; -// Tries to create a term or fetch it if it already exists. -function findOrCreateTerm( termName, restBase, namespace ) { - const escapedTermName = escapeHtml( termName ); - - return apiFetch( { - path: `/${ namespace }/${ restBase }`, - method: 'POST', - data: { name: escapedTermName }, - } ) - .catch( ( error ) => { - if ( error.code !== 'term_exists' ) { - return Promise.reject( error ); - } - - return Promise.resolve( { - id: error.data.term_id, - name: termName, - } ); - } ) - .then( unescapeTerm ); -} - export function FlatTermSelector( { slug } ) { const [ values, setValues ] = useState( [] ); const [ search, setSearch ] = useState( '' ); @@ -165,11 +137,30 @@ export function FlatTermSelector( { slug } ) { }, [ searchResults ] ); const { editPost } = useDispatch( editorStore ); + const { saveEntityRecord } = useDispatch( coreStore ); if ( ! hasAssignAction ) { return null; } + async function findOrCreateTerm( term ) { + try { + const newTerm = await saveEntityRecord( 'taxonomy', slug, term, { + throwOnError: true, + } ); + return unescapeTerm( newTerm ); + } catch ( error ) { + if ( error.code !== 'term_exists' ) { + throw error; + } + + return { + id: error.data.term_id, + name: term.name, + }; + } + } + function onUpdateTerms( newTermIds ) { editPost( { [ taxonomy.rest_base ]: newTermIds } ); } @@ -209,10 +200,9 @@ export function FlatTermSelector( { slug } ) { return; } - const namespace = taxonomy?.rest_namespace ?? 'wp/v2'; Promise.all( newTermNames.map( ( termName ) => - findOrCreateTerm( termName, taxonomy.rest_base, namespace ) + findOrCreateTerm( { name: termName } ) ) ).then( ( newTerms ) => { const newAvailableTerms = availableTerms.concat( newTerms ); diff --git a/packages/editor/src/components/post-text-editor/index.js b/packages/editor/src/components/post-text-editor/index.js index b771c74605526c..c07d72f15c4a9d 100644 --- a/packages/editor/src/components/post-text-editor/index.js +++ b/packages/editor/src/components/post-text-editor/index.js @@ -7,8 +7,9 @@ import Textarea from 'react-autosize-textarea'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useEffect, useState, useRef } from '@wordpress/element'; -import { parse } from '@wordpress/blocks'; +import { store as coreStore } from '@wordpress/core-data'; +import { useMemo } from '@wordpress/element'; +import { __unstableSerializeAndClean } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; import { useInstanceId } from '@wordpress/compose'; import { VisuallyHidden } from '@wordpress/components'; @@ -19,63 +20,34 @@ import { VisuallyHidden } from '@wordpress/components'; import { store as editorStore } from '../../store'; export default function PostTextEditor() { - const postContent = useSelect( - ( select ) => select( editorStore ).getEditedPostContent(), - [] - ); - - const { editPost, resetEditorBlocks } = useDispatch( editorStore ); - - const [ value, setValue ] = useState( postContent ); - const [ isDirty, setIsDirty ] = useState( false ); const instanceId = useInstanceId( PostTextEditor ); - const valueRef = useRef(); - - if ( ! isDirty && value !== postContent ) { - setValue( postContent ); - } + const { content, blocks, type, id } = useSelect( ( select ) => { + const { getEditedEntityRecord } = select( coreStore ); + const { getCurrentPostType, getCurrentPostId } = select( editorStore ); + const _type = getCurrentPostType(); + const _id = getCurrentPostId(); + const editedRecord = getEditedEntityRecord( 'postType', _type, _id ); - /** - * Handles a textarea change event to notify the onChange prop callback and - * reflect the new value in the component's own state. This marks the start - * of the user's edits, if not already changed, preventing future props - * changes to value from replacing the rendered value. This is expected to - * be followed by a reset to dirty state via `stopEditing`. - * - * @see stopEditing - * - * @param {Event} event Change event. - */ - const onChange = ( event ) => { - const newValue = event.target.value; - editPost( { content: newValue } ); - setValue( newValue ); - setIsDirty( true ); - valueRef.current = newValue; - }; - - /** - * Function called when the user has completed their edits, responsible for - * ensuring that changes, if made, are surfaced to the onPersist prop - * callback and resetting dirty state. - */ - const stopEditing = () => { - if ( isDirty ) { - const blocks = parse( value ); - resetEditorBlocks( blocks ); - setIsDirty( false ); - } - }; - - // Ensure changes aren't lost when component unmounts. - useEffect( () => { - return () => { - if ( valueRef.current ) { - const blocks = parse( valueRef.current ); - resetEditorBlocks( blocks ); - } + return { + content: editedRecord?.content, + blocks: editedRecord?.blocks, + type: _type, + id: _id, }; }, [] ); + const { editEntityRecord } = useDispatch( coreStore ); + // Replicates the logic found in getEditedPostContent(). + const value = useMemo( () => { + if ( content instanceof Function ) { + return content( { blocks } ); + } else if ( blocks ) { + // If we have parsed blocks already, they should be our source of truth. + // Parsing applies block deprecations and legacy block conversions that + // unparsed content will not have. + return __unstableSerializeAndClean( blocks ); + } + return content; + }, [ content, blocks ] ); return ( <> @@ -89,8 +61,13 @@ export default function PostTextEditor() { autoComplete="off" dir="auto" value={ value } - onChange={ onChange } - onBlur={ stopEditing } + onChange={ ( event ) => { + editEntityRecord( 'postType', type, id, { + content: event.target.value, + blocks: undefined, + selection: undefined, + } ); + } } className="editor-post-text-editor" id={ `post-content-${ instanceId }` } placeholder={ __( 'Start writing with text or HTML' ) } diff --git a/packages/editor/src/components/post-text-editor/test/index.js b/packages/editor/src/components/post-text-editor/test/index.js deleted file mode 100644 index acaa7e95600b82..00000000000000 --- a/packages/editor/src/components/post-text-editor/test/index.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * External dependencies - */ -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import PostTextEditor from '../'; - -// "Downgrade" ReactAutosizeTextarea to a regular textarea. Assumes aligned -// props interface. -jest.mock( 'react-autosize-textarea', () => ( props ) => ( - <textarea { ...props } /> -) ); - -jest.mock( '@wordpress/data/src/components/use-select', () => { - // This allows us to tweak the returned value on each test. - const mock = jest.fn(); - return mock; -} ); - -let mockEditPost = jest.fn(); -let mockResetEditorBlocks = jest.fn(); - -jest.mock( '@wordpress/data/src/components/use-dispatch', () => { - return { - useDispatch: () => ( { - editPost: mockEditPost, - resetEditorBlocks: mockResetEditorBlocks, - } ), - }; -} ); - -describe( 'PostTextEditor', () => { - beforeEach( () => { - useSelect.mockImplementation( () => 'Hello World' ); - - mockEditPost = jest.fn(); - mockResetEditorBlocks = jest.fn(); - } ); - - it( 'should render via the value from useSelect', () => { - render( <PostTextEditor /> ); - - expect( screen.getByLabelText( 'Type text or HTML' ) ).toHaveValue( - 'Hello World' - ); - } ); - - it( 'should render via the state value when edits made', async () => { - const user = userEvent.setup(); - render( <PostTextEditor /> ); - - const textarea = screen.getByLabelText( 'Type text or HTML' ); - - await user.clear( textarea ); - await user.type( textarea, 'Hello Chicken' ); - - expect( textarea ).toHaveValue( 'Hello Chicken' ); - expect( mockEditPost ).toHaveBeenCalledWith( { - content: 'Hello Chicken', - } ); - } ); - - it( 'should render via the state value when edits made, even if prop value changes', async () => { - const user = userEvent.setup(); - const { rerender } = render( <PostTextEditor /> ); - - const textarea = screen.getByLabelText( 'Type text or HTML' ); - - await user.clear( textarea ); - await user.type( textarea, 'Hello Chicken' ); - - useSelect.mockImplementation( () => 'Goodbye World' ); - - rerender( <PostTextEditor /> ); - - expect( textarea ).toHaveValue( 'Hello Chicken' ); - expect( mockEditPost ).toHaveBeenCalledWith( { - content: 'Hello Chicken', - } ); - } ); - - it( 'should render via the state value when edits made, even if prop value changes and state value empty', async () => { - const user = userEvent.setup(); - const { rerender } = render( <PostTextEditor /> ); - - const textarea = screen.getByLabelText( 'Type text or HTML' ); - - await user.clear( textarea ); - - useSelect.mockImplementation( () => 'Goodbye World' ); - - rerender( <PostTextEditor /> ); - - expect( textarea ).toHaveValue( '' ); - expect( mockEditPost ).toHaveBeenCalledWith( { - content: '', - } ); - } ); - - it( 'calls onPersist after changes made and user stops editing', async () => { - const user = userEvent.setup(); - render( <PostTextEditor /> ); - - const textarea = screen.getByLabelText( 'Type text or HTML' ); - - await user.clear( textarea ); - - // Stop editing. - act( () => { - textarea.blur(); - } ); - - expect( mockResetEditorBlocks ).toHaveBeenCalledWith( [] ); - } ); - - it( 'does not call onPersist after user stops editing without changes', () => { - render( <PostTextEditor /> ); - - // Stop editing. - screen.getByLabelText( 'Type text or HTML' ).blur(); - - expect( mockResetEditorBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'resets to prop value after user stops editing', async () => { - // This isn't the most realistic case, since typically we'd assume the - // parent renderer to pass the value as it had received onPersist. The - // test here is more an edge case to stress that it's intentionally - // differentiating between state and prop values. - const user = userEvent.setup(); - const { rerender } = render( <PostTextEditor /> ); - - const textarea = screen.getByLabelText( 'Type text or HTML' ); - - await user.clear( textarea ); - - useSelect.mockImplementation( () => 'Goodbye World' ); - - rerender( <PostTextEditor /> ); - - act( () => { - textarea.blur(); - } ); - - expect( textarea ).toHaveValue( 'Goodbye World' ); - } ); -} ); diff --git a/packages/editor/src/components/post-title/index.js b/packages/editor/src/components/post-title/index.js index b9143a29ff3c05..d7094b080de9d3 100644 --- a/packages/editor/src/components/post-title/index.js +++ b/packages/editor/src/components/post-title/index.js @@ -74,7 +74,10 @@ function PostTitle( _, forwardedRef ) { return; } - const { ownerDocument } = ref.current; + const { defaultView } = ref.current.ownerDocument; + const { name, parent } = defaultView; + const ownerDocument = + name === 'editor-canvas' ? parent.document : defaultView.document; const { activeElement, body } = ownerDocument; // Only autofocus the title when the post is entirely empty. This should diff --git a/packages/editor/src/components/post-title/style.native.scss b/packages/editor/src/components/post-title/style.native.scss index 05727a318300ad..42b0b5bce146db 100644 --- a/packages/editor/src/components/post-title/style.native.scss +++ b/packages/editor/src/components/post-title/style.native.scss @@ -1,10 +1,10 @@ .titleContainer { - padding-left: 12; - padding-right: 16; - padding-top: 12; - padding-bottom: 12; - margin-top: 24; + padding-left: $block-edge-to-content; + padding-right: $block-edge-to-content; + padding-top: 6; + padding-bottom: 6; + margin-top: 12; } .dimmed { diff --git a/packages/editor/src/components/post-trash/style.scss b/packages/editor/src/components/post-trash/style.scss index f24a6eb2743dd4..e47981314d5f28 100644 --- a/packages/editor/src/components/post-trash/style.scss +++ b/packages/editor/src/components/post-trash/style.scss @@ -1,4 +1,4 @@ .editor-post-trash.components-button { - width: 100%; - display: block; + flex-grow: 1; + justify-content: center; } diff --git a/packages/editor/src/components/post-type-support-check/index.js b/packages/editor/src/components/post-type-support-check/index.js index 57a774fc17e422..fffabf6ab247c9 100644 --- a/packages/editor/src/components/post-type-support-check/index.js +++ b/packages/editor/src/components/post-type-support-check/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; /** @@ -14,7 +14,6 @@ import { store as editorStore } from '../../store'; * type supports one of the given `supportKeys` prop. * * @param {Object} props Props. - * @param {string} [props.postType] Current post type. * @param {WPElement} props.children Children to be rendered if post * type supports. * @param {(string|string[])} props.supportKeys String or string array of keys @@ -22,7 +21,12 @@ import { store as editorStore } from '../../store'; * * @return {WPComponent} The component to be rendered. */ -export function PostTypeSupportCheck( { postType, children, supportKeys } ) { +function PostTypeSupportCheck( { children, supportKeys } ) { + const postType = useSelect( ( select ) => { + const { getEditedPostAttribute } = select( editorStore ); + const { getPostType } = select( coreStore ); + return getPostType( getEditedPostAttribute( 'type' ) ); + }, [] ); let isSupported = true; if ( postType ) { isSupported = ( @@ -37,10 +41,4 @@ export function PostTypeSupportCheck( { postType, children, supportKeys } ) { return children; } -export default withSelect( ( select ) => { - const { getEditedPostAttribute } = select( editorStore ); - const { getPostType } = select( coreStore ); - return { - postType: getPostType( getEditedPostAttribute( 'type' ) ), - }; -} )( PostTypeSupportCheck ); +export default PostTypeSupportCheck; diff --git a/packages/editor/src/components/post-type-support-check/test/index.js b/packages/editor/src/components/post-type-support-check/test/index.js index c9f9e0ab1ebe36..8acef8abacb6b2 100644 --- a/packages/editor/src/components/post-type-support-check/test/index.js +++ b/packages/editor/src/components/post-type-support-check/test/index.js @@ -3,15 +3,37 @@ */ import { render } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + /** * Internal dependencies */ -import { PostTypeSupportCheck } from '../'; +import PostTypeSupportCheck from '../'; + +jest.mock( '@wordpress/data/src/components/use-select', () => { + // This allows us to tweak the returned value on each test. + const mock = jest.fn(); + return mock; +} ); + +function setupUseSelectMock( postType ) { + useSelect.mockImplementation( ( cb ) => { + return cb( () => ( { + getPostType: () => postType, + getEditedPostAttribute: () => 'post', + } ) ); + } ); +} describe( 'PostTypeSupportCheck', () => { it( 'renders its children when post type is not known', () => { + setupUseSelectMock( undefined ); + const { container } = render( - <PostTypeSupportCheck postType={ undefined } supportKeys="title"> + <PostTypeSupportCheck supportKeys="title"> Supported </PostTypeSupportCheck> ); @@ -20,11 +42,11 @@ describe( 'PostTypeSupportCheck', () => { } ); it( 'does not render its children when post type is known and not supports', () => { - const postType = { + setupUseSelectMock( { supports: {}, - }; + } ); const { container } = render( - <PostTypeSupportCheck postType={ postType } supportKeys="title"> + <PostTypeSupportCheck supportKeys="title"> Supported </PostTypeSupportCheck> ); @@ -33,13 +55,13 @@ describe( 'PostTypeSupportCheck', () => { } ); it( 'renders its children when post type is known and supports', () => { - const postType = { + setupUseSelectMock( { supports: { title: true, }, - }; + } ); const { container } = render( - <PostTypeSupportCheck postType={ postType } supportKeys="title"> + <PostTypeSupportCheck supportKeys="title"> Supported </PostTypeSupportCheck> ); @@ -48,16 +70,13 @@ describe( 'PostTypeSupportCheck', () => { } ); it( 'renders its children if some of keys supported', () => { - const postType = { + setupUseSelectMock( { supports: { title: true, }, - }; + } ); const { container } = render( - <PostTypeSupportCheck - postType={ postType } - supportKeys={ [ 'title', 'thumbnail' ] } - > + <PostTypeSupportCheck supportKeys={ [ 'title', 'thumbnail' ] }> Supported </PostTypeSupportCheck> ); @@ -66,14 +85,11 @@ describe( 'PostTypeSupportCheck', () => { } ); it( 'does not render its children if none of keys supported', () => { - const postType = { + setupUseSelectMock( { supports: {}, - }; + } ); const { container } = render( - <PostTypeSupportCheck - postType={ postType } - supportKeys={ [ 'title', 'thumbnail' ] } - > + <PostTypeSupportCheck supportKeys={ [ 'title', 'thumbnail' ] }> Supported </PostTypeSupportCheck> ); diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 1cff19c7daae7f..f2f6102a710c67 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -10,8 +10,8 @@ import { BlockContextProvider, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; -import { ReusableBlocksMenuItems } from '@wordpress/reusable-blocks'; import { store as noticesStore } from '@wordpress/notices'; +import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns'; /** * Internal dependencies @@ -19,9 +19,10 @@ import { store as noticesStore } from '@wordpress/notices'; import withRegistryProvider from './with-registry-provider'; import { store as editorStore } from '../../store'; import useBlockEditorSettings from './use-block-editor-settings'; -import { unlock } from '../../lockUnlock'; +import { unlock } from '../../lock-unlock'; const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); +const { PatternsMenuItems } = unlock( editPatternsPrivateApis ); export const ExperimentalEditorProvider = withRegistryProvider( ( { @@ -130,7 +131,7 @@ export const ExperimentalEditorProvider = withRegistryProvider( useSubRegistry={ false } > { children } - <ReusableBlocksMenuItems /> + <PatternsMenuItems /> </BlockEditorProviderComponent> </BlockContextProvider> </EntityProvider> diff --git a/packages/editor/src/components/provider/index.native.js b/packages/editor/src/components/provider/index.native.js index 73648a90a84f71..5fd6a4cdbb888b 100644 --- a/packages/editor/src/components/provider/index.native.js +++ b/packages/editor/src/components/provider/index.native.js @@ -319,8 +319,6 @@ class NativeEditorProvider extends Component { const { mode, switchMode } = this.props; // Refresh html content first. this.serializeToNativeAction(); - // Make sure to blur the selected block and dismiss the keyboard. - this.props.clearSelectedBlock(); switchMode( mode === 'visual' ? 'text' : 'visual' ); } @@ -387,12 +385,8 @@ const ComposedNativeProvider = compose( [ withDispatch( ( dispatch ) => { const { editPost, resetEditorBlocks, updateEditorSettings } = dispatch( editorStore ); - const { - updateSettings, - clearSelectedBlock, - insertBlock, - replaceBlock, - } = dispatch( blockEditorStore ); + const { updateSettings, insertBlock, replaceBlock } = + dispatch( blockEditorStore ); const { switchEditorMode } = dispatch( editPostStore ); const { addEntities, receiveEntityRecords } = dispatch( coreStore ); const { createSuccessNotice } = dispatch( noticesStore ); @@ -401,7 +395,6 @@ const ComposedNativeProvider = compose( [ updateBlockEditorSettings: updateSettings, updateEditorSettings, addEntities, - clearSelectedBlock, insertBlock, createSuccessNotice, editTitle( title ) { diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 8a71fb5deca65f..92cbe05cdacf4b 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -29,6 +29,7 @@ const BLOCK_EDITOR_SETTINGS = [ '__unstableGalleryWithImageBlocks', 'alignWide', 'allowedBlockTypes', + 'allowRightClickOverrides', 'blockInspectorTabs', 'allowedMimeTypes', 'bodyPlaceholder', @@ -47,6 +48,7 @@ const BLOCK_EDITOR_SETTINGS = [ 'enableCustomUnits', 'enableOpenverseMediaCategory', 'focusMode', + 'distractionFree', 'fontSizes', 'gradients', 'generateAnchors', diff --git a/packages/editor/src/components/unsaved-changes-warning/index.js b/packages/editor/src/components/unsaved-changes-warning/index.js index c419ed5e77c5ac..b5c78644082133 100644 --- a/packages/editor/src/components/unsaved-changes-warning/index.js +++ b/packages/editor/src/components/unsaved-changes-warning/index.js @@ -13,41 +13,36 @@ import { store as coreStore } from '@wordpress/core-data'; * @return {WPComponent} The component. */ export default function UnsavedChangesWarning() { - const isDirty = useSelect( ( select ) => { - return () => { - const { __experimentalGetDirtyEntityRecords } = select( coreStore ); + const { __experimentalGetDirtyEntityRecords } = useSelect( coreStore ); + + useEffect( () => { + /** + * Warns the user if there are unsaved changes before leaving the editor. + * + * @param {Event} event `beforeunload` event. + * + * @return {string | undefined} Warning prompt message, if unsaved changes exist. + */ + const warnIfUnsavedChanges = ( event ) => { + // We need to call the selector directly in the listener to avoid race + // conditions with `BrowserURL` where `componentDidUpdate` gets the + // new value of `isEditedPostDirty` before this component does, + // causing this component to incorrectly think a trashed post is still dirty. const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); - return dirtyEntityRecords.length > 0; + if ( dirtyEntityRecords.length > 0 ) { + event.returnValue = __( + 'You have unsaved changes. If you proceed, they will be lost.' + ); + return event.returnValue; + } }; - }, [] ); - /** - * Warns the user if there are unsaved changes before leaving the editor. - * - * @param {Event} event `beforeunload` event. - * - * @return {string | undefined} Warning prompt message, if unsaved changes exist. - */ - const warnIfUnsavedChanges = ( event ) => { - // We need to call the selector directly in the listener to avoid race - // conditions with `BrowserURL` where `componentDidUpdate` gets the - // new value of `isEditedPostDirty` before this component does, - // causing this component to incorrectly think a trashed post is still dirty. - if ( isDirty() ) { - event.returnValue = __( - 'You have unsaved changes. If you proceed, they will be lost.' - ); - return event.returnValue; - } - }; - - useEffect( () => { window.addEventListener( 'beforeunload', warnIfUnsavedChanges ); return () => { window.removeEventListener( 'beforeunload', warnIfUnsavedChanges ); }; - }, [] ); + }, [ __experimentalGetDirtyEntityRecords ] ); return null; } diff --git a/packages/editor/src/hooks/custom-sources-backwards-compatibility.js b/packages/editor/src/hooks/custom-sources-backwards-compatibility.js index cd72904bfa6fe5..c5347bc6c25213 100644 --- a/packages/editor/src/hooks/custom-sources-backwards-compatibility.js +++ b/packages/editor/src/hooks/custom-sources-backwards-compatibility.js @@ -1,8 +1,7 @@ /** * WordPress dependencies */ -import { store as blocksStore } from '@wordpress/blocks'; -import { select as globalSelect, useSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { useEntityProp } from '@wordpress/core-data'; import { useMemo } from '@wordpress/element'; import { createHigherOrderComponent } from '@wordpress/compose'; @@ -124,26 +123,3 @@ addFilter( 'core/editor/custom-sources-backwards-compatibility/shim-attribute-source', shimAttributeSource ); - -// The above filter will only capture blocks registered after the filter was -// added. There may already be blocks registered by this point, and those must -// be updated to apply the shim. -// -// The following implementation achieves this, albeit with a couple caveats: -// - Only blocks registered on the global store will be modified. -// - The block settings are directly mutated, since there is currently no -// mechanism to update an existing block registration. This is the reason for -// `getBlockType` separate from `getBlockTypes`, since the latter returns a -// _copy_ of the block registration (i.e. the mutation would not affect the -// actual registered block settings). -// -// `getBlockTypes` or `getBlockType` implementation could change in the future -// in regards to creating settings clones, but the corresponding end-to-end -// tests for meta blocks should cover against any potential regressions. -// -// In the future, we could support updating block settings, at which point this -// implementation could use that mechanism instead. -globalSelect( blocksStore ) - .getBlockTypes() - .map( ( { name } ) => globalSelect( blocksStore ).getBlockType( name ) ) - .forEach( shimAttributeSource ); diff --git a/packages/editor/src/lockUnlock.js b/packages/editor/src/lock-unlock.js similarity index 100% rename from packages/editor/src/lockUnlock.js rename to packages/editor/src/lock-unlock.js diff --git a/packages/editor/src/private-apis.js b/packages/editor/src/private-apis.js index edfc3045f1100d..b0c2ab86c8bf2e 100644 --- a/packages/editor/src/private-apis.js +++ b/packages/editor/src/private-apis.js @@ -2,9 +2,11 @@ * Internal dependencies */ import { ExperimentalEditorProvider } from './components/provider'; -import { lock } from './lockUnlock'; +import { lock } from './lock-unlock'; +import { EntitiesSavedStatesExtensible } from './components/entities-saved-states'; export const privateApis = {}; lock( privateApis, { ExperimentalEditorProvider, + EntitiesSavedStatesExtensible, } ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 390c19571542dc..1cca9ee05ee30e 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -11,6 +11,7 @@ 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 { store as preferencesStore } from '@wordpress/preferences'; /** @@ -177,15 +178,26 @@ export const savePost = edits, options ); - dispatch( { type: 'REQUEST_POST_UPDATE_FINISH', options } ); - const error = registry + let error = registry .select( coreStore ) .getLastEntitySaveError( 'postType', previousRecord.type, previousRecord.id ); + + if ( ! error ) { + await applyFilters( + 'editor.__unstableSavePost', + Promise.resolve(), + options + ).catch( ( err ) => { + error = err; + } ); + } + dispatch( { type: 'REQUEST_POST_UPDATE_FINISH', options } ); + if ( error ) { const args = getNotificationArgumentsForSaveFail( { post: previousRecord, @@ -289,6 +301,26 @@ export const autosave = } }; +export const __unstableSaveForPreview = + ( { forceIsAutosaveable } = {} ) => + async ( { select, dispatch } ) => { + if ( + ( forceIsAutosaveable || select.isEditedPostAutosaveable() ) && + ! select.isPostLocked() + ) { + const isDraft = [ 'draft', 'auto-draft' ].includes( + select.getEditedPostAttribute( 'status' ) + ); + if ( isDraft ) { + await dispatch.savePost( { isPreview: true } ); + } else { + await dispatch.autosave( { isPreview: true } ); + } + } + + return select.getEditedPostPreviewLink(); + }; + /** * Action that restores last popped state in undo history. */ diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 51b307dce9e7c4..ae62492e79042d 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -10,6 +10,7 @@ import { getFreeformContentHandlerName, getDefaultBlockName, __unstableSerializeAndClean, + parse, } from '@wordpress/blocks'; import { isInTheFuture, getDate } from '@wordpress/date'; import { addQueryArgs, cleanForSlug } from '@wordpress/url'; @@ -42,15 +43,6 @@ import { getTemplatePartIcon } from '../utils/get-template-part-icon'; */ const EMPTY_OBJECT = {}; -/** - * Shared reference to an empty array for cases where it is important to avoid - * returning a new array reference on every invocation, as in a connected or - * other pure component which performs `shouldComponentUpdate` check on props. - * This should be used as a last resort, since the normalized data should be - * maintained by the reducer result in state. - */ -const EMPTY_ARRAY = []; - /** * Returns true if any past editor history snapshots exist, or false otherwise. * @@ -507,16 +499,31 @@ export function isEditedPostSaveable( state ) { * * @return {boolean} Whether post has content. */ -export function isEditedPostEmpty( state ) { - // While the condition of truthy content string is sufficient to determine - // emptiness, testing saveable blocks length is a trivial operation. Since - // this function can be called frequently, optimize for the fast case as a - // condition of the mere existence of blocks. Note that the value of edited - // content takes precedent over block content, and must fall through to the - // default logic. - const blocks = getEditorBlocks( state ); +export const isEditedPostEmpty = createRegistrySelector( + ( select ) => ( state ) => { + // While the condition of truthy content string is sufficient to determine + // emptiness, testing saveable blocks length is a trivial operation. Since + // this function can be called frequently, optimize for the fast case as a + // condition of the mere existence of blocks. Note that the value of edited + // content takes precedent over block content, and must fall through to the + // default logic. + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + const record = select( coreStore ).getEditedEntityRecord( + 'postType', + postType, + postId + ); + if ( typeof record.content !== 'function' ) { + return ! record.content; + } + + const blocks = getEditedPostAttribute( state, 'blocks' ); + + if ( blocks.length === 0 ) { + return true; + } - if ( blocks.length ) { // Pierce the abstraction of the serializer in knowing that blocks are // joined with newlines such that even if every individual block // produces an empty save result, the serialized content is non-empty. @@ -542,10 +549,10 @@ export function isEditedPostEmpty( state ) { ) { return false; } - } - return ! getEditedPostContent( state ); -} + return ! getEditedPostContent( state ); + } +); /** * Returns true if the post can be autosaved, or false otherwise. @@ -604,8 +611,8 @@ export const isEditedPostAutosaveable = createRegistrySelector( return true; } - // If the title or excerpt has changed, the post is autosaveable. - return [ 'title', 'excerpt' ].some( + // If title, excerpt, or meta have changed, the post is autosaveable. + return [ 'title', 'excerpt', 'meta' ].some( ( field ) => getPostRawValue( autosave[ field ] ) !== getEditedPostAttribute( state, field ) @@ -681,15 +688,9 @@ export function isDeletingPost( state ) { * * @return {boolean} Whether post is being saved. */ -export const isSavingPost = createRegistrySelector( ( select ) => ( state ) => { - const postType = getCurrentPostType( state ); - const postId = getCurrentPostId( state ); - return select( coreStore ).isSavingEntityRecord( - 'postType', - postType, - postId - ); -} ); +export function isSavingPost( state ) { + return !! state.saving.pending; +} /** * Returns true if non-post entities are currently being saved, or false otherwise. @@ -760,10 +761,7 @@ export const didPostSaveRequestFail = createRegistrySelector( * @return {boolean} Whether the post is autosaving. */ export function isAutosavingPost( state ) { - if ( ! isSavingPost( state ) ) { - return false; - } - return Boolean( state.saving.options?.isAutosave ); + return isSavingPost( state ) && Boolean( state.saving.options?.isAutosave ); } /** @@ -774,10 +772,7 @@ export function isAutosavingPost( state ) { * @return {boolean} Whether the post is being previewed. */ export function isPreviewingPost( state ) { - if ( ! isSavingPost( state ) ) { - return false; - } - return Boolean( state.saving.options?.isPreview ); + return isSavingPost( state ) && Boolean( state.saving.options?.isPreview ); } /** @@ -1100,9 +1095,18 @@ export const isPublishSidebarEnabled = createRegistrySelector( * @param {Object} state * @return {Array} Block list. */ -export function getEditorBlocks( state ) { - return getEditedPostAttribute( state, 'blocks' ) || EMPTY_ARRAY; -} +export const getEditorBlocks = createSelector( + ( state ) => { + return ( + getEditedPostAttribute( state, 'blocks' ) || + parse( getEditedPostContent( state ) ) + ); + }, + ( state ) => [ + getEditedPostAttribute( state, 'blocks' ), + getEditedPostContent( state ), + ] +); /** * A block selection object. diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 4b29e251f80c02..ea8b3fd97b87d1 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -75,10 +75,6 @@ selectorNames.forEach( ( name ) => { }; }, - isSavingEntityRecord() { - return state.saving && state.saving.requesting; - }, - getLastEntitySaveError() { const saving = state.saving; const successful = saving && saving.successful; @@ -264,7 +260,7 @@ describe( 'selectors', () => { parent: [ 'core/test-block-b' ], } ); - registerBlockType( 'core/test-freeform', { + registerBlockType( 'core/freeform', { save: ( props ) => <RawHTML>{ props.attributes.content }</RawHTML>, category: 'text', title: 'Test Freeform Content Handler', @@ -291,7 +287,7 @@ describe( 'selectors', () => { save: () => null, } ); - setFreeformContentHandlerName( 'core/test-freeform' ); + setFreeformContentHandlerName( 'core/freeform' ); setDefaultBlockName( 'core/test-default' ); cachedSelectors.forEach( ( { clear } ) => clear() ); @@ -302,7 +298,7 @@ describe( 'selectors', () => { unregisterBlockType( 'core/test-block-a' ); unregisterBlockType( 'core/test-block-b' ); unregisterBlockType( 'core/test-block-c' ); - unregisterBlockType( 'core/test-freeform' ); + unregisterBlockType( 'core/freeform' ); unregisterBlockType( 'core/test-default' ); setFreeformContentHandlerName( undefined ); @@ -1254,7 +1250,7 @@ describe( 'selectors', () => { title: 'sassel', }, saving: { - requesting: true, + pending: true, }, }; @@ -1320,7 +1316,9 @@ describe( 'selectors', () => { }, ], }, - edits: {}, + edits: { + content: () => {}, + }, }, }, initialEdits: {}, @@ -1339,7 +1337,7 @@ describe( 'selectors', () => { value: [ { clientId: 123, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: '', @@ -1366,7 +1364,7 @@ describe( 'selectors', () => { value: [ { clientId: 123, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: '', @@ -1403,9 +1401,8 @@ describe( 'selectors', () => { currentPost: { title: 'sassel', }, - saving: { - requesting: true, - }, + postAutosavingLock: {}, + saving: {}, getCurrentUser() {}, hasFetchedAutosaves() { return false; @@ -1434,9 +1431,8 @@ describe( 'selectors', () => { currentPost: { title: 'sassel', }, - saving: { - requesting: true, - }, + postAutosavingLock: {}, + saving: {}, getCurrentUser() {}, hasFetchedAutosaves() { return true; @@ -1597,14 +1593,13 @@ describe( 'selectors', () => { const state = { editor: { present: { - blocks: { - value: [], - }, edits: {}, }, }, initialEdits: {}, - currentPost: {}, + currentPost: { + content: '', + }, }; expect( isEditedPostEmpty( state ) ).toBe( true ); @@ -1626,7 +1621,9 @@ describe( 'selectors', () => { }, ], }, - edits: {}, + edits: { + content: () => {}, + }, }, }, initialEdits: {}, @@ -1656,7 +1653,9 @@ describe( 'selectors', () => { }, ], }, - edits: {}, + edits: { + content: () => {}, + }, }, }, initialEdits: {}, @@ -1666,7 +1665,7 @@ describe( 'selectors', () => { expect( isEditedPostEmpty( state ) ).toBe( true ); } ); - it( 'should return false if blocks, but empty content edit', () => { + it( 'should return true if blocks, but empty content edit', () => { const state = { editor: { present: { @@ -1693,7 +1692,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostEmpty( state ) ).toBe( false ); + expect( isEditedPostEmpty( state ) ).toBe( true ); } ); it( 'should return true if the post has an empty content property', () => { @@ -1715,7 +1714,7 @@ describe( 'selectors', () => { expect( isEditedPostEmpty( state ) ).toBe( true ); } ); - it( 'should return true if edits include a non-empty content property, but blocks are empty', () => { + it( 'should return false if edits include a non-empty content property', () => { const state = { editor: { present: { @@ -1731,7 +1730,7 @@ describe( 'selectors', () => { currentPost: {}, }; - expect( isEditedPostEmpty( state ) ).toBe( true ); + expect( isEditedPostEmpty( state ) ).toBe( false ); } ); it( 'should return true if empty classic block', () => { @@ -1742,7 +1741,7 @@ describe( 'selectors', () => { value: [ { clientId: 123, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: '', @@ -1750,7 +1749,9 @@ describe( 'selectors', () => { }, ], }, - edits: {}, + edits: { + content: () => {}, + }, }, }, initialEdits: {}, @@ -1768,7 +1769,7 @@ describe( 'selectors', () => { value: [ { clientId: 123, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: '', @@ -1796,7 +1797,7 @@ describe( 'selectors', () => { value: [ { clientId: 123, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: 'Test Data', @@ -1824,7 +1825,7 @@ describe( 'selectors', () => { value: [ { clientId: 123, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: '', @@ -1832,7 +1833,7 @@ describe( 'selectors', () => { }, { clientId: 456, - name: 'core/test-freeform', + name: 'core/freeform', isValid: true, attributes: { content: '', @@ -2017,7 +2018,7 @@ describe( 'selectors', () => { it( 'should return true if the post is currently being saved', () => { const state = { saving: { - requesting: true, + pending: true, }, }; @@ -2027,7 +2028,7 @@ describe( 'selectors', () => { it( 'should return false if the post is not currently being saved', () => { const state = { saving: { - requesting: false, + pending: false, }, }; @@ -2156,6 +2157,7 @@ describe( 'selectors', () => { attributes: { providerNameSlug: 'instagram', }, + innerBlocks: [], }, ], }, @@ -2178,6 +2180,7 @@ describe( 'selectors', () => { clientId: 567, name: 'core/embed', attributes: {}, + innerBlocks: [], }, ], }, @@ -2200,11 +2203,13 @@ describe( 'selectors', () => { clientId: 123, name: 'core/image', attributes: {}, + innerBlocks: [], }, { clientId: 456, name: 'core/quote', attributes: {}, + innerBlocks: [], }, ], }, @@ -2228,6 +2233,7 @@ describe( 'selectors', () => { clientId: 123, name: 'core/image', attributes: {}, + innerBlocks: [], }, ], }, @@ -2251,6 +2257,7 @@ describe( 'selectors', () => { clientId: 456, name: 'core/quote', attributes: {}, + innerBlocks: [], }, ], }, @@ -2276,6 +2283,7 @@ describe( 'selectors', () => { attributes: { providerNameSlug: 'youtube', }, + innerBlocks: [], }, ], }, @@ -2301,6 +2309,7 @@ describe( 'selectors', () => { attributes: { providerNameSlug: 'soundcloud', }, + innerBlocks: [], }, ], }, @@ -2324,11 +2333,13 @@ describe( 'selectors', () => { clientId: 456, name: 'core/quote', attributes: {}, + innerBlocks: [], }, { clientId: 789, name: 'core/paragraph', attributes: {}, + innerBlocks: [], }, ], }, @@ -2415,7 +2426,7 @@ describe( 'selectors', () => { } ); it( "returns removep'd serialization of blocks for single unknown", () => { - const unknownBlock = createBlock( 'core/test-freeform', { + const unknownBlock = createBlock( 'core/freeform', { content: '<p>foo</p>', } ); const state = { @@ -2437,10 +2448,10 @@ describe( 'selectors', () => { } ); it( "returns non-removep'd serialization of blocks for multiple unknown", () => { - const firstUnknown = createBlock( 'core/test-freeform', { + const firstUnknown = createBlock( 'core/freeform', { content: '<p>foo</p>', } ); - const secondUnknown = createBlock( 'core/test-freeform', { + const secondUnknown = createBlock( 'core/freeform', { content: '<p>bar</p>', } ); const state = { diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 9d035ec4d654aa..dbffbbef4d5212 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -11,6 +11,7 @@ @import "./components/post-publish-button/style.scss"; @import "./components/post-publish-panel/style.scss"; @import "./components/post-saved-state/style.scss"; +@import "./components/post-sync-status/style.scss"; @import "./components/post-taxonomies/style.scss"; @import "./components/post-text-editor/style.scss"; @import "./components/post-url/style.scss"; diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md index 055691b4afea78..6e1c9539740b5b 100644 --- a/packages/element/CHANGELOG.md +++ b/packages/element/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 5.17.0 (2023-08-16) + +## 5.16.0 (2023-08-10) + +## 5.15.0 (2023-07-20) + +## 5.14.0 (2023-07-05) + +## 5.13.0 (2023-06-23) + +## 5.12.0 (2023-06-07) + ## 5.11.0 (2023-05-24) ## 5.10.0 (2023-05-10) diff --git a/packages/element/package.json b/packages/element/package.json index 97a145c380a2ad..102874371d4cfe 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/element", - "version": "5.11.0", + "version": "5.17.0", "description": "Element React module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 54bbd44d2b4485..c909b2654acaa0 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,32 @@ ## Unreleased +## 8.6.0 (2023-08-16) + +## 8.5.0 (2023-08-10) + +## 8.4.0 (2023-07-20) + +## 8.3.0 (2023-07-05) + +## 8.2.0 (2023-06-23) + +## 8.1.1 (2023-06-17) + +### Bug fix + +- Address issue where a missing file in the underlying Docker image caused `wp-env` to crash. [#51513](https://github.com/WordPress/gutenberg/pull/51513) + +## 8.1.0 (2023-06-07) + +### New feature + +- Execute the local package's `wp-env` instead of the globally installed version if one is available. + +### Bug fix + +- Run `useradd` with `-l` option to prevent excessive Docker image sizes. + ## 8.0.0 (2023-05-24) ### Breaking Change diff --git a/packages/env/README.md b/packages/env/README.md index 0bc36092830330..fb9e9751d9c666 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -44,9 +44,9 @@ If your project already has a package.json, it's also possible to use `wp-env` a $ npm i @wordpress/env --save-dev ``` -At this point, you can use the local, project-level version of wp-env via [`npx`](https://www.npmjs.com/package/npx), a utility automatically installed with `npm`.`npx` finds binaries like wp-env installed through node modules. As an example: `npx wp-env start --update`. +If you have also installed `wp-env` globally, running it will automatically execute the local, project-level package. Alternatively, you can execute `wp-env` via [`npx`](https://www.npmjs.com/package/npx), a utility automatically installed with `npm`.`npx` finds binaries like `wp-env` installed through node modules. As an example: `npx wp-env start --update`. -If you don't wish to use `npx`, modify your package.json and add an extra command to npm `scripts` (https://docs.npmjs.com/misc/scripts): +If you don't wish to use the global installation or `npx`, modify your `package.json` and add an extra command to npm `scripts` (https://docs.npmjs.com/misc/scripts): ```json "scripts": { diff --git a/packages/env/bin/wp-env b/packages/env/bin/wp-env index 7ce3e39103bcd1..a6c4784a3e7fb5 100755 --- a/packages/env/bin/wp-env +++ b/packages/env/bin/wp-env @@ -1,4 +1,20 @@ #!/usr/bin/env node 'use strict'; -const command = process.argv.slice( 2 ); -require( '../lib/cli' )().parse( command.length ? command : [ '--help' ] ); + +// Remove 'node' and the name of the script from the arguments. +let command = process.argv.slice( 2 ); +// Default to help text when they aren't running any commands. +if ( ! command.length ) { + command = [ '--help' ]; +} + +// Rather than just executing the current CLI we will attempt to find a local version +// and execute that one instead. This prevents users from accidentally using the +// global CLI when a potentially different local version is expected. +const localPath = require.resolve( '@wordpress/env/lib/cli.js', { + paths: [ process.cwd(), __dirname ], +} ); +const cli = require( localPath )(); + +// Now we can execute the CLI with the given command. +cli.parse( command ); diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index 72a5eec911e087..1788315b60b9db 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -11,6 +11,7 @@ const { execSync } = require( 'child_process' ); /** * Internal dependencies */ +const pkg = require( '../package.json' ); const env = require( './env' ); const parseXdebugMode = require( './parse-xdebug-mode' ); const { @@ -110,6 +111,10 @@ module.exports = function cli() { 'populate--': true, } ); + // Since we might be running a different CLI version than the one that was called + // we need to set the version manually from the correct package.json. + yargs.version( pkg.version ); + yargs.command( 'start', wpGreen( diff --git a/packages/env/lib/init-config.js b/packages/env/lib/init-config.js index ee26ade75c665e..efde002dc58389 100644 --- a/packages/env/lib/init-config.js +++ b/packages/env/lib/init-config.js @@ -124,6 +124,7 @@ function wordpressDockerFileContents( env, config ) { # Update apt sources for archived versions of Debian. # stretch (https://lists.debian.org/debian-devel-announce/2023/03/msg00006.html) +RUN touch /etc/apt/sources.list RUN sed -i 's|deb.debian.org/debian stretch|archive.debian.org/debian stretch|g' /etc/apt/sources.list RUN sed -i 's|security.debian.org/debian-security stretch|archive.debian.org/debian-security stretch|g' /etc/apt/sources.list RUN sed -i '/stretch-updates/d' /etc/apt/sources.list @@ -133,8 +134,8 @@ ARG HOST_USERNAME ARG HOST_UID ARG HOST_GID # When the IDs are already in use we can still safely move on. -RUN groupadd -g $HOST_GID $HOST_USERNAME || true -RUN useradd -m -u $HOST_UID -g $HOST_GID $HOST_USERNAME || true +RUN groupadd -o -g $HOST_GID $HOST_USERNAME || true +RUN useradd -mlo -u $HOST_UID -g $HOST_GID $HOST_USERNAME || true # Install any dependencies we need in the container. ${ installDependencies( 'wordpress', env, config ) }`; @@ -167,7 +168,7 @@ RUN adduser -h /home/$HOST_USERNAME -G $( getent group $HOST_GID | cut -d: -f1 ) # Install any dependencies we need in the container. ${ installDependencies( 'cli', env, config ) } - + # Switch back to the original user now that we're done. USER www-data @@ -200,6 +201,9 @@ RUN apt-get -qy update # Install some basic PHP dependencies. RUN apt-get -qy install $PHPIZE_DEPS && touch /usr/local/etc/php/php.ini +# Install git +RUN apt-get -qy install git + # Set up sudo so they can have root access. RUN apt-get -qy install sudo RUN echo "#$HOST_UID ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers`; diff --git a/packages/env/package.json b/packages/env/package.json index 76fca2a0bdb9dd..ba83d8c5b6ef11 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/env", - "version": "8.0.0", + "version": "8.6.0", "description": "A zero-config, self contained local WordPress environment for development and testing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/escape-html/CHANGELOG.md b/packages/escape-html/CHANGELOG.md index 6850ee26d3d02e..86320c1dd505de 100644 --- a/packages/escape-html/CHANGELOG.md +++ b/packages/escape-html/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.40.0 (2023-08-16) + +## 2.39.0 (2023-08-10) + +## 2.38.0 (2023-07-20) + +## 2.37.0 (2023-07-05) + +## 2.36.0 (2023-06-23) + +## 2.35.0 (2023-06-07) + ## 2.34.0 (2023-05-24) ## 2.33.0 (2023-05-10) diff --git a/packages/escape-html/package.json b/packages/escape-html/package.json index 464b458bfc54c9..c20e43f8fb32e0 100644 --- a/packages/escape-html/package.json +++ b/packages/escape-html/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/escape-html", - "version": "2.34.0", + "version": "2.40.0", "description": "Escape HTML utils.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index c7319a98e42541..dc1ca8f9b3da2e 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +## 15.0.0 (2023-08-16) + +### Breaking Changes + +- The bundled `eslint-plugin-jsdoc` dependency has been updated from requiring ^39.6.9 to requiring ^46.4.6 ([#53629](https://github.com/WordPress/gutenberg/pull/53629)): + - Removes `jsdoc/newline-after-description` rule in favor of `jsdoc/tag-lines` with option `startLines: 0` for "never" and `startLines: 1` for "always". Defaults now to `startLines: null`. + - Removes `dropEndLines: true` from `jsdoc/tag-lines` in favor of option `endLines: 0`. + - Drops `jsdoc/tag-lines` rule's `noEndLines: true` in favor of `applyToEndTag: false`. + - Disables the newly introduced `jsdoc/no-defaults` rule. + +### Enhancement + +- Support Typescript 5 and 5.1 by updating both `@typescript-eslint/parser` and `@typescript-eslint/eslint-plugin` to version `^5.62.0`. ([#52621](https://github.com/WordPress/gutenberg/pull/52621)). + +## 14.12.0 (2023-08-10) + +## 14.11.0 (2023-07-20) + +## 14.10.0 (2023-07-05) + +## 14.9.0 (2023-06-23) + +## 14.8.0 (2023-06-07) + ## 14.7.0 (2023-05-24) ## 14.6.0 (2023-05-10) diff --git a/packages/eslint-plugin/configs/jsdoc.js b/packages/eslint-plugin/configs/jsdoc.js index 246cb561dac0af..4aaed834363996 100644 --- a/packages/eslint-plugin/configs/jsdoc.js +++ b/packages/eslint-plugin/configs/jsdoc.js @@ -83,6 +83,7 @@ module.exports = { }, }, rules: { + 'jsdoc/no-defaults': 'off', 'jsdoc/no-undefined-types': [ 'error', { @@ -105,7 +106,15 @@ module.exports = { 'jsdoc/require-param-description': 'off', 'jsdoc/require-returns': 'off', 'jsdoc/require-yields': 'off', - 'jsdoc/tag-lines': 'off', + 'jsdoc/tag-lines': [ + 1, + 'any', + { + startLines: null, + endLines: 0, + applyToEndTag: false, + }, + ], 'jsdoc/no-multi-asterisks': [ 'error', { preventAtMiddleLines: false }, @@ -127,7 +136,6 @@ module.exports = { 'jsdoc/check-values': 'off', 'jsdoc/empty-tags': 'error', 'jsdoc/implements-on-classes': 'error', - 'jsdoc/newline-after-description': 'error', 'jsdoc/require-param': 'error', 'jsdoc/require-param-name': 'error', 'jsdoc/require-param-type': 'error', diff --git a/packages/eslint-plugin/configs/recommended-with-formatting.js b/packages/eslint-plugin/configs/recommended-with-formatting.js index 3c7ce025ce8fe9..2499d8656d0c04 100644 --- a/packages/eslint-plugin/configs/recommended-with-formatting.js +++ b/packages/eslint-plugin/configs/recommended-with-formatting.js @@ -16,6 +16,7 @@ const config = { globals: { window: true, document: true, + SCRIPT_DEBUG: 'readonly', wp: 'readonly', }, settings: { diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 7dcd3a37a76392..6abf721e120105 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/eslint-plugin", - "version": "14.7.0", + "version": "15.0.0", "description": "ESLint plugin for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -32,15 +32,15 @@ "main": "index.js", "dependencies": { "@babel/eslint-parser": "^7.16.0", - "@typescript-eslint/eslint-plugin": "^5.3.0", - "@typescript-eslint/parser": "^5.3.0", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", "@wordpress/babel-preset-default": "file:../babel-preset-default", "@wordpress/prettier-config": "file:../prettier-config", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.25.2", "eslint-plugin-jest": "^27.2.1", - "eslint-plugin-jsdoc": "^39.6.9", + "eslint-plugin-jsdoc": "^46.4.6", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^3.3.0", "eslint-plugin-react": "^7.27.0", diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 630031a3e0e859..d2aa5c66405ff7 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.17.0 (2023-08-16) + +## 4.16.0 (2023-08-10) + +## 4.15.0 (2023-07-20) + +## 4.14.0 (2023-07-05) + +## 4.13.0 (2023-06-23) + +## 4.12.0 (2023-06-07) + ## 4.11.0 (2023-05-24) ## 4.10.0 (2023-05-10) diff --git a/packages/format-library/package.json b/packages/format-library/package.json index 3ec4eb055d4d40..793ca7cbd9599f 100644 --- a/packages/format-library/package.json +++ b/packages/format-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/format-library", - "version": "4.11.0", + "version": "4.17.0", "description": "Format library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/format-library/src/language/index.js b/packages/format-library/src/language/index.js index 664f28126c1f2c..d37d8d6dbd0cd8 100644 --- a/packages/format-library/src/language/index.js +++ b/packages/format-library/src/language/index.js @@ -29,21 +29,10 @@ export const language = { title, }; -function Edit( props ) { - const { contentRef, isActive, onChange, value } = props; - const popoverAnchor = useAnchor( { - editableContentElement: contentRef.current, - settings: language, - } ); - - const [ lang, setLang ] = useState( '' ); - const [ dir, setDir ] = useState( 'ltr' ); - +function Edit( { isActive, value, onChange, contentRef } ) { const [ isPopoverVisible, setIsPopoverVisible ] = useState( false ); const togglePopover = () => { setIsPopoverVisible( ( state ) => ! state ); - setLang( '' ); - setDir( 'ltr' ); }; return ( @@ -62,63 +51,80 @@ function Edit( props ) { isActive={ isActive } role="menuitemcheckbox" /> - { isPopoverVisible && ( - <Popover - className="block-editor-format-toolbar__language-popover" - anchor={ popoverAnchor } - placement="bottom" + <InlineLanguageUI + value={ value } + onChange={ onChange } onClose={ togglePopover } - > - <form - className="block-editor-format-toolbar__language-container-content" - onSubmit={ ( event ) => { - onChange( - applyFormat( value, { - type: name, - attributes: { - lang, - dir, - }, - } ) - ); - togglePopover(); - event.preventDefault(); - } } - > - <TextControl - label={ title } - value={ lang } - onChange={ ( val ) => setLang( val ) } - help={ __( - 'A valid language attribute, like "en" or "fr".' - ) } - /> - <SelectControl - label={ __( 'Text direction' ) } - value={ dir } - options={ [ - { - label: __( 'Left to right' ), - value: 'ltr', - }, - { - label: __( 'Right to left' ), - value: 'rtl', - }, - ] } - onChange={ ( val ) => setDir( val ) } - /> - <HStack alignment="right"> - <Button - variant="primary" - type="submit" - text={ __( 'Apply' ) } - /> - </HStack> - </form> - </Popover> + contentRef={ contentRef } + /> ) } </> ); } + +function InlineLanguageUI( { value, contentRef, onChange, onClose } ) { + const popoverAnchor = useAnchor( { + editableContentElement: contentRef.current, + settings: language, + } ); + + const [ lang, setLang ] = useState( '' ); + const [ dir, setDir ] = useState( 'ltr' ); + + return ( + <Popover + className="block-editor-format-toolbar__language-popover" + anchor={ popoverAnchor } + onClose={ onClose } + > + <form + className="block-editor-format-toolbar__language-container-content" + onSubmit={ ( event ) => { + event.preventDefault(); + onChange( + applyFormat( value, { + type: name, + attributes: { + lang, + dir, + }, + } ) + ); + onClose(); + } } + > + <TextControl + label={ title } + value={ lang } + onChange={ ( val ) => setLang( val ) } + help={ __( + 'A valid language attribute, like "en" or "fr".' + ) } + /> + <SelectControl + label={ __( 'Text direction' ) } + value={ dir } + options={ [ + { + label: __( 'Left to right' ), + value: 'ltr', + }, + { + label: __( 'Right to left' ), + value: 'rtl', + }, + ] } + onChange={ ( val ) => setDir( val ) } + /> + <HStack alignment="right"> + <Button + variant="primary" + type="submit" + text={ __( 'Apply' ) } + /> + </HStack> + </form> + </Popover> + ); +} diff --git a/packages/format-library/src/link/index.js b/packages/format-library/src/link/index.js index 40f1322fcf1c4e..03e25a37b9b8e9 100644 --- a/packages/format-library/src/link/index.js +++ b/packages/format-library/src/link/index.js @@ -60,9 +60,11 @@ function Edit( { } } - function stopAddingLink() { + function stopAddingLink( returnFocus = true ) { setAddingLink( false ); - onFocus(); + if ( returnFocus ) { + onFocus(); + } } function onRemoveFormat() { @@ -87,6 +89,8 @@ function Edit( { isActive={ isActive } shortcutType="primaryShift" shortcutCharacter="k" + aria-haspopup="true" + aria-expanded={ addingLink || isActive } /> ) } { ! isActive && ( @@ -98,6 +102,8 @@ function Edit( { isActive={ isActive } shortcutType="primary" shortcutCharacter="k" + aria-haspopup="true" + aria-expanded={ addingLink || isActive } /> ) } { ( addingLink || isActive ) && ( diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index 67790c36937f21..29636a8d8b94be 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -1,9 +1,10 @@ /** * WordPress dependencies */ -import { useState, useRef, createInterpolateElement } from '@wordpress/element'; +import { useRef, createInterpolateElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { withSpokenMessages, Popover } from '@wordpress/components'; +import { speak } from '@wordpress/a11y'; +import { Popover } from '@wordpress/components'; import { prependHTTP } from '@wordpress/url'; import { create, @@ -36,7 +37,6 @@ function InlineLinkUI( { addingLink, value, onChange, - speak, stopAddingLink, contentRef, } ) { @@ -45,16 +45,6 @@ function InlineLinkUI( { // Get the text content minus any HTML tags. const richTextText = richLinkTextValue.text; - /** - * Pending settings to be applied to the next link. When inserting a new - * link, toggle values cannot be applied immediately, because there is not - * yet a link for them to apply to. Thus, they are maintained in a state - * value until the time that the link can be inserted or edited. - * - * @type {[Object|undefined,Function]} - */ - const [ nextLinkValue, setNextLinkValue ] = useState(); - const { createPageEntity, userCanCreatePages } = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); const _settings = getSettings(); @@ -71,7 +61,6 @@ function InlineLinkUI( { id: activeAttributes.id, opensInNewTab: activeAttributes.target === '_blank', title: richTextText, - ...nextLinkValue, }; function removeLink() { @@ -82,32 +71,18 @@ function InlineLinkUI( { } function onChangeLink( nextValue ) { - // Merge with values from state, both for the purpose of assigning the - // next state value, and for use in constructing the new link format if - // the link is ready to be applied. - nextValue = { - ...nextLinkValue, - ...nextValue, - }; - // LinkControl calls `onChange` immediately upon the toggling a setting. + // Before merging the next value with the current link value, check if + // the setting was toggled. const didToggleSetting = linkValue.opensInNewTab !== nextValue.opensInNewTab && - linkValue.url === nextValue.url; - - // If change handler was called as a result of a settings change during - // link insertion, it must be held in state until the link is ready to - // be applied. - const didToggleSettingForNewLink = - didToggleSetting && nextValue.url === undefined; + nextValue.url === undefined; - // If link will be assigned, the state value can be considered flushed. - // Otherwise, persist the pending changes. - setNextLinkValue( didToggleSettingForNewLink ? nextValue : undefined ); - - if ( didToggleSettingForNewLink ) { - return; - } + // Merge the next value with the current link value. + nextValue = { + ...linkValue, + ...nextValue, + }; const newUrl = prependHTTP( nextValue.url ); const linkFormat = createLinkFormat( { @@ -185,6 +160,8 @@ function InlineLinkUI( { } newValue.start = newValue.end; + + // Hides the Link UI. newValue.activeFormats = []; onChange( newValue ); } @@ -243,7 +220,7 @@ function InlineLinkUI( { return createInterpolateElement( sprintf( /* translators: %s: search term. */ - __( 'Create Page: <mark>%s</mark>' ), + __( 'Create page: <mark>%s</mark>' ), searchTerm ), { mark: <mark /> } @@ -255,6 +232,7 @@ function InlineLinkUI( { anchor={ popoverAnchor } focusOnMount={ focusOnMount.current } onClose={ stopAddingLink } + onFocusOutside={ () => stopAddingLink( false ) } placement="bottom" shift > @@ -298,4 +276,4 @@ function getRichTextValueFromSelection( value, isActive ) { return slice( value, textStart, textEnd ); } -export default withSpokenMessages( InlineLinkUI ); +export default InlineLinkUI; diff --git a/packages/format-library/src/link/test/__snapshots__/modal.native.js.snap b/packages/format-library/src/link/test/__snapshots__/modal.native.js.snap new file mode 100644 index 00000000000000..12d7ac6d30136f --- /dev/null +++ b/packages/format-library/src/link/test/__snapshots__/modal.native.js.snap @@ -0,0 +1,553 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LinksUI LinksUI renders 1`] = ` +<Modal + animationInTiming={400} + animationOutTiming={300} + backdropOpacity={0.2} + backdropTransitionInTiming={50} + backdropTransitionOutTiming={50} + isVisible={true} + onAccessibilityEscape={[Function]} + onBackButtonPress={[Function]} + onBackdropPress={[Function]} + onModalHide={[Function]} + onMoveShouldSetResponder={[Function]} + onMoveShouldSetResponderCapture={[Function]} + onSwipeComplete={[Function]} + preferredColorScheme="light" + swipeDirection="down" + testID="link-settings-modal" +> + <View + behavior={false} + style={ + { + "borderColor": "rgba(0, 0, 0, 0.1)", + "flex": undefined, + "marginTop": 0, + "maxWidth": 512, + } + } + > + <View + onLayout={[Function]} + testID="link-settings-modal-header" + > + <View /> + </View> + <View + style={ + { + "maxHeight": 787.06, + } + } + > + <View + animatedStyle={ + { + "value": { + "height": 1, + }, + } + } + collapsable={false} + style={ + { + "height": 1, + } + } + > + <RNGestureHandlerRootView + style={ + { + "flex": 1, + } + } + > + <View + style={ + [ + { + "backgroundColor": undefined, + "flex": 1, + }, + undefined, + ] + } + > + <View + onLayout={[Function]} + style={ + { + "flex": 1, + } + } + > + <View + collapsable={false} + forwardedRef={[Function]} + pointerEvents="box-none" + style={ + { + "bottom": 0, + "display": "flex", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + > + <View + collapsable={false} + style={ + { + "opacity": 1, + } + } + /> + <View + accessibilityElementsHidden={false} + closing={false} + gestureVelocityImpact={0.3} + importantForAccessibility="auto" + onClose={[Function]} + onGestureBegin={[Function]} + onGestureCanceled={[Function]} + onGestureEnd={[Function]} + onOpen={[Function]} + onTransition={[Function]} + pointerEvents="box-none" + style={ + [ + { + "display": "flex", + "overflow": undefined, + }, + { + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + }, + ] + } + transitionSpec={ + { + "close": { + "animation": "timing", + "config": { + "duration": 200, + "easing": [Function], + }, + }, + "open": { + "animation": "timing", + "config": { + "duration": 200, + "easing": [Function], + }, + }, + } + } + > + <View + pointerEvents="box-none" + style={ + { + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + <View + collapsable={false} + pointerEvents="box-none" + style={ + { + "flex": 1, + } + } + > + <View + collapsable={false} + forwardedRef={[Function]} + handlerTag={1} + handlerType="PanGestureHandler" + needsOffscreenAlphaCompositing={true} + onGestureHandlerEvent={[Function]} + onGestureHandlerStateChange={[Function]} + style={ + { + "flex": 1, + "opacity": 1, + } + } + > + <View + pointerEvents="box-none" + style={ + [ + { + "flex": 1, + "overflow": "hidden", + }, + [ + { + "backgroundColor": undefined, + }, + undefined, + ], + ] + } + > + <View + style={ + { + "flex": 1, + "flexDirection": "column-reverse", + } + } + > + <View + style={ + { + "flex": 1, + } + } + > + <RCTScrollView + automaticallyAdjustContentInsets={false} + bounces={false} + contentContainerStyle={ + [ + { + "paddingLeft": 8, + "paddingRight": 8, + }, + undefined, + {}, + false, + ] + } + disableScrollViewPanResponder={true} + onScroll={[Function]} + safeAreaBottomInset={0} + scrollEnabled={true} + scrollEventThrottle={16} + style={ + { + "maxHeight": 787.06, + } + } + > + <View> + <View + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={false} + focusable={false} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + > + <View + onLayout={[Function]} + testID="navigation-screen-linkSettings" + > + <View + accessibilityLabel="Link to, Search or type URL" + accessibilityRole="button" + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": false, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "opacity": 1, + } + } + > + <View + pointerEvents="auto" + style={ + [ + undefined, + {}, + ] + } + > + <View + style={ + [ + undefined, + {}, + ] + } + > + <View> + <View + style={ + [ + undefined, + undefined, + ] + } + > + <Svg + height={24} + lock={true} + style={{}} + viewBox="0 0 24 24" + width={24} + xmlns="http://www.w3.org/2000/svg" + > + Path + </Svg> + <View /> + </View> + <Text + style={ + [ + undefined, + {}, + ] + } + > + Link to + </Text> + </View> + </View> + <Text + ellipsizeMode="middle" + numberOfLines={1} + style={ + { + "color": "gray", + } + } + > + Search or type URL + </Text> + <View + pointerEvents="auto" + style={ + [ + false, + undefined, + ] + } + > + <Svg + height={24} + style={{}} + viewBox="0 0 24 24" + width={24} + xmlns="http://www.w3.org/2000/svg" + > + Path + </Svg> + </View> + </View> + <View + style={{}} + /> + </View> + <View + accessibilityHint="Double tap to edit this value" + accessibilityLabel="Link text. Empty" + accessibilityRole="button" + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": false, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "opacity": 1, + } + } + > + <View + pointerEvents="auto" + style={ + [ + undefined, + {}, + ] + } + > + <View + style={ + [ + undefined, + {}, + ] + } + > + <View> + <View + style={ + [ + undefined, + undefined, + ] + } + > + <Svg + height={24} + lock={true} + style={{}} + viewBox="0 0 24 24" + width={24} + xmlns="http://www.w3.org/2000/svg" + > + Path + </Svg> + <View /> + </View> + <Text + style={ + [ + undefined, + {}, + ] + } + > + Link text + </Text> + </View> + </View> + <TextInput + disabled={false} + editable={true} + keyboardType="default" + numberOfLines={1} + onBlur={[Function]} + onChangeText={[Function]} + onFocus={[Function]} + onSubmitEditing={[Function]} + placeholder="Add link text" + placeholderTextColor="gray" + pointerEvents="none" + preferredColorScheme="light" + style={{}} + value="" + /> + <View + pointerEvents="auto" + style={ + [ + false, + undefined, + ] + } + /> + </View> + <View /> + </View> + <View + style={ + { + "height": 20, + } + } + /> + </View> + </View> + </View> + </RCTScrollView> + </View> + <View + collapsable={false} + pointerEvents="box-none" + style={{}} + /> + </View> + </View> + </View> + </View> + </View> + </View> + </View> + <View + collapsable={false} + pointerEvents="box-none" + style={ + { + "height": 56, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 1, + } + } + /> + </View> + </RNGestureHandlerRootView> + </View> + </View> + </View> +</Modal> +`; diff --git a/packages/format-library/src/link/test/index.native.js b/packages/format-library/src/link/test/index.native.js index 328ce17764dce3..e55590fc4070e9 100644 --- a/packages/format-library/src/link/test/index.native.js +++ b/packages/format-library/src/link/test/index.native.js @@ -2,7 +2,7 @@ * External dependencies */ import { Keyboard, Platform } from 'react-native'; -import { render, fireEvent, waitFor } from 'test/helpers'; +import { render, fireEvent } from 'test/helpers'; /** * WordPress dependencies @@ -53,9 +53,7 @@ describe( 'Android', () => { screen.getByLabelText( 'Link to, Search or type URL' ) ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => - screen.getByLabelText( 'Go back' ) - ); + const backButton = await screen.findByLabelText( 'Go back' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); @@ -63,7 +61,7 @@ describe( 'Android', () => { } ); it( 'improves apply animation performance by dismissing keyboard beforehand', async () => { - const { getByLabelText } = render( + const screen = render( <LinkEditSlot activeAttributes={ {} } onChange={ () => {} } @@ -74,10 +72,12 @@ describe( 'Android', () => { } } /> ); - fireEvent.press( getByLabelText( 'Link' ) ); - fireEvent.press( getByLabelText( 'Link to, Search or type URL' ) ); + fireEvent.press( screen.getByLabelText( 'Link' ) ); + fireEvent.press( + screen.getByLabelText( 'Link to, Search or type URL' ) + ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => getByLabelText( 'Apply' ) ); + const backButton = await screen.findByLabelText( 'Apply' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); @@ -112,9 +112,7 @@ describe( 'iOS', () => { screen.getByLabelText( 'Link to, Search or type URL' ) ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => - screen.getByLabelText( 'Go back' ) - ); + const backButton = await screen.findByLabelText( 'Go back' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); @@ -122,7 +120,7 @@ describe( 'iOS', () => { } ); it( 'improves apply animation performance by dismissing keyboard beforehand', async () => { - const { getByLabelText } = render( + const screen = render( <LinkEditSlot activeAttributes={ {} } onChange={ () => {} } @@ -133,10 +131,12 @@ describe( 'iOS', () => { } } /> ); - fireEvent.press( getByLabelText( 'Link' ) ); - fireEvent.press( getByLabelText( 'Link to, Search or type URL' ) ); + fireEvent.press( screen.getByLabelText( 'Link' ) ); + fireEvent.press( + screen.getByLabelText( 'Link to, Search or type URL' ) + ); // Await back button to allow async state updates to complete - const backButton = await waitFor( () => getByLabelText( 'Apply' ) ); + const backButton = await screen.findByLabelText( 'Apply' ); Keyboard.dismiss.mockClear(); fireEvent.press( backButton ); diff --git a/packages/format-library/src/link/test/modal.native.js b/packages/format-library/src/link/test/modal.native.js index 94d4499ef8906e..36ba4480634de8 100644 --- a/packages/format-library/src/link/test/modal.native.js +++ b/packages/format-library/src/link/test/modal.native.js @@ -9,7 +9,10 @@ import { render } from 'test/helpers'; describe( 'LinksUI', () => { it( 'LinksUI renders', () => { - const screen = render( <ModalLinkUI /> ); - expect( screen.container ).toBeTruthy(); + const value = { text: '' }; // empty `RichTextValue` + const screen = render( + <ModalLinkUI isVisible value={ value } activeAttributes={ {} } /> + ); + expect( screen.toJSON() ).toMatchSnapshot(); } ); } ); diff --git a/packages/format-library/src/text-color/index.native.js b/packages/format-library/src/text-color/index.native.js index 5003dee86d3a5b..5f133383ae1d1d 100644 --- a/packages/format-library/src/text-color/index.native.js +++ b/packages/format-library/src/text-color/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; /** * WordPress dependencies @@ -14,7 +14,11 @@ import { ToolbarButton, useMobileGlobalStylesColors, } from '@wordpress/components'; -import { Icon, textColor as textColorIcon } from '@wordpress/icons'; +import { + Icon, + color as colorIcon, + textColor as textColorIcon, +} from '@wordpress/icons'; import { removeFormat } from '@wordpress/rich-text'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; @@ -98,10 +102,13 @@ function TextColorEdit( { } }, [ hasColorsToChoose, value ] ); - const outlineStyle = usePreferredColorSchemeStyle( - styles[ 'components-inline-color__outline' ], - styles[ 'components-inline-color__outline--dark' ] - ); + const outlineStyle = [ + usePreferredColorSchemeStyle( + styles[ 'components-inline-color__outline' ], + styles[ 'components-inline-color__outline--dark' ] + ), + { borderWidth: StyleSheet.hairlineWidth }, + ]; if ( ! hasColorsToChoose && ! isActive ) { return null; @@ -131,7 +138,11 @@ function TextColorEdit( { isActive={ isActive } icon={ <Icon - icon={ textColorIcon } + icon={ + Object.keys( activeAttributes ).length + ? textColorIcon + : colorIcon + } style={ colorIndicatorStyle?.color && { color: colorIndicatorStyle.color, diff --git a/packages/format-library/src/text-color/style.native.scss b/packages/format-library/src/text-color/style.native.scss index 43e84e81ea7e99..c7751b3357d5f1 100644 --- a/packages/format-library/src/text-color/style.native.scss +++ b/packages/format-library/src/text-color/style.native.scss @@ -3,19 +3,18 @@ } .components-inline-color__outline { - border-color: $light-dim; + border-color: $light-quaternary; top: 6px; bottom: 6px; left: 11px; right: 11px; border-radius: 24px; - border-width: $border-width; position: absolute; z-index: 2; } .components-inline-color__outline--dark { - border-color: $dark-ultra-dim; + border-color: $dark-quaternary; } .components-inline-color__button-container { diff --git a/packages/format-library/src/text-color/test/index.native.js b/packages/format-library/src/text-color/test/index.native.js index 4b08bf16baf2bb..c7350cfe4bb6c0 100644 --- a/packages/format-library/src/text-color/test/index.native.js +++ b/packages/format-library/src/text-color/test/index.native.js @@ -83,6 +83,10 @@ describe( 'Text color', () => { const pinkColorButton = await screen.findByA11yHint( COLOR_PINK ); expect( pinkColorButton ).toBeDefined(); fireEvent.press( pinkColorButton ); + // TODO(jest-console): Fix the warning and remove the expect below. + expect( console ).toHaveWarnedWith( + `Non-serializable values were found in the navigation state. Check:\n\ntext-color > Palette > params.onColorChange (Function)\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.` + ); expect( getEditorHtml() ).toMatchSnapshot(); } ); diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index 121ce7117fae52..49c9320fd63ad9 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/hooks/package.json b/packages/hooks/package.json index ddb4a13de2683f..35218c60f5ff8b 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/hooks", - "version": "3.34.0", + "version": "3.40.0", "description": "WordPress hooks library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/hooks/src/createRunHook.js b/packages/hooks/src/createRunHook.js index ec83026d4d0db6..c2bf6fd187ce08 100644 --- a/packages/hooks/src/createRunHook.js +++ b/packages/hooks/src/createRunHook.js @@ -8,7 +8,7 @@ * @param {boolean} [returnFirstArg=false] Whether each hook callback is expected to * return its first argument. * - * @return {(hookName:string, ...args: unknown[]) => unknown} Function that runs hook callbacks. + * @return {(hookName:string, ...args: unknown[]) => undefined|unknown} Function that runs hook callbacks. */ function createRunHook( hooks, storeKey, returnFirstArg = false ) { return function runHooks( hookName, ...args ) { @@ -60,6 +60,8 @@ function createRunHook( hooks, storeKey, returnFirstArg = false ) { if ( returnFirstArg ) { return args[ 0 ]; } + + return undefined; }; } diff --git a/packages/html-entities/CHANGELOG.md b/packages/html-entities/CHANGELOG.md index 4e822575368294..ca3b32d6dd6b04 100644 --- a/packages/html-entities/CHANGELOG.md +++ b/packages/html-entities/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/html-entities/package.json b/packages/html-entities/package.json index ee9dfb3ac1b85b..2484954ca5210b 100644 --- a/packages/html-entities/package.json +++ b/packages/html-entities/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/html-entities", - "version": "3.34.0", + "version": "3.40.0", "description": "HTML entity utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index b87af9b446d4b7..43676a827be9a8 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.40.0 (2023-08-16) + +## 4.39.0 (2023-08-10) + +## 4.38.0 (2023-07-20) + +## 4.37.0 (2023-07-05) + +## 4.36.0 (2023-06-23) + +## 4.35.0 (2023-06-07) + ## 4.34.0 (2023-05-24) ## 4.33.0 (2023-05-10) diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 040e59e7208c15..1b25f9a9fe2deb 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/i18n", - "version": "4.34.0", + "version": "4.40.0", "description": "WordPress internationalization (i18n) library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index 911bdceb09db2c..7ce0c1bbfa3ffb 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### Bug Fix + +- Fixed invalid XML namespace on `alignJustify`, `customLink`, `mapMarker`, `postContent` and `title` ([#53955](https://github.com/WordPress/gutenberg/pull/53955)). + +## 9.31.0 (2023-08-16) + +## 9.30.0 (2023-08-10) + +## 9.29.0 (2023-07-20) + +## 9.28.0 (2023-07-05) + +## 9.27.0 (2023-06-23) + +## 9.26.0 (2023-06-07) + ## 9.25.0 (2023-05-24) ### New Features diff --git a/packages/icons/package.json b/packages/icons/package.json index 36291e2f43c9ff..8aa25cba5cb221 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/icons", - "version": "9.25.0", + "version": "9.31.0", "description": "WordPress Icons package, based on dashicon.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/icons/src/icon/stories/index.js b/packages/icons/src/icon/stories/index.story.js similarity index 100% rename from packages/icons/src/icon/stories/index.js rename to packages/icons/src/icon/stories/index.story.js diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js index f3fc6c1d71f6ce..f135e77e066bed 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -123,6 +123,7 @@ export { default as justifyRight } from './library/justify-right'; export { default as justifySpaceBetween } from './library/justify-space-between'; export { default as justifyStretch } from './library/justify-stretch'; export { default as key } from './library/key'; +export { default as keyboard } from './library/keyboard'; export { default as keyboardClose } from './library/keyboard-close'; export { default as keyboardReturn } from './library/keyboard-return'; export { default as language } from './library/language'; @@ -169,6 +170,7 @@ export { default as positionRight } from './library/position-right'; export { default as pencil } from './library/pencil'; export { default as people } from './library/people'; export { default as pin } from './library/pin'; +export { default as pinSmall } from './library/pin-small'; export { default as plugins } from './library/plugins'; export { default as plusCircleFilled } from './library/plus-circle-filled'; export { default as plusCircle } from './library/plus-circle'; @@ -245,7 +247,17 @@ export { default as termDescription } from './library/term-description'; export { default as footer } from './library/footer'; export { default as header } from './library/header'; export { default as sidebar } from './library/sidebar'; +export { default as sidesAll } from './library/sides-all'; +export { default as sidesAxial } from './library/sides-axial'; +export { default as sidesBottom } from './library/sides-bottom'; +export { default as sidesHorizontal } from './library/sides-horizontal'; +export { default as sidesLeft } from './library/sides-left'; +export { default as sidesRight } from './library/sides-right'; +export { default as sidesTop } from './library/sides-top'; +export { default as sidesVertical } from './library/sides-vertical'; export { default as textColor } from './library/text-color'; +export { default as textHorizontal } from './library/text-horizontal'; +export { default as textVertical } from './library/text-vertical'; export { default as tablet } from './library/tablet'; export { default as title } from './library/title'; export { default as tip } from './library/tip'; diff --git a/packages/icons/src/library/align-center.js b/packages/icons/src/library/align-center.js index 7e6f3ad7d7195e..1b3408b1982efd 100644 --- a/packages/icons/src/library/align-center.js +++ b/packages/icons/src/library/align-center.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const alignCenter = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M16.4 4.2H7.6v1.5h8.9V4.2zM4 11.2v1.5h16v-1.5H4zm3.6 8.6h8.9v-1.5H7.6v1.5z" /> + <Path d="M7.5 5.5h9V4h-9v1.5Zm-3.5 7h16V11H4v1.5Zm3.5 7h9V18h-9v1.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/align-justify.js b/packages/icons/src/library/align-justify.js index 9fee191c0a71e2..f79a8bf06d5b2f 100644 --- a/packages/icons/src/library/align-justify.js +++ b/packages/icons/src/library/align-justify.js @@ -4,7 +4,7 @@ import { SVG, Path } from '@wordpress/primitives'; const alignJustify = ( - <SVG xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <Path d="M4 12.8h16v-1.5H4v1.5zm0 7h12.4v-1.5H4v1.5zM4 4.3v1.5h16V4.3H4z" /> </SVG> ); diff --git a/packages/icons/src/library/align-left.js b/packages/icons/src/library/align-left.js index 152a2c2bd96f5a..ef597eb019aee1 100644 --- a/packages/icons/src/library/align-left.js +++ b/packages/icons/src/library/align-left.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const alignLeft = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M4 19.8h8.9v-1.5H4v1.5zm8.9-15.6H4v1.5h8.9V4.2zm-8.9 7v1.5h16v-1.5H4z" /> + <Path d="M13 5.5H4V4h9v1.5Zm7 7H4V11h16v1.5Zm-7 7H4V18h9v1.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/align-none.js b/packages/icons/src/library/align-none.js index 0948cd8d943000..2c29275123317f 100644 --- a/packages/icons/src/library/align-none.js +++ b/packages/icons/src/library/align-none.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const alignNone = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M5 15h14V9H5v6zm0 4.8h14v-1.5H5v1.5zM5 4.2v1.5h14V4.2H5z" /> + <Path d="M19 5.5H5V4h14v1.5ZM19 20H5v-1.5h14V20ZM5 9h14v6H5V9Z" /> </SVG> ); diff --git a/packages/icons/src/library/align-right.js b/packages/icons/src/library/align-right.js index 4d0f60f37309f6..2daff79001f327 100644 --- a/packages/icons/src/library/align-right.js +++ b/packages/icons/src/library/align-right.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const alignRight = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M11.1 19.8H20v-1.5h-8.9v1.5zm0-15.6v1.5H20V4.2h-8.9zM4 12.8h16v-1.5H4v1.5z" /> + <Path d="M11.111 5.5H20V4h-8.889v1.5ZM4 12.5h16V11H4v1.5Zm7.111 7H20V18h-8.889v1.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/button.js b/packages/icons/src/library/button.js index 9081993de98ddd..3565aac38cf190 100644 --- a/packages/icons/src/library/button.js +++ b/packages/icons/src/library/button.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const button = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M19 6.5H5c-1.1 0-2 .9-2 2v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7c0-1.1-.9-2-2-2zm.5 9c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5v-7c0-.3.2-.5.5-.5h14c.3 0 .5.2.5.5v7zM8 12.8h8v-1.5H8v1.5z" /> + <Path d="M8 12.5h8V11H8v1.5Z M19 6.5H5a2 2 0 0 0-2 2V15a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.5a2 2 0 0 0-2-2ZM5 8h14a.5.5 0 0 1 .5.5V15a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V8.5A.5.5 0 0 1 5 8Z" /> </SVG> ); diff --git a/packages/icons/src/library/buttons.js b/packages/icons/src/library/buttons.js index 8fc5856efec1b7..bb6f59a7764c1d 100644 --- a/packages/icons/src/library/buttons.js +++ b/packages/icons/src/library/buttons.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const buttons = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M17 3H7c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 6c0 .3-.2.5-.5.5H7c-.3 0-.5-.2-.5-.5V5c0-.3.2-.5.5-.5h10c.3 0 .5.2.5.5v4zm-8-1.2h5V6.2h-5v1.6zM17 13H7c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zm.5 6c0 .3-.2.5-.5.5H7c-.3 0-.5-.2-.5-.5v-4c0-.3.2-.5.5-.5h10c.3 0 .5.2.5.5v4zm-8-1.2h5v-1.5h-5v1.5z" /> + <Path d="M14.5 17.5H9.5V16H14.5V17.5Z M14.5 8H9.5V6.5H14.5V8Z M7 3.5H17C18.1046 3.5 19 4.39543 19 5.5V9C19 10.1046 18.1046 11 17 11H7C5.89543 11 5 10.1046 5 9V5.5C5 4.39543 5.89543 3.5 7 3.5ZM17 5H7C6.72386 5 6.5 5.22386 6.5 5.5V9C6.5 9.27614 6.72386 9.5 7 9.5H17C17.2761 9.5 17.5 9.27614 17.5 9V5.5C17.5 5.22386 17.2761 5 17 5Z M7 13H17C18.1046 13 19 13.8954 19 15V18.5C19 19.6046 18.1046 20.5 17 20.5H7C5.89543 20.5 5 19.6046 5 18.5V15C5 13.8954 5.89543 13 7 13ZM17 14.5H7C6.72386 14.5 6.5 14.7239 6.5 15V18.5C6.5 18.7761 6.72386 19 7 19H17C17.2761 19 17.5 18.7761 17.5 18.5V15C17.5 14.7239 17.2761 14.5 17 14.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/custom-link.js b/packages/icons/src/library/custom-link.js index 27a5d24a71083e..b7aa7c309aaeb8 100644 --- a/packages/icons/src/library/custom-link.js +++ b/packages/icons/src/library/custom-link.js @@ -4,7 +4,7 @@ import { SVG, Path } from '@wordpress/primitives'; const customLink = ( - <SVG xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <Path d="M12.5 14.5h-1V16h1c2.2 0 4-1.8 4-4s-1.8-4-4-4h-1v1.5h1c1.4 0 2.5 1.1 2.5 2.5s-1.1 2.5-2.5 2.5zm-4 1.5v-1.5h-1C6.1 14.5 5 13.4 5 12s1.1-2.5 2.5-2.5h1V8h-1c-2.2 0-4 1.8-4 4s1.8 4 4 4h1zm-1-3.2h5v-1.5h-5v1.5zM18 4H9c-1.1 0-2 .9-2 2v.5h1.5V6c0-.3.2-.5.5-.5h9c.3 0 .5.2.5.5v12c0 .3-.2.5-.5.5H9c-.3 0-.5-.2-.5-.5v-.5H7v.5c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2z" /> </SVG> ); diff --git a/packages/icons/src/library/justify-center.js b/packages/icons/src/library/justify-center.js index e0b9e6645a40bb..9276be34f08e0d 100644 --- a/packages/icons/src/library/justify-center.js +++ b/packages/icons/src/library/justify-center.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const justifyCenter = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M20 9h-7.2V4h-1.6v5H4v6h7.2v5h1.6v-5H20z" /> + <Path d="M12.5 15v5H11v-5H4V9h7V4h1.5v5h7v6h-7Z" /> </SVG> ); diff --git a/packages/icons/src/library/keyboard.js b/packages/icons/src/library/keyboard.js new file mode 100644 index 00000000000000..658b0e2d7ff3e7 --- /dev/null +++ b/packages/icons/src/library/keyboard.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const keyboard = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="m16 15.5h-8v-1.5h8zm-7.5-2.5h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm-9-3h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2z" /> + <Path d="m18.5 6.5h-13a.5.5 0 0 0 -.5.5v9.5a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-9.5a.5.5 0 0 0 -.5-.5zm-13-1.5h13a2 2 0 0 1 2 2v9.5a2 2 0 0 1 -2 2h-13a2 2 0 0 1 -2-2v-9.5a2 2 0 0 1 2-2z" /> + </SVG> +); + +export default keyboard; diff --git a/packages/icons/src/library/link-off.js b/packages/icons/src/library/link-off.js index 0ba44b747753a3..ff03679aa3f5dd 100644 --- a/packages/icons/src/library/link-off.js +++ b/packages/icons/src/library/link-off.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const linkOff = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M15.6 7.3h-.7l1.6-3.5-.9-.4-3.9 8.5H9v1.5h2l-1.3 2.8H8.4c-2 0-3.7-1.7-3.7-3.7s1.7-3.7 3.7-3.7H10V7.3H8.4c-2.9 0-5.2 2.3-5.2 5.2 0 2.9 2.3 5.2 5.2 5.2H9l-1.4 3.2.9.4 5.7-12.5h1.4c2 0 3.7 1.7 3.7 3.7s-1.7 3.7-3.7 3.7H14v1.5h1.6c2.9 0 5.2-2.3 5.2-5.2 0-2.9-2.4-5.2-5.2-5.2z" /> + <Path d="M17.031 4.703 15.576 4l-1.56 3H14v.03l-2.324 4.47H9.5V13h1.396l-1.502 2.889h-.95a3.694 3.694 0 0 1 0-7.389H10V7H8.444a5.194 5.194 0 1 0 0 10.389h.17L7.5 19.53l1.416.719L15.049 8.5h.507a3.694 3.694 0 0 1 0 7.39H14v1.5h1.556a5.194 5.194 0 0 0 .273-10.383l1.202-2.304Z" /> </SVG> ); diff --git a/packages/icons/src/library/link.js b/packages/icons/src/library/link.js index 48375903deb2c6..81ff6b82e48fc2 100644 --- a/packages/icons/src/library/link.js +++ b/packages/icons/src/library/link.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const link = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M15.6 7.2H14v1.5h1.6c2 0 3.7 1.7 3.7 3.7s-1.7 3.7-3.7 3.7H14v1.5h1.6c2.8 0 5.2-2.3 5.2-5.2 0-2.9-2.3-5.2-5.2-5.2zM4.7 12.4c0-2 1.7-3.7 3.7-3.7H10V7.2H8.4c-2.9 0-5.2 2.3-5.2 5.2 0 2.9 2.3 5.2 5.2 5.2H10v-1.5H8.4c-2 0-3.7-1.7-3.7-3.7zm4.6.9h5.3v-1.5H9.3v1.5z" /> + <Path d="M10 17.389H8.444A5.194 5.194 0 1 1 8.444 7H10v1.5H8.444a3.694 3.694 0 0 0 0 7.389H10v1.5ZM14 7h1.556a5.194 5.194 0 0 1 0 10.39H14v-1.5h1.556a3.694 3.694 0 0 0 0-7.39H14V7Zm-4.5 6h5v-1.5h-5V13Z" /> </SVG> ); diff --git a/packages/icons/src/library/map-marker.js b/packages/icons/src/library/map-marker.js index f142d17d352af2..52fa68e9590c3a 100644 --- a/packages/icons/src/library/map-marker.js +++ b/packages/icons/src/library/map-marker.js @@ -4,7 +4,7 @@ import { SVG, Path } from '@wordpress/primitives'; const mapMarker = ( - <SVG xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <Path d="M12 9c-.8 0-1.5.7-1.5 1.5S11.2 12 12 12s1.5-.7 1.5-1.5S12.8 9 12 9zm0-5c-3.6 0-6.5 2.8-6.5 6.2 0 .8.3 1.8.9 3.1.5 1.1 1.2 2.3 2 3.6.7 1 3 3.8 3.2 3.9l.4.5.4-.5c.2-.2 2.6-2.9 3.2-3.9.8-1.2 1.5-2.5 2-3.6.6-1.3.9-2.3.9-3.1C18.5 6.8 15.6 4 12 4zm4.3 8.7c-.5 1-1.1 2.2-1.9 3.4-.5.7-1.7 2.2-2.4 3-.7-.8-1.9-2.3-2.4-3-.8-1.2-1.4-2.3-1.9-3.3-.6-1.4-.7-2.2-.7-2.5 0-2.6 2.2-4.7 5-4.7s5 2.1 5 4.7c0 .2-.1 1-.7 2.4z" /> </SVG> ); diff --git a/packages/icons/src/library/media-and-text.js b/packages/icons/src/library/media-and-text.js index 31bb37d9b41f28..dd65590e49412b 100644 --- a/packages/icons/src/library/media-and-text.js +++ b/packages/icons/src/library/media-and-text.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const mediaAndText = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M3 18h8V6H3v12zM14 7.5V9h7V7.5h-7zm0 5.3h7v-1.5h-7v1.5zm0 3.7h7V15h-7v1.5z" /> + <Path d="M3 6v11.5h8V6H3Zm11 3h7V7.5h-7V9Zm7 3.5h-7V11h7v1.5ZM14 16h7v-1.5h-7V16Z" /> </SVG> ); diff --git a/packages/icons/src/library/page-break.js b/packages/icons/src/library/page-break.js index ef8a0a82869d15..a382b5d41bf452 100644 --- a/packages/icons/src/library/page-break.js +++ b/packages/icons/src/library/page-break.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const pageBreak = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M7.8 6c0-.7.6-1.2 1.2-1.2h6c.7 0 1.2.6 1.2 1.2v3h1.5V6c0-1.5-1.2-2.8-2.8-2.8H9C7.5 3.2 6.2 4.5 6.2 6v3h1.5V6zm8.4 11c0 .7-.6 1.2-1.2 1.2H9c-.7 0-1.2-.6-1.2-1.2v-3H6.2v3c0 1.5 1.2 2.8 2.8 2.8h6c1.5 0 2.8-1.2 2.8-2.8v-3h-1.5v3zM4 11v1h16v-1H4z" /> + <Path d="M17.5 9V6a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2v3H8V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v3h1.5Zm0 6.5V18a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-2.5H8V18a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5v-2.5h1.5ZM4 13h16v-1.5H4V13Z" /> </SVG> ); diff --git a/packages/icons/src/library/pin-small.js b/packages/icons/src/library/pin-small.js new file mode 100644 index 00000000000000..d479c27d77687a --- /dev/null +++ b/packages/icons/src/library/pin-small.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const pinSmall = ( + <SVG + width="24" + height="24" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <Path d="M10.97 10.159a3.382 3.382 0 0 0-2.857.955l1.724 1.723-2.836 2.913L7 17h1.25l2.913-2.837 1.723 1.723a3.38 3.38 0 0 0 .606-.825c.33-.63.446-1.343.35-2.032L17 10.695 13.305 7l-2.334 3.159Z" /> + </SVG> +); + +export default pinSmall; diff --git a/packages/icons/src/library/position-center.js b/packages/icons/src/library/position-center.js index 7c9686535ce65a..6730c90638780a 100644 --- a/packages/icons/src/library/position-center.js +++ b/packages/icons/src/library/position-center.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const positionCenter = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M7 9v6h10V9H7zM5 19.8h14v-1.5H5v1.5zM5 4.3v1.5h14V4.3H5z" /> + <Path d="M19 5.5H5V4h14v1.5ZM19 20H5v-1.5h14V20ZM7 9h10v6H7V9Z" /> </SVG> ); diff --git a/packages/icons/src/library/position-left.js b/packages/icons/src/library/position-left.js index 5119fdf02acfc8..fff393d10637e2 100644 --- a/packages/icons/src/library/position-left.js +++ b/packages/icons/src/library/position-left.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const positionLeft = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M4 9v6h14V9H4zm8-4.8H4v1.5h8V4.2zM4 19.8h8v-1.5H4v1.5z" /> + <Path d="M5 5.5h8V4H5v1.5ZM5 20h8v-1.5H5V20ZM19 9H5v6h14V9Z" /> </SVG> ); diff --git a/packages/icons/src/library/position-right.js b/packages/icons/src/library/position-right.js index ba6af93107723a..eff1c853534f34 100644 --- a/packages/icons/src/library/position-right.js +++ b/packages/icons/src/library/position-right.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const positionRight = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M6 15h14V9H6v6zm6-10.8v1.5h8V4.2h-8zm0 15.6h8v-1.5h-8v1.5z" /> + <Path d="M19 5.5h-8V4h8v1.5ZM19 20h-8v-1.5h8V20ZM5 9h14v6H5V9Z" /> </SVG> ); diff --git a/packages/icons/src/library/post-content.js b/packages/icons/src/library/post-content.js index fac55cf5f4e194..6047a5ad368f6b 100644 --- a/packages/icons/src/library/post-content.js +++ b/packages/icons/src/library/post-content.js @@ -4,8 +4,8 @@ import { SVG, Path } from '@wordpress/primitives'; const postContent = ( - <SVG xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M4 20h16v-1.5H4V20zm0-4.8h16v-1.5H4v1.5zm0-6.4v1.5h16V8.8H4zM16 4H4v1.5h12V4z" /> + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M4 6h12V4.5H4V6Zm16 4.5H4V9h16v1.5ZM4 15h16v-1.5H4V15Zm0 4.5h16V18H4v1.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/resize-corner-n-e.js b/packages/icons/src/library/resize-corner-n-e.js index 387efc99fb00db..48cd2a7a3fa58c 100644 --- a/packages/icons/src/library/resize-corner-n-e.js +++ b/packages/icons/src/library/resize-corner-n-e.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const resizeCornerNE = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M12.5 4.2v1.6h4.7L5.8 17.2V12H4.2v7.8H12v-1.6H6.8L18.2 6.8v4.7h1.6V4.2z" /> + <Path d="M7 18h4.5v1.5h-7v-7H6V17L17 6h-4.5V4.5h7v7H18V7L7 18Z" /> </SVG> ); diff --git a/packages/icons/src/library/separator.js b/packages/icons/src/library/separator.js index 98805ca1814526..9e1b3334a88ba8 100644 --- a/packages/icons/src/library/separator.js +++ b/packages/icons/src/library/separator.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const separator = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M20.2 7v4H3.8V7H2.2v9h1.6v-3.5h16.4V16h1.6V7z" /> + <Path d="M4.5 12.5v4H3V7h1.5v3.987h15V7H21v9.5h-1.5v-4h-15Z" /> </SVG> ); diff --git a/packages/icons/src/library/sides-all.js b/packages/icons/src/library/sides-all.js new file mode 100644 index 00000000000000..d6e027685640a6 --- /dev/null +++ b/packages/icons/src/library/sides-all.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesAll = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path + d="M3.5 17H5V7H3.5v10zM7 20.5h10V19H7v1.5zM19 7v10h1.5V7H19zM7 5h10V3.5H7V5z" + style={ { fill: '#1e1e1e' } } + /> + </SVG> +); + +export default sidesAll; diff --git a/packages/icons/src/library/sides-axial.js b/packages/icons/src/library/sides-axial.js new file mode 100644 index 00000000000000..660d3ac20853d1 --- /dev/null +++ b/packages/icons/src/library/sides-axial.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesAxial = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path + d="M8.2 5.3h8V3.8h-8v1.5zm0 14.5h8v-1.5h-8v1.5zm3.5-6.5h1v-1h-1v1zm1-6.5h-1v.5h1v-.5zm-1 4.5h1v-1h-1v1zm0-2h1v-1h-1v1zm0 7.5h1v-.5h-1v.5zm1-2.5h-1v1h1v-1zm-8.5 1.5h1.5v-8H4.2v8zm14.5-8v8h1.5v-8h-1.5zm-5 4.5v-1h-1v1h1zm-6.5 0h.5v-1h-.5v1zm3.5-1v1h1v-1h-1zm6 1h.5v-1h-.5v1zm-8-1v1h1v-1h-1zm6 0v1h1v-1h-1z" + style={ { + fill: '#1e1e1e', + fillRule: 'evenodd', + clipRule: 'evenodd', + } } + /> + </SVG> +); + +export default sidesAxial; diff --git a/packages/icons/src/library/sides-bottom.js b/packages/icons/src/library/sides-bottom.js new file mode 100644 index 00000000000000..d1615ebc2a93b0 --- /dev/null +++ b/packages/icons/src/library/sides-bottom.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesBottom = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M7 20.5h10V19H7v1.5z" style={ { fill: '#1e1e1e' } } /> + <Path + d="M3.5 17H5V7H3.5v10zM19 7v10h1.5V7H19zM7 5h10V3.5H7V5z" + style={ { fill: '#1e1e1e', opacity: 0.1 } } + /> + </SVG> +); + +export default sidesBottom; diff --git a/packages/icons/src/library/sides-horizontal.js b/packages/icons/src/library/sides-horizontal.js new file mode 100644 index 00000000000000..87402487689e3b --- /dev/null +++ b/packages/icons/src/library/sides-horizontal.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesHorizontal = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path + d="M3.5 17H5V7H3.5v10zM19 7v10h1.5V7H19z" + style={ { fill: '#1e1e1e' } } + /> + <Path + d="M7 20.5h10V19H7v1.5zm0-17V5h10V3.5H7z" + style={ { fill: '#1e1e1e', opacity: 0.1 } } + /> + </SVG> +); + +export default sidesHorizontal; diff --git a/packages/icons/src/library/sides-left.js b/packages/icons/src/library/sides-left.js new file mode 100644 index 00000000000000..ff2189f76a0dc9 --- /dev/null +++ b/packages/icons/src/library/sides-left.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesLeft = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M5 17H3.5V7H5v10z" style={ { fill: '#1e1e1e' } } /> + <Path + d="M7 20.5h10V19H7v1.5zM19 7v10h1.5V7H19zM7 5h10V3.5H7V5z" + style={ { fill: '#1e1e1e', opacity: 0.1 } } + /> + </SVG> +); + +export default sidesLeft; diff --git a/packages/icons/src/library/sides-right.js b/packages/icons/src/library/sides-right.js new file mode 100644 index 00000000000000..1abb83ad1bb689 --- /dev/null +++ b/packages/icons/src/library/sides-right.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesRight = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M20.5 7H19v10h1.5V7z" style={ { fill: '#1e1e1e' } } /> + <Path + d="M3.5 17H5V7H3.5v10zM7 20.5h10V19H7v1.5zm0-17V5h10V3.5H7z" + style={ { fill: '#1e1e1e', opacity: 0.1 } } + /> + </SVG> +); + +export default sidesRight; diff --git a/packages/icons/src/library/sides-top.js b/packages/icons/src/library/sides-top.js new file mode 100644 index 00000000000000..8519397ffd63e1 --- /dev/null +++ b/packages/icons/src/library/sides-top.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesTop = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path + d="M3.5 17H5V7H3.5v10zM7 20.5h10V19H7v1.5zM19 7v10h1.5V7H19z" + style={ { fill: '#1e1e1e', opacity: 0.1 } } + /> + <Path d="M7 5h10V3.5H7V5z" style={ { fill: '#1e1e1e' } } /> + </SVG> +); + +export default sidesTop; diff --git a/packages/icons/src/library/sides-vertical.js b/packages/icons/src/library/sides-vertical.js new file mode 100644 index 00000000000000..09306cf72549a7 --- /dev/null +++ b/packages/icons/src/library/sides-vertical.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const sidesVertical = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path + d="M3.5 17H5V7H3.5v10zM19 7v10h1.5V7H19z" + style={ { fill: '#1e1e1e', opacity: 0.1 } } + /> + <Path + d="M7 20.5h10V19H7v1.5zm0-17V5h10V3.5H7z" + style={ { fill: '#1e1e1e' } } + /> + </SVG> +); + +export default sidesVertical; diff --git a/packages/icons/src/library/stretch-full-width.js b/packages/icons/src/library/stretch-full-width.js index 3d6e37ff544d84..ee2c6393dcd222 100644 --- a/packages/icons/src/library/stretch-full-width.js +++ b/packages/icons/src/library/stretch-full-width.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const stretchFullWidth = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M5 4v11h14V4H5zm3 15.8h8v-1.5H8v1.5z" /> + <Path d="M5 4h14v11H5V4Zm11 16H8v-1.5h8V20Z" /> </SVG> ); diff --git a/packages/icons/src/library/stretch-wide.js b/packages/icons/src/library/stretch-wide.js index a9cf65fe001fff..c341d8718934b5 100644 --- a/packages/icons/src/library/stretch-wide.js +++ b/packages/icons/src/library/stretch-wide.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const stretchWide = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M5 9v6h14V9H5zm11-4.8H8v1.5h8V4.2zM8 19.8h8v-1.5H8v1.5z" /> + <Path d="M16 5.5H8V4h8v1.5ZM16 20H8v-1.5h8V20ZM5 9h14v6H5V9Z" /> </SVG> ); diff --git a/packages/icons/src/library/text-horizontal.js b/packages/icons/src/library/text-horizontal.js new file mode 100644 index 00000000000000..3f82ee1fba4cbd --- /dev/null +++ b/packages/icons/src/library/text-horizontal.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const textHorizontal = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M8.2 14.4h3.9L13 17h1.7L11 6.5H9.3L5.6 17h1.7l.9-2.6zm2-5.5 1.4 4H8.8l1.4-4zm7.4 7.5-1.3.8.8 1.4H5.5V20h14.3l-2.2-3.6z" /> + </SVG> +); + +export default textHorizontal; diff --git a/packages/icons/src/library/text-vertical.js b/packages/icons/src/library/text-vertical.js new file mode 100644 index 00000000000000..406561bee97cc8 --- /dev/null +++ b/packages/icons/src/library/text-vertical.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const textVertical = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M7 5.6v1.7l2.6.9v3.9L7 13v1.7L17.5 11V9.3L7 5.6zm4.2 6V8.8l4 1.4-4 1.4zm-5.7 5.6V5.5H4v14.3l3.6-2.2-.8-1.3-1.3.9z" /> + </SVG> +); + +export default textVertical; diff --git a/packages/icons/src/library/title.js b/packages/icons/src/library/title.js index 902192be392ec4..cf77753bc35f89 100644 --- a/packages/icons/src/library/title.js +++ b/packages/icons/src/library/title.js @@ -4,8 +4,8 @@ import { SVG, Path } from '@wordpress/primitives'; const title = ( - <SVG xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M4 5.417h2.267V12h1.466V5.417H10V4H4v1.417ZM20 16H4v-1.5h16V16Zm-7 4H4v-1.5h9V20Z" /> + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="m4 5.5h2v6.5h1.5v-6.5h2v-1.5h-5.5zm16 10.5h-16v-1.5h16zm-7 4h-9v-1.5h9z" /> </SVG> ); diff --git a/packages/interactivity/.npmrc b/packages/interactivity/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/interactivity/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md new file mode 100644 index 00000000000000..6a4565f3ad1833 --- /dev/null +++ b/packages/interactivity/CHANGELOG.md @@ -0,0 +1,41 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased + +### Enhancements + +- Support keys using `data-wp-key`. ([#53844](https://github.com/WordPress/gutenberg/pull/53844)) +- Merge new server-side rendered context on client-side navigation. ([#53853](https://github.com/WordPress/gutenberg/pull/53853)) +- Support region-based client-side navigation. ([#53733](https://github.com/WordPress/gutenberg/pull/53733)) +- Improve `data-wp-bind` hydration to match Preact's logic. ([#54003](https://github.com/WordPress/gutenberg/pull/54003)) + +### New Features + +- Add new directives that implement the Slot and Fill pattern: `data-wp-slot-provider`, `data-wp-slot` and `data-wp-fill`. ([#53958](https://github.com/WordPress/gutenberg/pull/53958)) + +## 2.1.0 (2023-08-16) + +### New Features + +- Allow passing optional `afterLoad` callbacks to `store` calls. ([#53363](https://github.com/WordPress/gutenberg/pull/53363)) + +### Bug Fix + +- Add support for underscores and leading dashes in the suffix part of the directive. ([#53337](https://github.com/WordPress/gutenberg/pull/53337)) +- Add an asynchronous short circuit to `useSignalEffect` to avoid infinite loops. ([#53358](https://github.com/WordPress/gutenberg/pull/53358)) + +### Enhancements + +- Add JSDoc comments to `store()` and `directive()` functions. ([#52469](https://github.com/WordPress/gutenberg/pull/52469)) + +## 2.0.0 (2023-08-10) + +### Breaking Change + +- Remove the `wp-show` directive until we figure out its final implementation. ([#53240](https://github.com/WordPress/gutenberg/pull/53240)) + +## 1.2.0 (2023-07-20) + +### New Features + +- Runtime support for the `data-wp-style` directive. ([#52645](https://github.com/WordPress/gutenberg/pull/52645)) diff --git a/packages/interactivity/README.md b/packages/interactivity/README.md new file mode 100644 index 00000000000000..6b36e24e7c2a97 --- /dev/null +++ b/packages/interactivity/README.md @@ -0,0 +1,62 @@ +# Interactivity API + +> **Warning** +> **This package is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. + + +> **Note** +> This package enables 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/explanations/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. + +This package can be tested, but it's still very experimental. +The Interactivity API is [being used in some core blocks](https://github.com/search?q=repo%3AWordPress%2Fgutenberg%20%40wordpress%2Finteractivity&type=code) but its use is still very limited. + + +## Frequently Asked Questions + +At this point, some of the questions you have about the Interactivity API may be: + +### What is this? + +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. + +### Can I use it? + +You can test it, but it's still very experimental. + +### How do I get started? + +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. + +### Where can I ask questions? + +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. + +### Where can I share my feedback about the API? + +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. + +## Installation + +Install the module: + +```bash +npm install @wordpress/interactivity --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 + +**[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: + +- **[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. + +Here you have some more resources to learn/read more about the Interactivity API: + +- **[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 + +<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/interactivity/docs/1-getting-started.md b/packages/interactivity/docs/1-getting-started.md new file mode 100644 index 00000000000000..c4bae5d5fe6702 --- /dev/null +++ b/packages/interactivity/docs/1-getting-started.md @@ -0,0 +1,89 @@ +# Getting started with the Interactivity API + +To get started with the Interactivity API, you can follow this [**Quick Start Guide**](#quick-start-guide) by taking into account the [current requirements of the Interactivity API](#requirements-of-the-interactivity-api) (especially the need for Gutenberg 16.2 or later). + +## Table of Contents + +- [Quick Start Guide](#quick-start-guide) + - [1. Scaffold an interactive block](#1-scaffold-an-interactive-block) + - [2. Generate the build](#2-generate-the-build) + - [3. Use it in your WordPress installation ](#3-use-it-in-your-wordpress-installation) +- [Requirements of the Interactivity API](#requirements-of-the-interactivity-aPI) + - [A local WordPress installation](#a-local-wordpress-installation) + - [Latest vesion of Gutenberg](#latest-vesion-of-gutenberg) + - [Node.js](#nodejs) + - [Code requirements](#code-requirements) + - [Add `interactivity` support to `block.json`](#add-interactivity-support-to-blockjson) + - [Add `wp-interactive` directive to a DOM element](#add-wp-interactive-directive-to-a-dom-element) + +## Quick Start Guide + +#### 1. Scaffold an interactive block + +We can scaffold a WordPress plugin that registers an interactive block (using the Interactivity API) by using a [template](https://www.npmjs.com/package/@wordpress/create-block-interactive-template) with the `@wordpress/create-block` command. + +``` +npx @wordpress/create-block my-first-interactive-block --template @wordpress/create-block-interactive-template +``` + +#### 2. Generate the build + +When the plugin folder is generated, we should launch the build process to get the final version of the interactive block that can be used from WordPress. + +``` +cd my-first-interactive-block && npm start +``` + +#### 3. Use it in your WordPress installation + +If you have a local WordPress installation already running, you can launch the commands above inside the `plugins` folder of that installation. If not, you can use [`wp-now`](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) to launch a WordPress site with the plugin installed by executing from the generated folder (and from a different terminal window or tab) the following command + +``` +npx @wp-now/wp-now start +``` + +At this point you should be able to insert the "My First Interactive Block" block into any post, and see how it behaves in the frontend when published. + +> **Note** +> We recommend you to also check the [API Reference](./2-api-reference.md) docs for your first exploration of the Interactivity API + +## Requirements of the Interactivity API + +To start working with the Interactivity API you'll need to have a [proper WordPress development environment for blocks](https://developer.wordpress.org/block-editor/getting-started/devenv/) and some specific code in your block, which should include: + +#### A local WordPress installation + +You can use [the tools to set your local WordPress environment](https://developer.wordpress.org/block-editor/getting-started/devenv/#wordpress-development-site) you feel more comfortable with. + +To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) is the easiest way to get a WordPress site up and running locally. + +#### Latest vesion of Gutenberg + +The Interactivity API is currently only available as an experimental feature from Gutenberg 16.2, so you'll need to have Gutenberg 16.2 or higher version installed and activated in your WordPress installation. + +#### Node.js + +Block development requires [Node](https://nodejs.org/en), so you'll need to have Node installed and running on your machine. Any version modern should work, but please check the minimum version requirements if you run into any issues with any of the Node.js tools used in WordPress development. + +#### Code requirements + +##### Add `interactivity` support to `block.json` + +To indicate that our block [supports](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) the Interactivity API features, we do so by adding `"interactivity": true` to the `supports` attribute of our block's `block.json` + +``` +"supports": { + "interactivity": true +}, +``` + +##### Add `wp-interactive` directive to a DOM element + +To "activate" the Interactivity API in a DOM element (and its children) we add the [`wp-interactive` directive](./2-api-reference.md#wp-interactive) to it from our `render.php` or `save.js` + + +```html +<div data-wp-interactive> + <!-- Interactivity API zone --> +</div> +``` \ No newline at end of file diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md new file mode 100644 index 00000000000000..828de4379c0269 --- /dev/null +++ b/packages/interactivity/docs/2-api-reference.md @@ -0,0 +1,713 @@ +# API Reference + +To add interactivity to blocks using the Interactivity API, developers can use: + +- **Directives** - added to the markup to add specific behavior to the DOM elements of block. +- **Store** - that contains the logic and data (state, actions, or effects among others) needed for the behaviour. + +DOM elements are connected to data stored in the state & context through directives. If data in the state or context change, directives will react to those changes updating the DOM accordingly (see [diagram](https://excalidraw.com/#json=rEg5d71O_jy3NrgYJUIVd,yjOUmMvxzNf6alqFjElvIw)). + +![State & Directives](assets/state-directives.png) + +## Table of Contents + +- [The directives](#the-directives) + - [List of Directives](#list-of-directives) + - [`wp-interactive`](#wp-interactive) ![](https://img.shields.io/badge/DECLARATIVE-afd2e3.svg) + - [`wp-context`](#wp-context) ![](https://img.shields.io/badge/STATE-afd2e3.svg) + - [`wp-bind`](#wp-bind) ![](https://img.shields.io/badge/ATTRIBUTES-afd2e3.svg) + - [`wp-class`](#wp-class) ![](https://img.shields.io/badge/ATTRIBUTES-afd2e3.svg) + - [`wp-style`](#wp-style) ![](https://img.shields.io/badge/ATTRIBUTES-afd2e3.svg) + - [`wp-text`](#wp-text) ![](https://img.shields.io/badge/CONTENT-afd2e3.svg) + - [`wp-on`](#wp-on) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg) + - [`wp-effect`](#wp-effect) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg) + - [`wp-init`](#wp-init) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg) + - [`wp-key`](#wp-key) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg) + - [Values of directives are references to store properties](#values-of-directives-are-references-to-store-properties) +- [The store](#the-store) + - [Elements of the store](#elements-of-the-store) + - [State](#state) + - [Actions](#actions) + - [Effects](#effects) + - [Selectors](#selectors) + - [Arguments passed to callbacks](#arguments-passed-to-callbacks) + - [Setting the store](#setting-the-store) + - [On the client side](#on-the-client-side) + - [On the server side](#on-the-server-side) + + + +## The directives + +Directives are custom attributes that are added to the markup of your block to add behaviour to its DOM elements. This can be done in the `render.php` file (for dynamic blocks) or the `save.js` file (for static blocks). + +Interactivity API directives use the `data-` prefix. + +_Example of directives used in the HTML markup_ + +```html +<div + data-wp-context='{ "myNamespace" : { "isOpen": false } }' + data-wp-effect="effects.myNamespace.logIsOpen" +> + <button + data-wp-on--click="actions.myNamespace.toggle" + data-wp-bind--aria-expanded="context.myNamespace.isOpen" + aria-controls="p-1" + > + Toggle + </button> + + <p id="p-1" data-bind--hidden="!context.myNamespace.isOpen"> + This element is now visible! + </p> +</div> +``` + +> **Note** +> The use of Namespaces to define the context, state or any other elements of the store is highly recommended to avoid possible collision with other elements with the same name. In the following examples we have not used namespaces for the sake of simplicity. + +Directives can also be injected dynamically using the [HTML Tag Processor](https://make.wordpress.org/core/2023/03/07/introducing-the-html-api-in-wordpress-6-2). + +### List of Directives + +With directives we can manage directly in the DOM behavior related to things such as side effects, state, event handlers, attributes or content. + +#### `wp-interactive` + +The `wp-interactive` directive "activates" the interactivity for the DOM element and its children through the Interactivity API (directives and store). + +```html +<!-- Let's make this element and its children interactive --> +<div + data-wp-interactive + data-wp-context='{ "myColor" : "red", "myBgColor": "yellow" }' +> + <p>I'm interactive now, <span data-wp-style--background-color="context.myBgColor">>and I can use directives!</span></p> + <div> + <p>I'm also interactive, <span data-wp-style--color="context.myColor">and I can also use directives!</span></p> + </div> +</div> +``` + +> **Note** +> The use of `wp-interactive` is a requirement for the Interactivity API "engine" to work. In the following examples the `wp-interactive` has not been added for the sake of simplicity. + + +#### `wp-context` + +It provides **local** state available to a specific HTML node and its children. + +The `wp-context` directive accepts a stringified JSON as value. + +_Example of `wp-context` directive_ +```php +//render.php +<div data-wp-context='{ {"post": { "id": <?php echo $post->ID; ?> } } ' > + <button data-wp-on--click="actions.logId" > + Click Me! + </button> +</div> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + logId: ( { context } ) => { + console.log( context.post.id ); + }, + }, +} ); +``` + +</details> +<br/> + +Different contexts can be defined at different levels and deeper levels will merge their own context with any parent one: + +```html +<div data-wp-context="{ foo: 'bar' }"> + <span data-wp-text="context.foo"><!-- Will output: "bar" --></span> + + <div data-wp-context="{ bar: 'baz' }"> + <span data-wp-text="context.foo"><!-- Will output: "bar" --></span> + + <div data-wp-context="{ foo: 'bob' }"> + <span data-wp-text="context.foo"><!-- Will output: "bob" --></span> + </div> + + </div> +</div> +``` + +#### `wp-bind` + +It allows setting HTML attributes on elements based on a boolean or string value. + +> This directive follows the syntax `data-wp-bind--attribute`. + +_Example of `wp-bind` directive_ +```html +<li data-wp-context='{ "isMenuOpen": false }'> + <button + data-wp-on--click="actions.toggleMenu" + data-wp-bind--aria-expanded="context.isMenuOpen" + > + Toggle + </button> + <div data-wp-bind--hidden="!context.isMenuOpen"> + <span>Title</span> + <ul> + SUBMENU ITEMS + </ul> + </div> +</li> +``` +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + toggleMenu: ( { context } ) => { + context.isMenuOpen = !context.isMenuOpen; + }, + }, +} ); +``` + +</details> +<br/> + +The `wp-bind` directive is executed: + - when the element is created. + - each time there's a change on any of the properties of the `state` or `context` involved on getting the final value of the directive (inside the callback or the expression passed as reference). + +When `wp-bind` directive references a callback to get its final value: +- The `wp-bind` directive will be executed each time there's a change on any of the properties of the `state` or `context` used inside this callback. +- The callback receives the attribute name: `attribute`. +- The returned value in the callback function is used to change the value of the associated attribute. + +The `wp-bind` will do different things over the DOM element is applied depending on its value: + - If the value is `true`, the attribute is added: `<div attribute>`. + - If the value is `false`, the attribute is removed: `<div>`. + - If the value is a string, the attribute is added with its value assigned: `<div attribute="value"`. + - If the attribute name starts with `aria-` or `data-` and the value is boolean (either `true` or `false`), the attribute is added to the DOM with the boolean value assigned as a string: `<div aria-attribute="true">`. + +#### `wp-class` + +It adds or removes a class to an HTML element, depending on a boolean value. + +> This directive follows the syntax `data-wp-class--classname`. + +_Example of `wp-class` directive_ +```php +<div> + <li + data-wp-context='{ "isSelected": false } ' + data-wp-on--click="actions.toggleSelection" + data-wp-class--selected="context.isSelected" + > + Option 1 + </li> + <li + data-wp-context='{ "isSelected": false } ' + data-wp-on--click="actions.toggleSelection" + data-wp-class--selected="context.isSelected" + > + Option 2 + </li> +</div> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + toggleSelection: ( { context } ) => { + context.isSelected = !context.isSelected + } + } +} ); +``` + +</details> +<br/> + +The `wp-class` directive is executed: + - when the element is created. + - each time there's a change on any of the properties of the `state` or `context` involved on getting the final value of the directive (inside the callback or the expression passed as reference). + +When `wp-class` directive references a callback to get its final boolean value, the callback receives the class name: `className`. + +The boolean value received by the directive is used to toggle (add when `true` or remove when `false`) the associated class name from the `class` attribute. + + +#### `wp-style` + +It adds or removes inline style to an HTML element, depending on its value. + +> This directive follows the syntax `data-wp-style--css-property`. + +_Example of `wp-style` directive_ +```html +<div data-wp-context='{ "color": "red" }' > + <button data-wp-on--click="actions.toggleContextColor">Toggle Color Text</button> + <p data-wp-style--color="context.color">Hello World!</p> +</div> +> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + toggleContextColor: ( { context } ) => { + context.color = context.color === 'red' ? 'blue' : 'red'; + }, + }, +} ); +``` + +</details> +<br/> + +The `wp-style` directive is executed: + - when the element is created. + - each time there's a change on any of the properties of the `state` or `context` involved on getting the final value of the directive (inside the callback or the expression passed as reference). + +When `wp-style` directive references a callback to get its final value, the callback receives the class style property: `css-property`. + +The value received by the directive is used to add or remove the style attribute with the associated CSS property: : + - If the value is `false`, the style attribute is removed: `<div>`. + - If the value is a string, the attribute is added with its value assigned: `<div style="css-property: value;">`. + +#### `wp-text` + +It sets the inner text of an HTML element. + +```html +<div data-wp-context='{ "text": "Text 1" }'> + <span data-wp-text="context.text"></span> + <button data-wp-on--click="actions.toggleContextText"> + Toggle Context Text + </button> +</div> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, +} ); +``` + +</details> +<br/> + +The `wp-text` directive is executed: + - when the element is created. + - each time there's a change on any of the properties of the `state` or `context` involved on getting the final value of the directive (inside the callback or the expression passed as reference). + +The returned value is used to change the inner content of the element: `<div>value</div>`. + +#### `wp-on` + +It runs code on dispatched DOM events like `click` or `keyup`. + +> The syntax of this directive is `data-wp-on--[event]` (like `data-wp-on--click` or `data-wp-on--keyup`). + +_Example of `wp-on` directive_ +```php +<button data-wp-on--click="actions.logTime" > + Click Me! +</button> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + logTime: () => console.log( new Date() ), + }, +} ); +``` + +</details> +<br/> + +The `wp-on` directive is executed each time the associated event is triggered. + +The callback passed as reference receives [the event](https://developer.mozilla.org/en-US/docs/Web/API/Event) (`event`) and the returned value by this callback is ignored. + + +#### `wp-effect` + +It runs a callback **when the node is created and runs it again when the state or context changes**. + +You can attach several effects to the same DOM element by using the syntax `data-wp-effect--[unique-id]`. _The unique id doesn't need to be unique globally, it just needs to be different than the other unique ids of the `wp-effect` directives of that DOM element._ + +_Example of `wp-effect` directive_ +```html +<div + data-wp-context='{ "counter": 0 }' + data-wp-effect="effects.logCounter" +> + <p>Counter: <span data-wp-text="context.counter"></span></p> + <button data-wp-on--click="actions.increaseCounter">+</button> + <button data-wp-on--click="actions.decreaseCounter">-</button> +</div> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + actions: { + increaseCounter: ({ context }) => { + context.counter++; + }, + decreaseCounter: ({ context }) => { + context.counter--; + }, + } + effects: { + logCounter: ({ context }) => console.log("Counter is " + context.counter + " at " + new Date() ), + }, +} ); +``` + +</details> +<br/> + +The `wp-effect` directive is executed: + - when the element is created. + - each time that any of the properties of the `state` or `context` used inside the callback changes. + +The `wp-effect` directive can return a function. If it does, the returned function is used as cleanup logic, i.e., it will run just before the callback runs again, and it will run again when the element is removed from the DOM. + +As a reference, some use cases for this directive may be: +- logging. +- changing the title of the page. +- setting the focus on an element with `.focus()`. +- changing the state or context when certain conditions are met. + +#### `wp-init` + +It runs a callback **only when the node is created**. + +You can attach several `wp-init` to the same DOM element by using the syntax `data-wp-init--[unique-id]`. _The unique id doesn't need to be unique globally, it just needs to be different than the other unique ids of the `wp-init` directives of that DOM element._ + +_Example of `data-wp-init` directive_ +```html +<div data-wp-init="effects.logTimeInit"> + <p>Hi!</> +</div> +``` + +_Example of several `wp-init` directives on the same DOM element_ +```html +<form + data-wp-init--log="effects.logTimeInit" + data-wp-init--focus="effects.focusFirstElement" +> + <input type="text"> +</form> +``` + +<details> + <summary><em>See store used with the directive above</em></summary> + +```js +store( { + effects: { + logTimeInit: () => console.log( `Init at ` + new Date() ), + focusFirstElement: ( { ref } ) => + ref.querySelector( 'input:first-child' ).focus(), + }, +} ); +``` + +</details> +<br/> + + +The `wp-init` can return a function. If it does, the returned function will run when the element is removed from the DOM. + +#### `wp-key` + + +The `wp-key` directive assigns a unique key to an element to help the Interactivity API identify it when iterating through arrays of elements. This becomes important if your array elements can move (e.g. due to sorting), get inserted, or get deleted. A well-chosen key value helps the Interactivity API infer what exactly has changed in the array, allowing it to make the correct updates to the DOM. + +The key should be a string that uniquely identifies the element among its siblings. Typically it is used on repeated elements like list items. For example: + +```html +<ul> + <li data-wp-key="unique-id-1">Item 1</li> + <li data-wp-key="unique-id-2">Item 2</li> +</ul> +``` + +But it can also be used on other elements: + +```html +<div> + <a data-wp-key="previous-page" ...>Previous page</a> + <a data-wp-key="next-page" ...>Next page</a> +</div> +``` + +When the list is re-rendered, the Interactivity API will match elements by their keys to determine if an item was added/removed/reordered. Elements without keys might be recreated unnecessarily. + +### Values of directives are references to store properties + +The value assigned to a directive is a string pointing to a specific state, selector, action, or effect. *Using a Namespace is highly recommended* to define these elements of the store. + +In the following example we use the namespace `wpmovies` (plugin name is usually a good namespace name) to define the `isPlaying` selector. + +```js +store( { + selectors: { + wpmovies: { + isPlaying: ( { state } ) => state.wpmovies.currentVideo !== '', + }, + }, +} ); +``` + +And then, we use the string value `"selectors.wpmovies.isPlaying"` to assign the result of this selector to `data-bind--hidden`. + +```php +<div data-bind--hidden="!selectors.wpmovies.isPlaying" ... > + <iframe ...></iframe> +</div> +``` + +These values assigned to directives are **references** to a particular property in the store. They are wired to the directives automatically so that each directive “knows” what store element (action, effect...) refers to without any additional configuration. + + +## The store + +The store is used to create the logic (actions, effects…) linked to the directives and the data used inside that logic (state, selectors…). + +**The store is usually created in the `view.js` file of each block**, although it can be initialized from the `render.php` of the block. + +The store contains the reactive state and the actions and effects that modify it. + +### Elements of the store + +#### State + +Defines data available to the HTML nodes of the page. It is important to differentiate between two ways to define the data: + - **Global state**: It is defined using the `store()` function, and the data is available to all the HTML nodes of the page. It can be accessed using the `state` property. + - **Context/Local State**: It is defined using the `data-wp-context` directive in an HTML node, and the data is available to that HTML node and its children. It can be accessed using the `context` property. + +```html +<div data-wp-context='{ "someText": "Hello World!" }'> + + <!-- Access global state --> + <span data-wp-text="state.someText"></span> + + <!-- Access local state (context) --> + <span data-wp-text="context.someText"></span> + +</div> +``` + +```js +store( { + state: { + someText: "Hello Universe!" + }, + actions: { + someAction: ({ state, context }) => { + state.someText // Access or modify global state - "Hello Universe!" + context.someText // Access or modify local state (context) - "Hello World!" + }, + }, +} ) +``` + +#### Actions + +Usually triggered by the `data-wp-on` directive (using event listeners) or other actions. + +#### Effects + +Automatically react to state changes. Usually triggered by `data-wp-effect` or `data-wp-init` directives. + +#### Selectors + +Also known as _derived state_, returns a computed version of the state. They can access both `state` and `context`. + +```js +// view.js +store( { + state: { + amount: 34, + defaultCurrency: 'EUR', + currencyExchange: { + USD: 1.1, + GBP: 0.85, + }, + }, + selectors: { + amountInUSD: ( { state } ) => + state.currencyExchange[ 'USD' ] * state.amount, + amountInGBP: ( { state } ) => + state.currencyExchange[ 'GBP' ] * state.amount, + }, +} ); +``` + +### Arguments passed to callbacks + +When a directive is evaluated, the reference callback receives an object with: + +- The **`store`** containing all the store properties, like `state`, `selectors`, `actions` or `effects` +- The **context** (an object containing the context defined in all the `wp-context` ancestors). +- The reference to the DOM element on which the directive was defined (a `ref`). +- Other properties relevant to the directive. For example, the `data-wp-on--click` directive also receives the instance of the [MouseEvent](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent) triggered by the user. + +_Example of action making use of all values received when it's triggered_ +```js +// view.js +store( { + state: { + theme: false, + }, + actions: { + toggle: ( { state, context, ref, event, className } ) => { + console.log( state ); + // `{ "theme": false }` + console.log( context ); + // `{ "isOpen": true }` + console.log( ref ); + // The DOM element + console.log( event ); + // The Event object if using the `data-wp-on` + console.log( className ); + // The class name if using the `data-wp-class` + }, + }, +} ); +``` + +This approach enables some functionalities that make directives flexible and powerful: + +- Actions and effects can read and modify the state and the context. +- Actions and state in blocks can be accessed by other blocks. +- Actions and effects can do anything a regular JavaScript function can do, like access the DOM or make API requests. +- Effects automatically react to state changes. + +### Setting the store + +#### On the client side + +*In the `view.js` file of each block* we can define both the state and the elements of the store referencing functions like actions, effects or selectors. + +`store` method used to set the store in javascript can be imported from `@wordpress/interactivity`. + +```js +// store +import { store } from '@wordpress/interactivity'; + +store( { + actions: { + toggle: ( { context } ) => { + context.isOpen = !context.isOpen; + }, + }, + effects: { + logIsOpen: ( { context } ) => { + // Log the value of `isOpen` each time it changes. + console.log( `Is open: ${ context.isOpen }` ); + } + }, +}); +``` + +#### On the server side + +The store can also be initialized on the server using the `wp_store()` function. You would typically do this in the `render.php` file of your block (the `render.php` templates were [introduced](https://make.wordpress.org/core/2022/10/12/block-api-changes-in-wordpress-6-1/) in WordPress 6.1). + +The store defined on the server with `wp_store()` gets merged with the stores defined in the view.js files. + +The `wp_store` function receives an [associative array](https://www.php.net/manual/en/language.types.array.php) as a parameter. + + +_Example of store initialized from the server with a `state` = `{ someValue: 123 }`_ + +```php +// render.php +wp_store( array( + 'state' => array( + 'myNamespace' => array( + 'someValue' = 123 + ) + ) +); +``` + +Initializing the store in the server also allows you to use any WordPress API. For example, you could use the Core Translation API to translate part of your state: + +```php +// render.php +wp_store( + array( + "state" => array( + "favoriteMovies" => array( + "1" => array( + "id" => "123-abc", + "movieName" => __("someMovieName", "textdomain") + ), + ), + ), + ) +); +``` + +### Store options + +The `store` function accepts an object as a second argument with the following optional properties: + +#### `afterLoad` + +Callback to be executed after the Interactivity API has been set up and the store is ready. It receives the global store as argument. + +```js +// view.js +store( + { + state: { + cart: [], + }, + }, + { + afterLoad: async ( { state } ) => { + // Let's consider `clientId` is added + // during server-side rendering. + state.cart = await getCartData( state.clientId ); + }, + } +); +``` + + + diff --git a/packages/interactivity/docs/README.md b/packages/interactivity/docs/README.md new file mode 100644 index 00000000000000..ea8bcbc36d5636 --- /dev/null +++ b/packages/interactivity/docs/README.md @@ -0,0 +1,33 @@ +# Interactivity API Docs + +👋 Hi! Welcome to the Interactivity API documentation. + + +> Interactivity API is a current [proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) that **is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. + +> As part of an [Open Source project](https://developer.wordpress.org/block-editor/explanations/faq/#the-gutenberg-project) we encourage participation in helping shape this API and its Docs. The [discussions](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) and [issues](https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API) in GitHub are the best place to engage. + + +## Quick start + +The best place to start with the Interactivity API is this [**Getting started guide**](1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API. + +## Take a deep dive + +At the [**API Reference**](2-api-reference.md) page you'll find detailed technical descriptions for the *Directives* and *Store* which are the main elements of the Interactivity API. + +You can also check [Getting Started - and other learning resources](https://github.com/WordPress/gutenberg/discussions/52894) among other [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) to learn more on this proposal. + +## Get Involved + +Feel free to open pull requests to improve any part of these docs, or to add other sections or files to the docs. + +If you are willing to help with the documentation, please add a comment to [#51928](https://github.com/WordPress/gutenberg/discussions/51928), and we'll coordinate everyone's efforts. + +There's a Tracking Issue opened to ease the coordination of the work related to the Interactivity API Docs: **[Documentation for the Interactivity API - Tracking Issue #53296](https://github.com/WordPress/gutenberg/issues/53296)** + +## License + +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. + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/interactivity/docs/assets/state-directives.png b/packages/interactivity/docs/assets/state-directives.png new file mode 100644 index 00000000000000..feb93a2d1f8956 Binary files /dev/null and b/packages/interactivity/docs/assets/state-directives.png differ diff --git a/packages/interactivity/docs/assets/store-server-client.png b/packages/interactivity/docs/assets/store-server-client.png new file mode 100644 index 00000000000000..089268cdc7d9c7 Binary files /dev/null and b/packages/interactivity/docs/assets/store-server-client.png differ diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json new file mode 100644 index 00000000000000..4903126004fc74 --- /dev/null +++ b/packages/interactivity/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/interactivity", + "version": "2.1.0", + "description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "interactivity" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/interactivity/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/interactivity" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@preact/signals": "^1.1.3", + "deepsignal": "^1.3.6", + "preact": "^10.13.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/interactivity/src/constants.js b/packages/interactivity/src/constants.js new file mode 100644 index 00000000000000..669e94263fb9ca --- /dev/null +++ b/packages/interactivity/src/constants.js @@ -0,0 +1 @@ +export const directivePrefix = 'wp'; diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js new file mode 100644 index 00000000000000..f0a3d7e32e09e1 --- /dev/null +++ b/packages/interactivity/src/directives.js @@ -0,0 +1,377 @@ +/** + * External dependencies + */ +import { useContext, useMemo, useEffect, useRef } from 'preact/hooks'; +import { deepSignal, peek } from 'deepsignal'; + +/** + * Internal dependencies + */ +import { createPortal } from './portals'; +import { useSignalEffect } from './utils'; +import { directive } from './hooks'; +import { SlotProvider, Slot, Fill } from './slots'; + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const mergeDeepSignals = ( target, source, overwrite ) => { + for ( const k in source ) { + if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { + mergeDeepSignals( + target[ `$${ k }` ].peek(), + source[ `$${ k }` ].peek(), + overwrite + ); + } else if ( overwrite || typeof peek( target, k ) === 'undefined' ) { + target[ `$${ k }` ] = source[ `$${ k }` ]; + } + } +}; + +export default () => { + // data-wp-context + directive( + 'context', + ( { + directives: { + context: { default: newContext }, + }, + props: { children }, + context: inheritedContext, + } ) => { + const { Provider } = inheritedContext; + const inheritedValue = useContext( inheritedContext ); + const currentValue = useRef( deepSignal( {} ) ); + currentValue.current = useMemo( () => { + const newValue = deepSignal( newContext ); + mergeDeepSignals( newValue, inheritedValue ); + mergeDeepSignals( currentValue.current, newValue, true ); + return currentValue.current; + }, [ newContext, inheritedValue ] ); + + return ( + <Provider value={ currentValue.current }>{ children }</Provider> + ); + }, + { priority: 5 } + ); + + // data-wp-body + directive( 'body', ( { props: { children } } ) => { + return createPortal( children, document.body ); + } ); + + // data-wp-effect--[name] + directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( effect ).forEach( ( path ) => { + useSignalEffect( () => { + return evaluate( path, { context: contextValue } ); + } ); + } ); + } ); + + // data-wp-init--[name] + directive( 'init', ( { directives: { init }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( init ).forEach( ( path ) => { + useEffect( () => { + return evaluate( path, { context: contextValue } ); + }, [] ); + } ); + } ); + + // data-wp-on--[event] + directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { + const contextValue = useContext( context ); + Object.entries( on ).forEach( ( [ name, path ] ) => { + element.props[ `on${ name }` ] = ( event ) => { + evaluate( path, { event, context: contextValue } ); + }; + } ); + } ); + + // data-wp-class--[classname] + directive( + 'class', + ( { + directives: { class: className }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + Object.keys( className ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( name ) => { + const result = evaluate( className[ name ], { + className: name, + context: contextValue, + } ); + const currentClass = element.props.class || ''; + const classFinder = new RegExp( + `(^|\\s)${ name }(\\s|$)`, + 'g' + ); + if ( ! result ) + element.props.class = currentClass + .replace( classFinder, ' ' ) + .trim(); + else if ( ! classFinder.test( currentClass ) ) + element.props.class = currentClass + ? `${ currentClass } ${ name }` + : name; + + useEffect( () => { + // This seems necessary because Preact doesn't change the class + // names on the hydration, so we have to do it manually. It doesn't + // need deps because it only needs to do it the first time. + if ( ! result ) { + element.ref.current.classList.remove( name ); + } else { + element.ref.current.classList.add( name ); + } + }, [] ); + } ); + } + ); + + const newRule = + /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; + const ruleClean = /\/\*[^]*?\*\/| +/g; + const ruleNewline = /\n+/g; + const empty = ' '; + + /** + * Convert a css style string into a object. + * + * Made by Cristian Bote (@cristianbote) for Goober. + * https://unpkg.com/browse/goober@2.1.13/src/core/astish.js + * + * @param {string} val CSS string. + * @return {Object} CSS object. + */ + const cssStringToObject = ( val ) => { + const tree = [ {} ]; + let block, left; + + while ( ( block = newRule.exec( val.replace( ruleClean, '' ) ) ) ) { + if ( block[ 4 ] ) { + tree.shift(); + } else if ( block[ 3 ] ) { + left = block[ 3 ].replace( ruleNewline, empty ).trim(); + tree.unshift( ( tree[ 0 ][ left ] = tree[ 0 ][ left ] || {} ) ); + } else { + tree[ 0 ][ block[ 1 ] ] = block[ 2 ] + .replace( ruleNewline, empty ) + .trim(); + } + } + + return tree[ 0 ]; + }; + + // data-wp-style--[style-key] + directive( + 'style', + ( { directives: { style }, element, evaluate, context } ) => { + const contextValue = useContext( context ); + Object.keys( style ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( key ) => { + const result = evaluate( style[ key ], { + key, + context: contextValue, + } ); + element.props.style = element.props.style || {}; + if ( typeof element.props.style === 'string' ) + element.props.style = cssStringToObject( + element.props.style + ); + if ( ! result ) delete element.props.style[ key ]; + else element.props.style[ key ] = result; + + useEffect( () => { + // This seems necessary because Preact doesn't change the styles on + // the hydration, so we have to do it manually. It doesn't need deps + // because it only needs to do it the first time. + if ( ! result ) { + element.ref.current.style.removeProperty( key ); + } else { + element.ref.current.style[ key ] = result; + } + }, [] ); + } ); + } + ); + + // data-wp-bind--[attribute] + directive( + 'bind', + ( { directives: { bind }, element, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.entries( bind ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( [ attribute, path ] ) => { + const result = evaluate( path, { + context: contextValue, + } ); + element.props[ attribute ] = result; + + // This seems necessary because Preact doesn't change the attributes + // on the hydration, so we have to do it manually. It doesn't need + // deps because it only needs to do it the first time. + useEffect( () => { + const el = element.ref.current; + + // We set the value directly to the corresponding + // HTMLElement instance property excluding the following + // special cases. + // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 + if ( + attribute !== 'width' && + attribute !== 'height' && + attribute !== 'href' && + attribute !== 'list' && + attribute !== 'form' && + // Default value in browsers is `-1` and an empty string is + // cast to `0` instead + attribute !== 'tabIndex' && + attribute !== 'download' && + attribute !== 'rowSpan' && + attribute !== 'colSpan' && + attribute in el + ) { + try { + el[ attribute ] = + result === null || result === undefined + ? '' + : result; + return; + } catch ( err ) {} + } + // aria- and data- attributes have no boolean representation. + // A `false` value is different from the attribute not being + // present, so we can't remove it. + // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + if ( + result !== null && + result !== undefined && + ( result !== false || attribute[ 4 ] === '-' ) + ) { + el.setAttribute( attribute, result ); + } else { + el.removeAttribute( attribute ); + } + }, [] ); + } ); + } + ); + + // data-wp-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + <Type + dangerouslySetInnerHTML={ { __html: cached } } + { ...rest } + /> + ); + } + ); + + // data-wp-text + directive( + 'text', + ( { + directives: { + text: { default: text }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + element.props.children = evaluate( text, { + context: contextValue, + } ); + } + ); + + // data-wp-slot + directive( + 'slot', + ( { + directives: { + slot: { default: slot }, + }, + props: { children }, + element, + } ) => { + const name = typeof slot === 'string' ? slot : slot.name; + const position = slot.position || 'children'; + + if ( position === 'before' ) { + return ( + <> + <Slot name={ name } /> + { children } + </> + ); + } + if ( position === 'after' ) { + return ( + <> + { children } + <Slot name={ name } /> + </> + ); + } + if ( position === 'replace' ) { + return <Slot name={ name }>{ children }</Slot>; + } + if ( position === 'children' ) { + element.props.children = ( + <Slot name={ name }>{ element.props.children }</Slot> + ); + } + }, + { priority: 4 } + ); + + // data-wp-fill + directive( + 'fill', + ( { + directives: { + fill: { default: fill }, + }, + props: { children }, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + const slot = evaluate( fill, { context: contextValue } ); + return <Fill slot={ slot }>{ children }</Fill>; + }, + { priority: 4 } + ); + + // data-wp-slot-provider + directive( + 'slot-provider', + ( { props: { children } } ) => ( + <SlotProvider>{ children }</SlotProvider> + ), + { priority: 4 } + ); +}; diff --git a/packages/interactivity/src/hooks.js b/packages/interactivity/src/hooks.js new file mode 100644 index 00000000000000..d5b019300fed1a --- /dev/null +++ b/packages/interactivity/src/hooks.js @@ -0,0 +1,225 @@ +/** + * External dependencies + */ +import { h, options, createContext, cloneElement } from 'preact'; +import { useRef, useCallback } from 'preact/hooks'; +/** + * Internal dependencies + */ +import { rawStore as store } from './store'; + +/** @typedef {import('preact').VNode} VNode */ +/** @typedef {typeof context} Context */ +/** @typedef {ReturnType<typeof getEvaluate>} Evaluate */ + +/** + * @typedef {Object} DirectiveCallbackParams Callback parameters. + * @property {Object} directives Object map with the defined directives of the element being evaluated. + * @property {Object} props Props present in the current element. + * @property {VNode} element Virtual node representing the original element. + * @property {Context} context The inherited context. + * @property {Evaluate} evaluate Function that resolves a given path to a value either in the store or the context. + */ + +/** + * @callback DirectiveCallback Callback that runs the directive logic. + * @param {DirectiveCallbackParams} params Callback parameters. + */ + +/** + * @typedef DirectiveOptions Options object. + * @property {number} [priority=10] Value that specifies the priority to + * evaluate directives of this type. Lower + * numbers correspond with earlier execution. + * Default is `10`. + */ + +// Main context. +const context = createContext( {} ); + +// WordPress Directives. +const directiveCallbacks = {}; +const directivePriorities = {}; + +/** + * Register a new directive type in the Interactivity API runtime. + * + * @example + * ```js + * directive( + * 'alert', // Name without the `data-wp-` prefix. + * ( { directives: { alert }, element, evaluate }) => { + * element.props.onclick = () => { + * alert( evaluate( alert.default ) ); + * } + * } + * ) + * ``` + * + * The previous code registers a custom directive type for displaying an alert + * message whenever an element using it is clicked. The message text is obtained + * from the store using `evaluate`. + * + * When the HTML is processed by the Interactivity API, any element containing + * the `data-wp-alert` directive will have the `onclick` event handler, e.g., + * + * ```html + * <button data-wp-alert="state.messages.alert">Click me!</button> + * ``` + * Note that, in the previous example, you access `alert.default` in order to + * retrieve the `state.messages.alert` value passed to the directive. You can + * also define custom names by appending `--` to the directive attribute, + * followed by a suffix, like in the following HTML snippet: + * + * ```html + * <button + * data-wp-color--text="state.theme.text" + * data-wp-color--background="state.theme.background" + * >Click me!</button> + * ``` + * + * This could be an hypothetical implementation of the custom directive used in + * the snippet above. + * + * @example + * ```js + * directive( + * 'color', // Name without prefix and suffix. + * ( { directives: { color }, ref, evaluate }) => { + * if ( color.text ) { + * ref.style.setProperty( + * 'color', + * evaluate( color.text ) + * ); + * } + * if ( color.background ) { + * ref.style.setProperty( + * 'background-color', + * evaluate( color.background ) + * ); + * } + * } + * ) + * ``` + * + * @param {string} name Directive name, without the `data-wp-` prefix. + * @param {DirectiveCallback} callback Function that runs the directive logic. + * @param {DirectiveOptions=} options Options object. + */ +export const directive = ( name, callback, { priority = 10 } = {} ) => { + directiveCallbacks[ name ] = callback; + directivePriorities[ name ] = priority; +}; + +// Resolve the path to some property of the store object. +const resolve = ( path, ctx ) => { + let current = { ...store, context: ctx }; + path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); + return current; +}; + +// Generate the evaluate function. +const getEvaluate = + ( { ref } = {} ) => + ( path, extraArgs = {} ) => { + // If path starts with !, remove it and save a flag. + const hasNegationOperator = + path[ 0 ] === '!' && !! ( path = path.slice( 1 ) ); + const value = resolve( path, extraArgs.context ); + const returnValue = + typeof value === 'function' + ? value( { + ref: ref.current, + ...store, + ...extraArgs, + } ) + : value; + return hasNegationOperator ? ! returnValue : returnValue; + }; + +// Separate directives by priority. The resulting array contains objects +// of directives grouped by same priority, and sorted in ascending order. +const getPriorityLevels = ( directives ) => { + const byPriority = Object.keys( directives ).reduce( ( obj, name ) => { + if ( directiveCallbacks[ name ] ) { + const priority = directivePriorities[ name ]; + ( obj[ priority ] = obj[ priority ] || [] ).push( name ); + } + return obj; + }, {} ); + + return Object.entries( byPriority ) + .sort( ( [ p1 ], [ p2 ] ) => p1 - p2 ) + .map( ( [ , arr ] ) => arr ); +}; + +// Priority level wrapper. +const Directives = ( { + directives, + priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ], + element, + evaluate, + originalProps, + elemRef, +} ) => { + // Initialize the DOM reference. + // eslint-disable-next-line react-hooks/rules-of-hooks + elemRef = elemRef || useRef( null ); + + // Create a reference to the evaluate function using the DOM reference. + // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps + evaluate = evaluate || useCallback( getEvaluate( { ref: elemRef } ), [] ); + + // Create a fresh copy of the vnode element. + element = cloneElement( element, { ref: elemRef } ); + + // Recursively render the wrapper for the next priority level. + const children = + nextPriorityLevels.length > 0 ? ( + <Directives + directives={ directives } + priorityLevels={ nextPriorityLevels } + element={ element } + evaluate={ evaluate } + originalProps={ originalProps } + elemRef={ elemRef } + /> + ) : ( + element + ); + + const props = { ...originalProps, children }; + const directiveArgs = { directives, props, element, context, evaluate }; + + for ( const directiveName of currentPriorityLevel ) { + const wrapper = directiveCallbacks[ directiveName ]?.( directiveArgs ); + if ( wrapper !== undefined ) props.children = wrapper; + } + + return props.children; +}; + +// Preact Options Hook called each time a vnode is created. +const old = options.vnode; +options.vnode = ( vnode ) => { + if ( vnode.props.__directives ) { + const props = vnode.props; + const directives = props.__directives; + if ( directives.key ) vnode.key = directives.key.default; + delete props.__directives; + const priorityLevels = getPriorityLevels( directives ); + if ( priorityLevels.length > 0 ) { + vnode.props = { + directives, + priorityLevels, + originalProps: props, + type: vnode.type, + element: h( vnode.type, props ), + top: true, + }; + vnode.type = Directives; + } + } + + if ( old ) old( vnode ); +}; diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js new file mode 100644 index 00000000000000..88e81e6f5877c0 --- /dev/null +++ b/packages/interactivity/src/index.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import registerDirectives from './directives'; +import { init } from './router'; +import { rawStore, afterLoads } from './store'; +export { store } from './store'; +export { directive } from './hooks'; +export { navigate, prefetch } from './router'; +export { h as createElement } from 'preact'; +export { useEffect, useContext, useMemo } from 'preact/hooks'; +export { deepSignal } from 'deepsignal'; + +document.addEventListener( 'DOMContentLoaded', async () => { + registerDirectives(); + await init(); + afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) ); +} ); diff --git a/packages/block-library/src/utils/interactivity/portals.js b/packages/interactivity/src/portals.js similarity index 100% rename from packages/block-library/src/utils/interactivity/portals.js rename to packages/interactivity/src/portals.js diff --git a/packages/interactivity/src/router.js b/packages/interactivity/src/router.js new file mode 100644 index 00000000000000..cc7925e2fc3981 --- /dev/null +++ b/packages/interactivity/src/router.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { hydrate, render } from 'preact'; +/** + * Internal dependencies + */ +import { toVdom, hydratedIslands } from './vdom'; +import { createRootFragment } from './utils'; +import { directivePrefix } from './constants'; + +// The cache of visited and prefetched pages. +const pages = new Map(); + +// Keep the same root fragment for each interactive region node. +const regionRootFragments = new WeakMap(); +const getRegionRootFragment = ( region ) => { + if ( ! regionRootFragments.has( region ) ) { + regionRootFragments.set( + region, + createRootFragment( region.parentElement, region ) + ); + } + return regionRootFragments.get( region ); +}; + +// Helper to remove domain and hash from the URL. We are only interesting in +// caching the path and the query. +const cleanUrl = ( url ) => { + const u = new URL( url, window.location ); + return u.pathname + u.search; +}; + +// Fetch a new page and convert it to a static virtual DOM. +const fetchPage = async ( url, { html } ) => { + try { + if ( ! html ) { + const res = await window.fetch( url ); + if ( res.status !== 200 ) return false; + html = await res.text(); + } + const dom = new window.DOMParser().parseFromString( html, 'text/html' ); + return regionsToVdom( dom ); + } catch ( e ) { + return false; + } +}; + +// Return an object with VDOM trees of those HTML regions marked with a +// `navigation-id` directive. +const regionsToVdom = ( dom ) => { + const regions = {}; + const attrName = `data-${ directivePrefix }-navigation-id`; + dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => { + const id = region.getAttribute( attrName ); + regions[ id ] = toVdom( region ); + } ); + + return { regions }; +}; + +// Prefetch a page. We store the promise to avoid triggering a second fetch for +// a page if a fetching has already started. +export const prefetch = ( url, options = {} ) => { + url = cleanUrl( url ); + if ( options.force || ! pages.has( url ) ) { + pages.set( url, fetchPage( url, options ) ); + } +}; + +// Render all interactive regions contained in the given page. +const renderRegions = ( page ) => { + const attrName = `data-${ directivePrefix }-navigation-id`; + document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => { + const id = region.getAttribute( attrName ); + const fragment = getRegionRootFragment( region ); + render( page.regions[ id ], fragment ); + } ); +}; + +// Navigate to a new page. +export const navigate = async ( href, options = {} ) => { + const url = cleanUrl( href ); + prefetch( url, options ); + const page = await pages.get( url ); + if ( page ) { + renderRegions( page ); + window.history[ options.replace ? 'replaceState' : 'pushState' ]( + {}, + '', + href + ); + } else { + window.location.assign( href ); + } +}; + +// Listen to the back and forward buttons and restore the page if it's in the +// cache. +window.addEventListener( 'popstate', async () => { + const url = cleanUrl( window.location ); // Remove hash. + const page = pages.has( url ) && ( await pages.get( url ) ); + if ( page ) { + renderRegions( page ); + } else { + window.location.reload(); + } +} ); + +// Initialize the router with the initial DOM. +export const init = async () => { + document + .querySelectorAll( `[data-${ directivePrefix }-interactive]` ) + .forEach( ( node ) => { + if ( ! hydratedIslands.has( node ) ) { + const fragment = getRegionRootFragment( node ); + const vdom = toVdom( node ); + hydrate( vdom, fragment ); + } + } ); + + // Cache the current regions. + pages.set( + cleanUrl( window.location ), + Promise.resolve( regionsToVdom( document ) ) + ); +}; diff --git a/packages/interactivity/src/slots.js b/packages/interactivity/src/slots.js new file mode 100644 index 00000000000000..e8bc6ddfa368f5 --- /dev/null +++ b/packages/interactivity/src/slots.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { createContext } from 'preact'; +import { useContext, useEffect } from 'preact/hooks'; +import { signal } from '@preact/signals'; + +const slotsContext = createContext(); + +export const Fill = ( { slot, children } ) => { + const slots = useContext( slotsContext ); + + useEffect( () => { + if ( slot ) { + slots.value = { ...slots.value, [ slot ]: children }; + return () => { + slots.value = { ...slots.value, [ slot ]: null }; + }; + } + }, [ slots, slot, children ] ); + + return !! slot ? null : children; +}; + +export const SlotProvider = ( { children } ) => { + return ( + // TODO: We can change this to use deepsignal once this PR is merged. + // https://github.com/luisherranz/deepsignal/pull/38 + <slotsContext.Provider value={ signal( {} ) }> + { children } + </slotsContext.Provider> + ); +}; + +export const Slot = ( { name, children } ) => { + const slots = useContext( slotsContext ); + return slots.value[ name ] || children; +}; diff --git a/packages/interactivity/src/store.js b/packages/interactivity/src/store.js new file mode 100644 index 00000000000000..e0c5f8b3fae777 --- /dev/null +++ b/packages/interactivity/src/store.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import { deepSignal } from 'deepsignal'; + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const deepMerge = ( target, source ) => { + if ( isObject( target ) && isObject( source ) ) { + for ( const key in source ) { + if ( isObject( source[ key ] ) ) { + if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); + deepMerge( target[ key ], source[ key ] ); + } else { + Object.assign( target, { [ key ]: source[ key ] } ); + } + } + } +}; + +const getSerializedState = () => { + const storeTag = document.querySelector( + `script[type="application/json"]#wp-interactivity-store-data` + ); + if ( ! storeTag ) return {}; + try { + const { state } = JSON.parse( storeTag.textContent ); + if ( isObject( state ) ) return state; + throw Error( 'Parsed state is not an object' ); + } catch ( e ) { + // eslint-disable-next-line no-console + console.log( e ); + } + return {}; +}; + +export const afterLoads = new Set(); + +const rawState = getSerializedState(); +export const rawStore = { state: deepSignal( rawState ) }; + +/** + * @typedef StoreProps Properties object passed to `store`. + * @property {Object} state State to be added to the global store. All the + * properties included here become reactive. + */ + +/** + * @typedef StoreOptions Options object. + * @property {(store:any) => void} [afterLoad] Callback to be executed after the + * Interactivity API has been set up + * and the store is ready. It + * receives the store as argument. + */ + +/** + * Extends the Interactivity API global store with the passed properties. + * + * These props typically consist of `state`, which is reactive, and other + * properties like `selectors`, `actions`, `effects`, etc. which can store + * callbacks and derived state. These props can then be referenced by any + * directive to make the HTML interactive. + * + * @example + * ```js + * store({ + * state: { + * counter: { value: 0 }, + * }, + * actions: { + * counter: { + * increment: ({ state }) => { + * state.counter.value += 1; + * }, + * }, + * }, + * }); + * ``` + * + * The code from the example above allows blocks to subscribe and interact with + * the store by using directives in the HTML, e.g.: + * + * ```html + * <div data-wp-interactive> + * <button + * data-wp-text="state.counter.value" + * data-wp-on--click="actions.counter.increment" + * > + * 0 + * </button> + * </div> + * ``` + * + * @param {StoreProps} properties Properties to be added to the global store. + * @param {StoreOptions} [options] Options passed to the `store` call. + */ +export const store = ( { state, ...block }, { afterLoad } = {} ) => { + deepMerge( rawStore, block ); + deepMerge( rawState, state ); + if ( afterLoad ) afterLoads.add( afterLoad ); +}; diff --git a/packages/interactivity/src/utils.js b/packages/interactivity/src/utils.js new file mode 100644 index 00000000000000..10b53104fb9c89 --- /dev/null +++ b/packages/interactivity/src/utils.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { useEffect } from 'preact/hooks'; +import { effect } from '@preact/signals'; + +const afterNextFrame = ( callback ) => { + return new Promise( ( resolve ) => { + const done = () => { + clearTimeout( timeout ); + window.cancelAnimationFrame( raf ); + setTimeout( () => { + callback(); + resolve(); + } ); + }; + const timeout = setTimeout( done, 100 ); + const raf = window.requestAnimationFrame( done ); + } ); +}; + +// Using the mangled properties: +// this.c: this._callback +// this.x: this._compute +// https://github.com/preactjs/signals/blob/main/mangle.json +function createFlusher( compute, notify ) { + let flush; + const dispose = effect( function () { + flush = this.c.bind( this ); + this.x = compute; + this.c = notify; + return compute(); + } ); + return { flush, dispose }; +} + +// Version of `useSignalEffect` with a `useEffect`-like execution. This hook +// implementation comes from this PR, but we added short-cirtuiting to avoid +// infinite loops: https://github.com/preactjs/signals/pull/290 +export function useSignalEffect( callback ) { + useEffect( () => { + let eff = null; + let isExecuting = false; + const notify = async () => { + if ( eff && ! isExecuting ) { + isExecuting = true; + await afterNextFrame( eff.flush ); + isExecuting = false; + } + }; + eff = createFlusher( callback, notify ); + return eff.dispose; + }, [] ); +} + +// For wrapperless hydration. +// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c +export const createRootFragment = ( parent, replaceNode ) => { + replaceNode = [].concat( replaceNode ); + const s = replaceNode[ replaceNode.length - 1 ].nextSibling; + function insert( c, r ) { + parent.insertBefore( c, r || s ); + } + return ( parent.__k = { + nodeType: 1, + parentNode: parent, + firstChild: replaceNode[ 0 ], + childNodes: replaceNode, + insertBefore: insert, + appendChild: insert, + removeChild( c ) { + parent.removeChild( c ); + }, + } ); +}; diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js new file mode 100644 index 00000000000000..1cf4a91ec1ead5 --- /dev/null +++ b/packages/interactivity/src/vdom.js @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import { h } from 'preact'; +/** + * Internal dependencies + */ +import { directivePrefix as p } from './constants'; + +const ignoreAttr = `data-${ p }-ignore`; +const islandAttr = `data-${ p }-interactive`; +const fullPrefix = `data-${ p }-`; + +// Regular expression for directive parsing. +const directiveParser = new RegExp( + `^data-${ p }-` + // ${p} must be a prefix string, like 'wp'. + // Match alphanumeric characters including hyphen-separated + // segments. It excludes underscore intentionally to prevent confusion. + // E.g., "custom-directive". + '([a-z0-9]+(?:-[a-z0-9]+)*)' + + // (Optional) Match '--' followed by any alphanumeric charachters. It + // excludes underscore intentionally to prevent confusion, but it can + // contain multiple hyphens. E.g., "--custom-prefix--with-more-info". + '(?:--([a-z0-9_-]+))?$', + 'i' // Case insensitive. +); + +export const hydratedIslands = new WeakSet(); + +// Recursive function that transforms a DOM tree into vDOM. +export function toVdom( root ) { + const treeWalker = document.createTreeWalker( + root, + 205 // ELEMENT + TEXT + COMMENT + CDATA_SECTION + PROCESSING_INSTRUCTION + ); + + function walk( node ) { + const { attributes, nodeType } = node; + + if ( nodeType === 3 ) return [ node.data ]; + if ( nodeType === 4 ) { + const next = treeWalker.nextSibling(); + node.replaceWith( new window.Text( node.nodeValue ) ); + return [ node.nodeValue, next ]; + } + if ( nodeType === 8 || nodeType === 7 ) { + const next = treeWalker.nextSibling(); + node.remove(); + return [ null, next ]; + } + + const props = {}; + const children = []; + const directives = {}; + let hasDirectives = false; + let ignore = false; + let island = false; + + for ( let i = 0; i < attributes.length; i++ ) { + const n = attributes[ i ].name; + if ( + n[ fullPrefix.length ] && + n.slice( 0, fullPrefix.length ) === fullPrefix + ) { + if ( n === ignoreAttr ) { + ignore = true; + } else if ( n === islandAttr ) { + island = true; + } else { + hasDirectives = true; + let val = attributes[ i ].value; + try { + val = JSON.parse( val ); + } catch ( e ) {} + const [ , prefix, suffix ] = directiveParser.exec( n ); + directives[ prefix ] = directives[ prefix ] || {}; + directives[ prefix ][ suffix || 'default' ] = val; + } + } else if ( n === 'ref' ) { + continue; + } + props[ n ] = attributes[ i ].value; + } + + if ( ignore && ! island ) + return [ + h( node.localName, { + ...props, + innerHTML: node.innerHTML, + __directives: { ignore: true }, + } ), + ]; + if ( island ) hydratedIslands.add( node ); + + if ( hasDirectives ) props.__directives = directives; + + let child = treeWalker.firstChild(); + if ( child ) { + while ( child ) { + const [ vnode, nextChild ] = walk( child ); + if ( vnode ) children.push( vnode ); + child = nextChild || treeWalker.nextSibling(); + } + treeWalker.parentNode(); + } + + return [ h( node.localName, props, children ) ]; + } + + return walk( treeWalker.currentNode ); +} diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md index 871398a821d553..d378dfa6c7ca81 100644 --- a/packages/interface/CHANGELOG.md +++ b/packages/interface/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 5.17.0 (2023-08-16) + +## 5.16.0 (2023-08-10) + +## 5.15.0 (2023-07-20) + +## 5.14.0 (2023-07-05) + +## 5.13.0 (2023-06-23) + +## 5.12.0 (2023-06-07) + ## 5.11.0 (2023-05-24) ## 5.10.0 (2023-05-10) diff --git a/packages/interface/package.json b/packages/interface/package.json index 9e126360cb24ff..55de7645dff678 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interface", - "version": "5.11.0", + "version": "5.17.0", "description": "Interface module for WordPress. The package contains shared functionality across the modern JavaScript-based WordPress screens.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interface/src/components/complementary-area-toggle/index.js b/packages/interface/src/components/complementary-area-toggle/index.js index aa77825d4a4f85..1abdeb2c84f260 100644 --- a/packages/interface/src/components/complementary-area-toggle/index.js +++ b/packages/interface/src/components/complementary-area-toggle/index.js @@ -24,7 +24,7 @@ function ComplementaryAreaToggle( { ( select ) => select( interfaceStore ).getActiveComplementaryArea( scope ) === identifier, - [ identifier ] + [ identifier, scope ] ); const { enableComplementaryArea, disableComplementaryArea } = useDispatch( interfaceStore ); diff --git a/packages/interface/src/components/complementary-area/README.md b/packages/interface/src/components/complementary-area/README.md index 5d1780e2b29849..0013f0b9d4dcc4 100644 --- a/packages/interface/src/components/complementary-area/README.md +++ b/packages/interface/src/components/complementary-area/README.md @@ -5,7 +5,7 @@ Multiple areas may be added in a given time, but only one is visible; the compon The contents passed to ComplementaryArea are rendered in the ComplementaryArea.Slot corresponding to their scope if the complementary area is enabled. -Besides rendering the complimentary area, the component renders a button in `PinnedItems` that allows opening the complementary area. The button only appears if the complementary is marked as favorite. By default, the complementary area headers rendered contain a button to mark and unmark areas as favorites. +Besides rendering the complementary area, the component renders a button in `PinnedItems` that allows opening the complementary area. The button only appears if the complementary is marked as favorite. By default, the complementary area headers rendered contain a button to mark and unmark areas as favorites. ## Props diff --git a/packages/interface/src/components/complementary-area/index.js b/packages/interface/src/components/complementary-area/index.js index 5617c39da301bd..de69762b6a15c4 100644 --- a/packages/interface/src/components/complementary-area/index.js +++ b/packages/interface/src/components/complementary-area/index.js @@ -79,7 +79,15 @@ function useAdjustComplementaryListener( if ( isSmall !== previousIsSmall.current ) { previousIsSmall.current = isSmall; } - }, [ isActive, isSmall, scope, identifier, activeArea ] ); + }, [ + isActive, + isSmall, + scope, + identifier, + activeArea, + disableComplementaryArea, + enableComplementaryArea, + ] ); } function ComplementaryArea( { @@ -145,7 +153,15 @@ function ComplementaryArea( { } else if ( activeArea === undefined && isSmall ) { disableComplementaryArea( scope, identifier ); } - }, [ activeArea, isActiveByDefault, scope, identifier, isSmall ] ); + }, [ + activeArea, + isActiveByDefault, + scope, + identifier, + isSmall, + enableComplementaryArea, + disableComplementaryArea, + ] ); return ( <> diff --git a/packages/interface/src/components/interface-skeleton/index.js b/packages/interface/src/components/interface-skeleton/index.js index fe329a75d43e07..58684ebaddd7e8 100644 --- a/packages/interface/src/components/interface-skeleton/index.js +++ b/packages/interface/src/components/interface-skeleton/index.js @@ -33,6 +33,15 @@ function useHTMLClass( className ) { }, [ className ] ); } +const headerVariants = { + hidden: { opacity: 0 }, + hover: { + opacity: 1, + transition: { type: 'tween', delay: 0.2, delayChildren: 0.2 }, + }, + distractionFreeInactive: { opacity: 1, transition: { delay: 0 } }, +}; + function InterfaceSkeleton( { isDistractionFree, @@ -43,6 +52,7 @@ function InterfaceSkeleton( secondarySidebar, notices, content, + contentProps, actions, labels, className, @@ -74,14 +84,6 @@ function InterfaceSkeleton( const mergedLabels = { ...defaultLabels, ...labels }; - const headerVariants = { - hidden: isDistractionFree ? { opacity: 0 } : { opacity: 1 }, - hover: { - opacity: 1, - transition: { type: 'tween', delay: 0.2, delayChildren: 0.2 }, - }, - }; - return ( <div { ...( enableRegionNavigation ? navigateRegionsProps : {} ) } @@ -97,23 +99,32 @@ function InterfaceSkeleton( ) } > <div className="interface-interface-skeleton__editor"> - { !! header && isDistractionFree && ( + { !! header && ( <NavigableRegion as={ motion.div } className="interface-interface-skeleton__header" aria-label={ mergedLabels.header } - initial={ isDistractionFree ? 'hidden' : 'hover' } - whileHover="hover" + initial={ + isDistractionFree + ? 'hidden' + : 'distractionFreeInactive' + } + whileHover={ + isDistractionFree + ? 'hover' + : 'distractionFreeInactive' + } + animate={ + isDistractionFree + ? 'hidden' + : 'distractionFreeInactive' + } variants={ headerVariants } - transition={ { type: 'tween', delay: 0.8 } } - > - { header } - </NavigableRegion> - ) } - { !! header && ! isDistractionFree && ( - <NavigableRegion - className="interface-interface-skeleton__header" - ariaLabel={ mergedLabels.header } + transition={ + isDistractionFree + ? { type: 'tween', delay: 0.8 } + : undefined + } > { header } </NavigableRegion> @@ -140,6 +151,7 @@ function InterfaceSkeleton( <NavigableRegion className="interface-interface-skeleton__content" ariaLabel={ mergedLabels.body } + { ...contentProps } > { content } </NavigableRegion> diff --git a/packages/interface/src/components/interface-skeleton/style.scss b/packages/interface/src/components/interface-skeleton/style.scss index a45239b529ddd0..d7ce996226ba59 100644 --- a/packages/interface/src/components/interface-skeleton/style.scss +++ b/packages/interface/src/components/interface-skeleton/style.scss @@ -88,6 +88,12 @@ html.interface-interface-skeleton__html-container { // to "bleed" through the header. // See https://github.com/WordPress/gutenberg/issues/32631 z-index: z-index(".interface-interface-skeleton__content"); + + // On Safari the z-index is not respected when the element is fixed. + // Setting it to auto fixes the problem + @include break-medium() { + z-index: auto; + } } .interface-interface-skeleton__secondary-sidebar, diff --git a/packages/interface/src/components/more-menu-feature-toggle/index.js b/packages/interface/src/components/more-menu-feature-toggle/index.js index 7e0bfb4661bdaa..f71e06802f508d 100644 --- a/packages/interface/src/components/more-menu-feature-toggle/index.js +++ b/packages/interface/src/components/more-menu-feature-toggle/index.js @@ -24,7 +24,7 @@ export default function MoreMenuFeatureToggle( { const isActive = useSelect( ( select ) => select( interfaceStore ).isFeatureActive( scope, feature ), - [ feature ] + [ feature, scope ] ); const { toggleFeature } = useDispatch( interfaceStore ); const speakMessage = () => { diff --git a/packages/interface/src/components/pinned-items/style.scss b/packages/interface/src/components/pinned-items/style.scss index 7ac9b19dfdfe01..cd516134076226 100644 --- a/packages/interface/src/components/pinned-items/style.scss +++ b/packages/interface/src/components/pinned-items/style.scss @@ -3,8 +3,19 @@ // We intentionally hide pinned items (plugins) on mobile, and unhide them at desktop breakpoints. // Otherwise the list can wreak havoc on the layout. - .components-button:not(:first-child) { + .components-button { display: none; + margin: 0; + + &:nth-last-child(1), + &:nth-last-child(2) { + display: flex; + } + + svg { + max-width: $icon-size; + max-height: $icon-size; + } @include break-small() { display: flex; @@ -16,13 +27,4 @@ // Account for larger grid from parent container gap. margin-right: -$grid-unit-05; - - .components-button { - margin: 0; - - svg { - max-width: $icon-size; - max-height: $icon-size; - } - } } diff --git a/packages/interface/src/store/actions.js b/packages/interface/src/store/actions.js index bc51d4fca72202..b7a9935e888b51 100644 --- a/packages/interface/src/store/actions.js +++ b/packages/interface/src/store/actions.js @@ -181,3 +181,28 @@ export function setFeatureDefaults( scope, defaults ) { registry.dispatch( preferencesStore ).setDefaults( scope, defaults ); }; } + +/** + * Returns an action object used in signalling that the user opened a modal. + * + * @param {string} name A string that uniquely identifies the modal. + * + * @return {Object} Action object. + */ +export function openModal( name ) { + return { + type: 'OPEN_MODAL', + name, + }; +} + +/** + * Returns an action object signalling that the user closed a modal. + * + * @return {Object} Action object. + */ +export function closeModal() { + return { + type: 'CLOSE_MODAL', + }; +} diff --git a/packages/interface/src/store/reducer.js b/packages/interface/src/store/reducer.js index 433f71d15bcc57..8317e53cec95bf 100644 --- a/packages/interface/src/store/reducer.js +++ b/packages/interface/src/store/reducer.js @@ -30,6 +30,26 @@ export function complementaryAreas( state = {}, action ) { return state; } +/** + * Reducer for storing the name of the open modal, or null if no modal is open. + * + * @param {Object} state Previous state. + * @param {Object} action Action object containing the `name` of the modal + * + * @return {Object} Updated state + */ +export function activeModal( state = null, action ) { + switch ( action.type ) { + case 'OPEN_MODAL': + return action.name; + case 'CLOSE_MODAL': + return null; + } + + return state; +} + export default combineReducers( { complementaryAreas, + activeModal, } ); diff --git a/packages/interface/src/store/selectors.js b/packages/interface/src/store/selectors.js index c92e45bbd3c590..548cb2f70346ac 100644 --- a/packages/interface/src/store/selectors.js +++ b/packages/interface/src/store/selectors.js @@ -90,3 +90,15 @@ export const isFeatureActive = createRegistrySelector( return !! select( preferencesStore ).get( scope, featureName ); } ); + +/** + * Returns true if a modal is active, or false otherwise. + * + * @param {Object} state Global application state. + * @param {string} modalName A string that uniquely identifies the modal. + * + * @return {boolean} Whether the modal is active. + */ +export function isModalActive( state, modalName ) { + return state.activeModal === modalName; +} diff --git a/packages/interface/src/store/test/reducer.js b/packages/interface/src/store/test/reducer.js new file mode 100644 index 00000000000000..eb9424637bca0f --- /dev/null +++ b/packages/interface/src/store/test/reducer.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import { activeModal } from '../reducer'; + +describe( 'state', () => { + describe( 'activeModal', () => { + it( 'should default to null', () => { + const state = activeModal( undefined, {} ); + expect( state ).toBeNull(); + } ); + + it( 'should set the activeModal to the provided name', () => { + const state = activeModal( null, { + type: 'OPEN_MODAL', + name: 'test-modal', + } ); + + expect( state ).toEqual( 'test-modal' ); + } ); + + it( 'should set the activeModal to null', () => { + const state = activeModal( 'test-modal', { + type: 'CLOSE_MODAL', + } ); + + expect( state ).toBeNull(); + } ); + } ); +} ); diff --git a/packages/interface/src/store/test/selectors.js b/packages/interface/src/store/test/selectors.js new file mode 100644 index 00000000000000..a3bea882043d29 --- /dev/null +++ b/packages/interface/src/store/test/selectors.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { isModalActive } from '../selectors'; + +describe( 'selectors', () => { + describe( 'isModalActive', () => { + it( 'returns true if the provided name matches the value in the preferences activeModal property', () => { + const state = { + activeModal: 'test-modal', + }; + + expect( isModalActive( state, 'test-modal' ) ).toBe( true ); + } ); + + it( 'returns false if the provided name does not match the preferences activeModal property', () => { + const state = { + activeModal: 'something-else', + }; + + expect( isModalActive( state, 'test-modal' ) ).toBe( false ); + } ); + + it( 'returns false if the preferences activeModal property is null', () => { + const state = { + activeModal: null, + }; + + expect( isModalActive( state, 'test-modal' ) ).toBe( false ); + } ); + } ); +} ); diff --git a/packages/is-shallow-equal/CHANGELOG.md b/packages/is-shallow-equal/CHANGELOG.md index ea14c52983f321..057275132433e2 100644 --- a/packages/is-shallow-equal/CHANGELOG.md +++ b/packages/is-shallow-equal/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.40.0 (2023-08-16) + +## 4.39.0 (2023-08-10) + +## 4.38.0 (2023-07-20) + +## 4.37.0 (2023-07-05) + +## 4.36.0 (2023-06-23) + +## 4.35.0 (2023-06-07) + ## 4.34.0 (2023-05-24) ## 4.33.0 (2023-05-10) diff --git a/packages/is-shallow-equal/package.json b/packages/is-shallow-equal/package.json index fbfad258551189..17c3f85398a02e 100644 --- a/packages/is-shallow-equal/package.json +++ b/packages/is-shallow-equal/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/is-shallow-equal", - "version": "4.34.0", + "version": "4.40.0", "description": "Test for shallow equality between two objects or arrays.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-console/CHANGELOG.md b/packages/jest-console/CHANGELOG.md index b4e6b00cf4dd25..f5aa959e2542e7 100644 --- a/packages/jest-console/CHANGELOG.md +++ b/packages/jest-console/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### Enhancement + +- Improved error messages and codes printed on the console ([#53743](https://github.com/WordPress/gutenberg/pull/53743)). + +## 7.11.0 (2023-08-16) + +## 7.10.0 (2023-08-10) + +## 7.9.0 (2023-07-20) + +## 7.8.0 (2023-07-05) + +## 7.7.0 (2023-06-23) + +## 7.6.0 (2023-06-07) + ## 7.5.0 (2023-05-24) ## 7.4.0 (2023-05-10) diff --git a/packages/jest-console/package.json b/packages/jest-console/package.json index e1668567423db4..9c5433c632173d 100644 --- a/packages/jest-console/package.json +++ b/packages/jest-console/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-console", - "version": "7.5.0", + "version": "7.11.0", "description": "Custom Jest matchers for the Console object.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -33,7 +33,7 @@ "types": "types", "dependencies": { "@babel/runtime": "^7.16.0", - "jest-matcher-utils": "^29.5.0" + "jest-matcher-utils": "^29.6.2" }, "peerDependencies": { "jest": ">=29" diff --git a/packages/jest-console/src/matchers.js b/packages/jest-console/src/matchers.js index 078ef47fb1da55..9793164f532b32 100644 --- a/packages/jest-console/src/matchers.js +++ b/packages/jest-console/src/matchers.js @@ -8,58 +8,66 @@ import { matcherHint, printExpected, printReceived } from 'jest-matcher-utils'; */ import supportedMatchers from './supported-matchers'; +const createErrorMessage = ( spyInfo ) => { + const { spy, pass, calls, matcherName, methodName, expected } = spyInfo; + const hint = pass ? `.not${ matcherName }` : matcherName; + const message = pass + ? `Expected mock function not to be called but it was called with:\n${ calls.map( + printReceived + ) }` + : `Expected mock function to be called${ + expected ? ` with:\n${ printExpected( expected ) }\n` : '.' + }\nbut it was called with:\n${ calls.map( printReceived ) }`; + + return () => + `${ matcherHint( hint, spy.getMockName() ) }` + + '\n\n' + + message + + '\n\n' + + `console.${ methodName }() should not be used unless explicitly expected\n` + + 'See https://www.npmjs.com/package/@wordpress/jest-console for details.'; +}; + +const createSpyInfo = ( spy, matcherName, methodName, expected ) => { + const calls = spy.mock.calls; + + const pass = expected + ? JSON.stringify( calls ).includes( JSON.stringify( expected ) ) + : calls.length > 0; + + const message = createErrorMessage( { + spy, + pass, + calls, + matcherName, + methodName, + expected, + } ); + + return { + pass, + message, + }; +}; + const createToHaveBeenCalledMatcher = ( matcherName, methodName ) => ( received ) => { const spy = received[ methodName ]; - const calls = spy.mock.calls; - const pass = calls.length > 0; - const message = pass - ? () => - matcherHint( `.not${ matcherName }`, spy.getMockName() ) + - '\n\n' + - 'Expected mock function not to be called but it was called with:\n' + - calls.map( printReceived ) - : () => - matcherHint( matcherName, spy.getMockName() ) + - '\n\n' + - 'Expected mock function to be called.'; + const spyInfo = createSpyInfo( spy, matcherName, methodName ); spy.assertionsNumber += 1; - return { - message, - pass, - }; + return spyInfo; }; const createToHaveBeenCalledWith = ( matcherName, methodName ) => function ( received, ...expected ) { const spy = received[ methodName ]; - const calls = spy.mock.calls; - const pass = calls.some( ( objects ) => - this.equals( objects, expected ) - ); - const message = pass - ? () => - matcherHint( `.not${ matcherName }`, spy.getMockName() ) + - '\n\n' + - 'Expected mock function not to be called with:\n' + - printExpected( expected ) - : () => - matcherHint( matcherName, spy.getMockName() ) + - '\n\n' + - 'Expected mock function to be called with:\n' + - printExpected( expected ) + - '\n' + - 'but it was called with:\n' + - calls.map( printReceived ); + const spyInfo = createSpyInfo( spy, matcherName, methodName, expected ); spy.assertionsNumber += 1; - return { - message, - pass, - }; + return spyInfo; }; expect.extend( diff --git a/packages/jest-preset-default/CHANGELOG.md b/packages/jest-preset-default/CHANGELOG.md index f7d4153940daa4..77f8976807d96a 100644 --- a/packages/jest-preset-default/CHANGELOG.md +++ b/packages/jest-preset-default/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 11.11.0 (2023-08-16) + +## 11.10.0 (2023-08-10) + +## 11.9.0 (2023-07-20) + +## 11.8.0 (2023-07-05) + +## 11.7.0 (2023-06-23) + +## 11.6.0 (2023-06-07) + ## 11.5.0 (2023-05-24) ## 11.4.0 (2023-05-10) diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json index 780464f13fa861..61f5e3590ce85c 100644 --- a/packages/jest-preset-default/package.json +++ b/packages/jest-preset-default/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-preset-default", - "version": "11.5.0", + "version": "11.11.0", "description": "Default Jest preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -31,7 +31,7 @@ "main": "index.js", "dependencies": { "@wordpress/jest-console": "file:../jest-console", - "babel-jest": "^29.5.0" + "babel-jest": "^29.6.2" }, "peerDependencies": { "@babel/core": ">=7", diff --git a/packages/jest-preset-default/scripts/setup-globals.js b/packages/jest-preset-default/scripts/setup-globals.js index 3855d1bc79b0c7..abd99f620dc893 100644 --- a/packages/jest-preset-default/scripts/setup-globals.js +++ b/packages/jest-preset-default/scripts/setup-globals.js @@ -1,3 +1,6 @@ +// Run all tests with development tools enabled. +global.SCRIPT_DEBUG = true; + // These are necessary to load TinyMCE successfully. global.URL = window.URL; global.window.tinyMCEPreInit = { @@ -49,12 +52,5 @@ global.window.matchMedia = () => ( { removeEventListener: () => {}, } ); -// Setup fake localStorage. -const storage = {}; -global.window.localStorage = { - getItem: ( key ) => ( key in storage ? storage[ key ] : null ), - setItem: ( key, value ) => ( storage[ key ] = value ), -}; - // UserSettings global. global.window.userSettings = { uid: 1 }; diff --git a/packages/jest-puppeteer-axe/CHANGELOG.md b/packages/jest-puppeteer-axe/CHANGELOG.md index da3e4e40c764bc..aa77aa43d54033 100644 --- a/packages/jest-puppeteer-axe/CHANGELOG.md +++ b/packages/jest-puppeteer-axe/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 6.11.0 (2023-08-16) + +## 6.10.0 (2023-08-10) + +## 6.9.0 (2023-07-20) + +## 6.8.0 (2023-07-05) + +## 6.7.0 (2023-06-23) + +## 6.6.0 (2023-06-07) + ## 6.5.0 (2023-05-24) ## 6.4.0 (2023-05-10) diff --git a/packages/jest-puppeteer-axe/package.json b/packages/jest-puppeteer-axe/package.json index 252b98c125698d..0145bf3c1e3ab0 100644 --- a/packages/jest-puppeteer-axe/package.json +++ b/packages/jest-puppeteer-axe/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-puppeteer-axe", - "version": "6.5.0", + "version": "6.11.0", "description": "Axe API integration with Jest and Puppeteer.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keyboard-shortcuts/CHANGELOG.md b/packages/keyboard-shortcuts/CHANGELOG.md index 73bc33786afc34..ef50c5f3c1594f 100644 --- a/packages/keyboard-shortcuts/CHANGELOG.md +++ b/packages/keyboard-shortcuts/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.17.0 (2023-08-16) + +## 4.16.0 (2023-08-10) + +## 4.15.0 (2023-07-20) + +## 4.14.0 (2023-07-05) + +## 4.13.0 (2023-06-23) + +## 4.12.0 (2023-06-07) + ## 4.11.0 (2023-05-24) ## 4.10.0 (2023-05-10) diff --git a/packages/keyboard-shortcuts/package.json b/packages/keyboard-shortcuts/package.json index a78b843a9e1e23..1c87e9b72eac9d 100644 --- a/packages/keyboard-shortcuts/package.json +++ b/packages/keyboard-shortcuts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keyboard-shortcuts", - "version": "4.11.0", + "version": "4.17.0", "description": "Handling keyboard shortcuts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keyboard-shortcuts/src/store/actions.js b/packages/keyboard-shortcuts/src/store/actions.js index b4cb625287d8fb..d45a23a09f21da 100644 --- a/packages/keyboard-shortcuts/src/store/actions.js +++ b/packages/keyboard-shortcuts/src/store/actions.js @@ -26,6 +26,44 @@ * * @param {WPShortcutConfig} config Shortcut config. * + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect, useDispatch } from '@wordpress/data'; + * import { useEffect } from '@wordpress/element'; + * import { __ } from '@wordpress/i18n'; + * + * const ExampleComponent = () => { + * const { registerShortcut } = useDispatch( keyboardShortcutsStore ); + * + * useEffect( () => { + * registerShortcut( { + * name: 'custom/my-custom-shortcut', + * category: 'my-category', + * description: __( 'My custom shortcut' ), + * keyCombination: { + * modifier: 'primary', + * character: 'j', + * }, + * } ); + * }, [] ); + * + * const shortcut = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getShortcutKeyCombination( + * 'custom/my-custom-shortcut' + * ), + * [] + * ); + * + * return shortcut ? ( + * <p>{ __( 'Shortcut is registered.' ) }</p> + * ) : ( + * <p>{ __( 'Shortcut is not registered.' ) }</p> + * ); + * }; + *``` * @return {Object} action. */ export function registerShortcut( { @@ -50,6 +88,36 @@ export function registerShortcut( { * * @param {string} name Shortcut name. * + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect, useDispatch } from '@wordpress/data'; + * import { useEffect } from '@wordpress/element'; + * import { __ } from '@wordpress/i18n'; + * + * const ExampleComponent = () => { + * const { unregisterShortcut } = useDispatch( keyboardShortcutsStore ); + * + * useEffect( () => { + * unregisterShortcut( 'core/edit-post/next-region' ); + * }, [] ); + * + * const shortcut = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getShortcutKeyCombination( + * 'core/edit-post/next-region' + * ), + * [] + * ); + * + * return shortcut ? ( + * <p>{ __( 'Shortcut is not unregistered.' ) }</p> + * ) : ( + * <p>{ __( 'Shortcut is unregistered.' ) }</p> + * ); + * }; + *``` * @return {Object} action. */ export function unregisterShortcut( name ) { diff --git a/packages/keyboard-shortcuts/src/store/selectors.js b/packages/keyboard-shortcuts/src/store/selectors.js index 45acf70f77d200..2d4f5976247b05 100644 --- a/packages/keyboard-shortcuts/src/store/selectors.js +++ b/packages/keyboard-shortcuts/src/store/selectors.js @@ -64,6 +64,39 @@ function getKeyCombinationRepresentation( shortcut, representation ) { * @param {Object} state Global state. * @param {string} name Shortcut name. * + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * import { createInterpolateElement } from '@wordpress/element'; + * import { sprintf } from '@wordpress/i18n'; + * const ExampleComponent = () => { + * const {character, modifier} = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getShortcutKeyCombination( + * 'core/edit-post/next-region' + * ), + * [] + * ); + * + * return ( + * <div> + * { createInterpolateElement( + * sprintf( + * 'Character: <code>%s</code> / Modifier: <code>%s</code>', + * character, + * modifier + * ), + * { + * code: <code />, + * } + * ) } + * </div> + * ); + * }; + *``` + * * @return {WPShortcutKeyCombination?} Key combination. */ export function getShortcutKeyCombination( state, name ) { @@ -77,6 +110,34 @@ export function getShortcutKeyCombination( state, name ) { * @param {string} name Shortcut name. * @param {keyof FORMATTING_METHODS} representation Type of representation * (display, raw, ariaLabel). + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * import { sprintf } from '@wordpress/i18n'; + * + * const ExampleComponent = () => { + * const {display, raw, ariaLabel} = useSelect( + * ( select ) =>{ + * return { + * display: select( keyboardShortcutsStore ).getShortcutRepresentation('core/edit-post/next-region' ), + * raw: select( keyboardShortcutsStore ).getShortcutRepresentation('core/edit-post/next-region','raw' ), + * ariaLabel: select( keyboardShortcutsStore ).getShortcutRepresentation('core/edit-post/next-region', 'ariaLabel') + * } + * }, + * [] + * ); + * + * return ( + * <ul> + * <li>{ sprintf( 'display string: %s', display ) }</li> + * <li>{ sprintf( 'raw string: %s', raw ) }</li> + * <li>{ sprintf( 'ariaLabel string: %s', ariaLabel ) }</li> + * </ul> + * ); + * }; + *``` * * @return {string?} Shortcut representation. */ @@ -95,6 +156,26 @@ export function getShortcutRepresentation( * @param {Object} state Global state. * @param {string} name Shortcut name. * + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * import { __ } from '@wordpress/i18n'; + * const ExampleComponent = () => { + * const shortcutDescription = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getShortcutDescription( 'core/edit-post/next-region' ), + * [] + * ); + * + * return shortcutDescription ? ( + * <div>{ shortcutDescription }</div> + * ) : ( + * <div>{ __( 'No description.' ) }</div> + * ); + * }; + *``` * @return {string?} Shortcut description. */ export function getShortcutDescription( state, name ) { @@ -106,6 +187,44 @@ export function getShortcutDescription( state, name ) { * * @param {Object} state Global state. * @param {string} name Shortcut name. + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * import { createInterpolateElement } from '@wordpress/element'; + * import { sprintf } from '@wordpress/i18n'; + * const ExampleComponent = () => { + * const shortcutAliases = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getShortcutAliases( + * 'core/edit-post/next-region' + * ), + * [] + * ); + * + * return ( + * shortcutAliases.length > 0 && ( + * <ul> + * { shortcutAliases.map( ( { character, modifier }, index ) => ( + * <li key={ index }> + * { createInterpolateElement( + * sprintf( + * 'Character: <code>%s</code> / Modifier: <code>%s</code>', + * character, + * modifier + * ), + * { + * code: <code />, + * } + * ) } + * </li> + * ) ) } + * </ul> + * ) + * ); + * }; + *``` * * @return {WPShortcutKeyCombination[]} Key combinations. */ @@ -115,6 +234,55 @@ export function getShortcutAliases( state, name ) { : EMPTY_ARRAY; } +/** + * Returns the shortcuts that include aliases for a given shortcut name. + * + * @param {Object} state Global state. + * @param {string} name Shortcut name. + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * import { createInterpolateElement } from '@wordpress/element'; + * import { sprintf } from '@wordpress/i18n'; + * + * const ExampleComponent = () => { + * const allShortcutKeyCombinations = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getAllShortcutKeyCombinations( + * 'core/edit-post/next-region' + * ), + * [] + * ); + * + * return ( + * allShortcutKeyCombinations.length > 0 && ( + * <ul> + * { allShortcutKeyCombinations.map( + * ( { character, modifier }, index ) => ( + * <li key={ index }> + * { createInterpolateElement( + * sprintf( + * 'Character: <code>%s</code> / Modifier: <code>%s</code>', + * character, + * modifier + * ), + * { + * code: <code />, + * } + * ) } + * </li> + * ) + * ) } + * </ul> + * ) + * ); + * }; + *``` + * + * @return {WPShortcutKeyCombination[]} Key combinations. + */ export const getAllShortcutKeyCombinations = createSelector( ( state, name ) => { return [ @@ -131,6 +299,47 @@ export const getAllShortcutKeyCombinations = createSelector( * @param {Object} state Global state. * @param {string} name Shortcut name. * + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * import { createInterpolateElement } from '@wordpress/element'; + * import { sprintf } from '@wordpress/i18n'; + * + * const ExampleComponent = () => { + * const allShortcutRawKeyCombinations = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getAllShortcutRawKeyCombinations( + * 'core/edit-post/next-region' + * ), + * [] + * ); + * + * return ( + * allShortcutRawKeyCombinations.length > 0 && ( + * <ul> + * { allShortcutRawKeyCombinations.map( + * ( shortcutRawKeyCombination, index ) => ( + * <li key={ index }> + * { createInterpolateElement( + * sprintf( + * ' <code>%s</code>', + * shortcutRawKeyCombination + * ), + * { + * code: <code />, + * } + * ) } + * </li> + * ) + * ) } + * </ul> + * ) + * ); + * }; + *``` + * * @return {string[]} Shortcuts. */ export const getAllShortcutRawKeyCombinations = createSelector( @@ -148,7 +357,32 @@ export const getAllShortcutRawKeyCombinations = createSelector( * * @param {Object} state Global state. * @param {string} name Category name. + * @example + * + *```js + * import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const categoryShortcuts = useSelect( + * ( select ) => + * select( keyboardShortcutsStore ).getCategoryShortcuts( + * 'block' + * ), + * [] + * ); * + * return ( + * categoryShortcuts.length > 0 && ( + * <ul> + * { categoryShortcuts.map( ( categoryShortcut ) => ( + * <li key={ categoryShortcut }>{ categoryShortcut }</li> + * ) ) } + * </ul> + * ) + * ); + * }; + *``` * @return {string[]} Shortcut names. */ export const getCategoryShortcuts = createSelector( diff --git a/packages/keycodes/CHANGELOG.md b/packages/keycodes/CHANGELOG.md index 88180c8512cb82..8b166452f814ab 100644 --- a/packages/keycodes/CHANGELOG.md +++ b/packages/keycodes/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json index 1feb935d6dcd83..e3923149b7b3bb 100644 --- a/packages/keycodes/package.json +++ b/packages/keycodes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keycodes", - "version": "3.34.0", + "version": "3.40.0", "description": "Keycodes utilities for WordPress. Used to check for keyboard events across browsers/operating systems.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/lazy-import/CHANGELOG.md b/packages/lazy-import/CHANGELOG.md index c659e9ddf050cf..8960e4cf55f09c 100644 --- a/packages/lazy-import/CHANGELOG.md +++ b/packages/lazy-import/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 1.27.0 (2023-08-16) + +## 1.26.0 (2023-08-10) + +## 1.25.0 (2023-07-20) + +## 1.24.0 (2023-07-05) + +## 1.23.0 (2023-06-23) + +## 1.22.0 (2023-06-07) + ## 1.21.0 (2023-05-24) ## 1.20.0 (2023-05-10) diff --git a/packages/lazy-import/README.md b/packages/lazy-import/README.md index c0659c153b4560..af19716c2ebc16 100644 --- a/packages/lazy-import/README.md +++ b/packages/lazy-import/README.md @@ -20,7 +20,7 @@ NPM 6.9.0 or newer is required, since it uses the [package aliases feature](http Usage is intended to mimic the behavior of the [dynamic `import` function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Dynamic_Imports), receiving the name (and optional version specifier) of an NPM package and returning a promise which resolves to the required module. -_**Note:** Currently, this alignment to `import` is superficial, and the module resolution still uses [CommonJS `require`](https://nodejs.org/docs/latest-v12.x/api/modules.html#modules_require_id), rather than the newer [ES Modules support](https://nodejs.org/docs/latest-v14.x/api/esm.html). Future versions of this package will likely support ES Modules, once an LTS release of Node.js including unflagged ES Modules support becomes available._ +_**Note:** Currently, this alignment to `import` is superficial, and the module resolution still uses [CommonJS `require`](https://nodejs.org/docs/latest-v12.x/api/modules.html#modules_require_id), rather than the newer [ES Modules support](https://nodejs.org/docs/latest-v16.x/api/esm.html). Future versions of this package will likely support ES Modules, once an LTS release of Node.js including unflagged ES Modules support becomes available._ The string passed to `lazyImport` can be formatted exactly as you would provide to `npm install`, including an optional version specifier (including [version ranges](https://docs.npmjs.com/misc/semver#ranges)). If the version specifier is omitted, it will be treated as equivalent to `*`, using the version of a locally installed package if available, otherwise installing the latest available version. diff --git a/packages/lazy-import/package.json b/packages/lazy-import/package.json index 82ec9a8748608a..28e2366de98414 100644 --- a/packages/lazy-import/package.json +++ b/packages/lazy-import/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/lazy-import", - "version": "1.21.0", + "version": "1.27.0", "description": "Lazily import a module, installing it automatically if missing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/list-reusable-blocks/CHANGELOG.md b/packages/list-reusable-blocks/CHANGELOG.md index 394b5040e4813c..9db39b32d6b101 100644 --- a/packages/list-reusable-blocks/CHANGELOG.md +++ b/packages/list-reusable-blocks/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.17.0 (2023-08-16) + +## 4.16.0 (2023-08-10) + +## 4.15.0 (2023-07-20) + +## 4.14.0 (2023-07-05) + +## 4.13.0 (2023-06-23) + +## 4.12.0 (2023-06-07) + ## 4.11.0 (2023-05-24) ## 4.10.0 (2023-05-10) diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json index c463bf9ad9ed60..f55344e7838d15 100644 --- a/packages/list-reusable-blocks/package.json +++ b/packages/list-reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/list-reusable-blocks", - "version": "4.11.0", + "version": "4.17.0", "description": "Adding Export/Import support to the reusable blocks listing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/list-reusable-blocks/src/components/import-form/index.js b/packages/list-reusable-blocks/src/components/import-form/index.js index 3b3daa49571f47..9ba1589e52f39b 100644 --- a/packages/list-reusable-blocks/src/components/import-form/index.js +++ b/packages/list-reusable-blocks/src/components/import-form/index.js @@ -49,8 +49,8 @@ function ImportForm( { instanceId, onUpload } ) { case 'Invalid JSON file': uiMessage = __( 'Invalid JSON file' ); break; - case 'Invalid Reusable block JSON file': - uiMessage = __( 'Invalid Reusable block JSON file' ); + case 'Invalid Pattern JSON file': + uiMessage = __( 'Invalid Pattern JSON file' ); break; default: uiMessage = __( 'Unknown error' ); diff --git a/packages/list-reusable-blocks/src/index.js b/packages/list-reusable-blocks/src/index.js index 3c9945139856f5..4440ba1c49f05a 100644 --- a/packages/list-reusable-blocks/src/index.js +++ b/packages/list-reusable-blocks/src/index.js @@ -31,9 +31,7 @@ document.addEventListener( 'DOMContentLoaded', () => { const showNotice = () => { const notice = document.createElement( 'div' ); notice.className = 'notice notice-success is-dismissible'; - notice.innerHTML = `<p>${ __( - 'Reusable block imported successfully!' - ) }</p>`; + notice.innerHTML = `<p>${ __( 'Pattern imported successfully!' ) }</p>`; const headerEnd = document.querySelector( '.wp-header-end' ); if ( ! headerEnd ) { diff --git a/packages/list-reusable-blocks/src/utils/export.js b/packages/list-reusable-blocks/src/utils/export.js index 0f70931c500805..4075c7576f1340 100644 --- a/packages/list-reusable-blocks/src/utils/export.js +++ b/packages/list-reusable-blocks/src/utils/export.js @@ -25,11 +25,13 @@ async function exportReusableBlock( id ) { } ); const title = post.title.raw; const content = post.content.raw; + const syncStatus = post.wp_pattern_sync_status; const fileContent = JSON.stringify( { __file: 'wp_block', title, content, + syncStatus, }, null, 2 diff --git a/packages/list-reusable-blocks/src/utils/import.js b/packages/list-reusable-blocks/src/utils/import.js index 84c28b5fcfc80c..465fb080ce8dfe 100644 --- a/packages/list-reusable-blocks/src/utils/import.js +++ b/packages/list-reusable-blocks/src/utils/import.js @@ -27,9 +27,11 @@ async function importReusableBlock( file ) { ! parsedContent.title || ! parsedContent.content || typeof parsedContent.title !== 'string' || - typeof parsedContent.content !== 'string' + typeof parsedContent.content !== 'string' || + ( parsedContent.syncStatus && + typeof parsedContent.syncStatus !== 'string' ) ) { - throw new Error( 'Invalid Reusable block JSON file' ); + throw new Error( 'Invalid Pattern JSON file' ); } const postType = await apiFetch( { path: `/wp/v2/types/wp_block` } ); const reusableBlock = await apiFetch( { @@ -38,6 +40,10 @@ async function importReusableBlock( file ) { title: parsedContent.title, content: parsedContent.content, status: 'publish', + meta: + parsedContent.syncStatus === 'unsynced' + ? { wp_pattern_sync_status: parsedContent.syncStatus } + : undefined, }, method: 'POST', } ); diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index d2d6e172d68fda..6421e4238cdd92 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.31.0 (2023-08-16) + +## 4.30.0 (2023-08-10) + +## 4.29.0 (2023-07-20) + +## 4.28.0 (2023-07-05) + +## 4.27.0 (2023-06-23) + +## 4.26.0 (2023-06-07) + ## 4.25.0 (2023-05-24) ## 4.24.0 (2023-05-10) diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index 3e893c815ed5fe..fcfbd1e9edff8a 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/media-utils", - "version": "4.25.0", + "version": "4.31.0", "description": "WordPress Media Upload Utils.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/notices/CHANGELOG.md b/packages/notices/CHANGELOG.md index 15e471a65d36a5..51ac0b28d96445 100644 --- a/packages/notices/CHANGELOG.md +++ b/packages/notices/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +## 4.8.0 (2023-08-16) + +## 4.7.0 (2023-08-10) + +## 4.6.0 (2023-07-20) + +## 4.5.0 (2023-07-05) + +## 4.4.0 (2023-06-23) + +## 4.3.0 (2023-06-07) + +### New Feature + +- Add a new action `removeNotices` which allows bulk removal of notices by their IDs. ([#39940](https://github.com/WordPress/gutenberg/pull/39940)) +- Add a new action `removeAllNotices` which removes all notices from a given context. ([#44059](https://github.com/WordPress/gutenberg/pull/44059)) + ## 4.2.0 (2023-05-24) ## 4.1.0 (2023-05-10) diff --git a/packages/notices/package.json b/packages/notices/package.json index 3fa0b40e4a5077..67a1dc161add61 100644 --- a/packages/notices/package.json +++ b/packages/notices/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/notices", - "version": "4.2.0", + "version": "4.8.0", "description": "State management for notices.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -30,6 +30,9 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/data": "file:../data" }, + "peerDependencies": { + "react": "^18.0.0" + }, "publishConfig": { "access": "public" } diff --git a/packages/notices/src/store/actions.js b/packages/notices/src/store/actions.js index a8fdb13f9352fc..11c38a9c931ae4 100644 --- a/packages/notices/src/store/actions.js +++ b/packages/notices/src/store/actions.js @@ -11,7 +11,6 @@ import { DEFAULT_CONTEXT, DEFAULT_STATUS } from './constants'; * browser navigation. * @property {?Function} onClick Optional function to invoke when action is * triggered by user. - * */ let uniqueId = 0; @@ -313,3 +312,106 @@ export function removeNotice( id, context = DEFAULT_CONTEXT ) { context, }; } + +/** + * Removes all notices from a given context. Defaults to the default context. + * + * @param {string} noticeType The context to remove all notices from. + * @param {string} context The context to remove all notices from. + * + * @example + * ```js + * import { __ } from '@wordpress/i18n'; + * import { useDispatch, useSelect } from '@wordpress/data'; + * import { store as noticesStore } from '@wordpress/notices'; + * import { Button } from '@wordpress/components'; + * + * export const ExampleComponent = () => { + * const notices = useSelect( ( select ) => + * select( noticesStore ).getNotices() + * ); + * const { removeNotices } = useDispatch( noticesStore ); + * return ( + * <> + * <ul> + * { notices.map( ( notice ) => ( + * <li key={ notice.id }>{ notice.content }</li> + * ) ) } + * </ul> + * <Button + * onClick={ () => + * removeAllNotices() + * } + * > + * { __( 'Clear all notices', 'woo-gutenberg-products-block' ) } + * </Button> + * <Button + * onClick={ () => + * removeAllNotices( 'snackbar' ) + * } + * > + * { __( 'Clear all snackbar notices', 'woo-gutenberg-products-block' ) } + * </Button> + * </> + * ); + * }; + * ``` + * + * @return {Object} Action object. + */ +export function removeAllNotices( + noticeType = 'default', + context = DEFAULT_CONTEXT +) { + return { + type: 'REMOVE_ALL_NOTICES', + noticeType, + context, + }; +} + +/** + * Returns an action object used in signalling that several notices are to be removed. + * + * @param {string[]} ids List of unique notice identifiers. + * @param {string} [context='global'] Optional context (grouping) in which the notices are + * intended to appear. Defaults to default context. + * @example + * ```js + * import { __ } from '@wordpress/i18n'; + * import { useDispatch, useSelect } from '@wordpress/data'; + * import { store as noticesStore } from '@wordpress/notices'; + * import { Button } from '@wordpress/components'; + * + * const ExampleComponent = () => { + * const notices = useSelect( ( select ) => + * select( noticesStore ).getNotices() + * ); + * const { removeNotices } = useDispatch( noticesStore ); + * return ( + * <> + * <ul> + * { notices.map( ( notice ) => ( + * <li key={ notice.id }>{ notice.content }</li> + * ) ) } + * </ul> + * <Button + * onClick={ () => + * removeNotices( notices.map( ( { id } ) => id ) ) + * } + * > + * { __( 'Clear all notices' ) } + * </Button> + * </> + * ); + * }; + * ``` + * @return {Object} Action object. + */ +export function removeNotices( ids, context = DEFAULT_CONTEXT ) { + return { + type: 'REMOVE_NOTICES', + ids, + context, + }; +} diff --git a/packages/notices/src/store/reducer.js b/packages/notices/src/store/reducer.js index ff2359b61cc013..5f4d88c04af299 100644 --- a/packages/notices/src/store/reducer.js +++ b/packages/notices/src/store/reducer.js @@ -23,6 +23,12 @@ const notices = onSubKey( 'context' )( ( state = [], action ) => { case 'REMOVE_NOTICE': return state.filter( ( { id } ) => id !== action.id ); + + case 'REMOVE_NOTICES': + return state.filter( ( { id } ) => ! action.ids.includes( id ) ); + + case 'REMOVE_ALL_NOTICES': + return state.filter( ( { type } ) => type !== action.noticeType ); } return state; diff --git a/packages/notices/src/store/selectors.js b/packages/notices/src/store/selectors.js index 639b776c313dfd..d3cd9d41a0ec06 100644 --- a/packages/notices/src/store/selectors.js +++ b/packages/notices/src/store/selectors.js @@ -39,7 +39,6 @@ const DEFAULT_NOTICES = []; * announced to screen readers. Defaults to * `true`. * @property {WPNoticeAction[]} actions User actions to present with notice. - * */ /** diff --git a/packages/notices/src/store/test/actions.js b/packages/notices/src/store/test/actions.js index db6aa72b468dec..37fefc5c3558f4 100644 --- a/packages/notices/src/store/test/actions.js +++ b/packages/notices/src/store/test/actions.js @@ -8,6 +8,8 @@ import { createErrorNotice, createWarningNotice, removeNotice, + removeAllNotices, + removeNotices, } from '../actions'; import { DEFAULT_CONTEXT, DEFAULT_STATUS } from '../constants'; @@ -215,4 +217,55 @@ describe( 'actions', () => { } ); } ); } ); + + describe( 'removeNotices', () => { + it( 'should return action', () => { + const ids = [ 'id', 'id2' ]; + + expect( removeNotices( ids ) ).toEqual( { + type: 'REMOVE_NOTICES', + ids, + context: DEFAULT_CONTEXT, + } ); + } ); + + it( 'should return action with custom context', () => { + const ids = [ 'id', 'id2' ]; + const context = 'foo'; + + expect( removeNotices( ids, context ) ).toEqual( { + type: 'REMOVE_NOTICES', + ids, + context, + } ); + } ); + } ); + + describe( 'removeAllNotices', () => { + it( 'should return action', () => { + expect( removeAllNotices() ).toEqual( { + type: 'REMOVE_ALL_NOTICES', + noticeType: 'default', + context: DEFAULT_CONTEXT, + } ); + } ); + + it( 'should return action with custom context', () => { + const context = 'foo'; + + expect( removeAllNotices( 'default', context ) ).toEqual( { + type: 'REMOVE_ALL_NOTICES', + noticeType: 'default', + context, + } ); + } ); + + it( 'should return action with type', () => { + expect( removeAllNotices( 'snackbar' ) ).toEqual( { + type: 'REMOVE_ALL_NOTICES', + noticeType: 'snackbar', + context: DEFAULT_CONTEXT, + } ); + } ); + } ); } ); diff --git a/packages/notices/src/store/test/reducer.js b/packages/notices/src/store/test/reducer.js index 52ba278c79d854..d807b814a51f47 100644 --- a/packages/notices/src/store/test/reducer.js +++ b/packages/notices/src/store/test/reducer.js @@ -7,7 +7,12 @@ import deepFreeze from 'deep-freeze'; * Internal dependencies */ import reducer from '../reducer'; -import { createNotice, removeNotice } from '../actions'; +import { + createNotice, + removeNotice, + removeNotices, + removeAllNotices, +} from '../actions'; import { getNotices } from '../selectors'; import { DEFAULT_CONTEXT } from '../constants'; @@ -141,6 +146,44 @@ describe( 'reducer', () => { expect( state[ DEFAULT_CONTEXT ] ).toHaveLength( 1 ); } ); + it( 'should omit several removed notices', () => { + const action = createNotice( 'error', 'save error' ); + const action2 = createNotice( 'error', 'second error' ); + const stateWithOneNotice = reducer( undefined, action ); + const original = deepFreeze( reducer( stateWithOneNotice, action2 ) ); + const ids = [ + getNotices( original )[ 0 ].id, + getNotices( original )[ 1 ].id, + ]; + + const state = reducer( original, removeNotices( ids ) ); + + expect( state ).toEqual( { + [ DEFAULT_CONTEXT ]: [], + } ); + } ); + + it( 'should omit several removed notices across contexts', () => { + const action = createNotice( 'error', 'save error' ); + const action2 = createNotice( 'error', 'second error', { + context: 'foo', + } ); + const action3 = createNotice( 'error', 'third error', { + context: 'foo', + } ); + const stateWithOneNotice = reducer( undefined, action ); + const stateWithTwoNotices = reducer( stateWithOneNotice, action2 ); + const original = deepFreeze( reducer( stateWithTwoNotices, action3 ) ); + const ids = [ + getNotices( original, 'foo' )[ 0 ].id, + getNotices( original, 'foo' )[ 1 ].id, + ]; + + const state = reducer( original, removeNotices( ids, 'foo' ) ); + + expect( state[ DEFAULT_CONTEXT ] ).toHaveLength( 1 ); + } ); + it( 'should dedupe distinct ids, preferring new', () => { let action = createNotice( 'error', 'save error (1)', { id: 'error-message', @@ -170,4 +213,84 @@ describe( 'reducer', () => { ], } ); } ); + + it( 'should remove all notices', () => { + let action = createNotice( 'error', 'save error' ); + const original = deepFreeze( reducer( undefined, action ) ); + + action = createNotice( 'success', 'successfully saved' ); + let state = reducer( original, action ); + state = reducer( state, removeAllNotices() ); + + expect( state ).toEqual( { + [ DEFAULT_CONTEXT ]: [], + } ); + } ); + + it( 'should remove all notices in a given context but leave other contexts intact', () => { + let action = createNotice( 'error', 'save error', { + context: 'foo', + id: 'foo-error', + } ); + const original = deepFreeze( reducer( undefined, action ) ); + + action = createNotice( 'success', 'successfully saved', { + context: 'bar', + } ); + + let state = reducer( original, action ); + state = reducer( state, removeAllNotices( 'default', 'bar' ) ); + + expect( state ).toEqual( { + bar: [], + foo: [ + { + id: 'foo-error', + content: 'save error', + spokenMessage: 'save error', + __unstableHTML: undefined, + status: 'error', + isDismissible: true, + actions: [], + type: 'default', + icon: null, + explicitDismiss: false, + onDismiss: undefined, + }, + ], + } ); + } ); + + it( 'should remove all notices of a given type', () => { + let action = createNotice( 'error', 'save error', { + id: 'global-error', + } ); + const original = deepFreeze( reducer( undefined, action ) ); + + action = createNotice( 'success', 'successfully saved', { + type: 'snackbar', + id: 'snackbar-success', + } ); + + let state = reducer( original, action ); + state = reducer( state, removeAllNotices( 'default' ) ); + + expect( state ).toEqual( { + [ DEFAULT_CONTEXT ]: [ + { + id: 'snackbar-success', + content: 'successfully saved', + spokenMessage: 'successfully saved', + __unstableHTML: undefined, + status: 'success', + isDismissible: true, + actions: [], + type: 'snackbar', + icon: null, + explicitDismiss: false, + onDismiss: undefined, + }, + ], + } ); + } ); } ); diff --git a/packages/npm-package-json-lint-config/CHANGELOG.md b/packages/npm-package-json-lint-config/CHANGELOG.md index 3726a7648a484c..cb8971f86de4f9 100644 --- a/packages/npm-package-json-lint-config/CHANGELOG.md +++ b/packages/npm-package-json-lint-config/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +## 4.25.0 (2023-08-16) + +### Enhancement + +- Updated `npm-package-json-lint` peer dependency to require v6.0.0 [#53636](https://github.com/WordPress/gutenberg/pull/53636) + +## 4.24.0 (2023-08-10) + +## 4.23.0 (2023-07-20) + +## 4.22.0 (2023-07-05) + +## 4.21.0 (2023-06-23) + +## 4.20.0 (2023-06-07) + ## 4.19.0 (2023-05-24) ## 4.18.0 (2023-05-10) diff --git a/packages/npm-package-json-lint-config/package.json b/packages/npm-package-json-lint-config/package.json index 02b074fd911f3e..8a5e2993ee2bdf 100644 --- a/packages/npm-package-json-lint-config/package.json +++ b/packages/npm-package-json-lint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/npm-package-json-lint-config", - "version": "4.19.0", + "version": "4.25.0", "description": "WordPress npm-package-json-lint shareable configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -27,7 +27,7 @@ ], "main": "index.js", "peerDependencies": { - "npm-package-json-lint": ">=3.6.0" + "npm-package-json-lint": ">=6.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/nux/.npmrc b/packages/nux/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/nux/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/nux/CHANGELOG.md b/packages/nux/CHANGELOG.md new file mode 100644 index 00000000000000..5fda6a3c01ac01 --- /dev/null +++ b/packages/nux/CHANGELOG.md @@ -0,0 +1,130 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased + +## 8.2.0 (2023-08-16) + +## 8.1.0 (2023-08-10) + +## 8.0.0 (2023-07-20) + +### Breaking Changes + +- Updated dependencies to require React 18 ([45235](https://github.com/WordPress/gutenberg/pull/45235)) + +## 5.20.0 (2022-11-16) + +## 5.19.0 (2022-11-02) + +## 5.18.0 (2022-10-19) + +## 5.17.0 (2022-10-05) + +## 5.16.0 (2022-09-21) + +## 5.15.0 (2022-09-13) + +## 5.14.0 (2022-08-24) + +## 5.13.0 (2022-08-10) + +## 5.12.0 (2022-07-27) + +## 5.11.0 (2022-07-13) + +## 5.10.0 (2022-06-29) + +## 5.9.0 (2022-06-15) + +## 5.8.0 (2022-06-01) + +## 5.7.0 (2022-05-18) + +## 5.6.0 (2022-05-04) + +## 5.5.0 (2022-04-21) + +## 5.4.0 (2022-04-08) + +## 5.3.0 (2022-03-23) + +## 5.2.0 (2022-03-11) + +## 5.1.0 (2022-01-27) + +## 5.0.0 (2021-07-29) + +### Breaking Change + +- Upgraded React components to work with v17.0 ([#29118](https://github.com/WordPress/gutenberg/pull/29118)). There are no new features in React v17.0 as explained in the [blog post](https://reactjs.org/blog/2020/10/20/react-v17.html). + +## 4.2.0 (2021-07-21) + +## 4.1.0 (2021-05-20) + +## 4.0.0 (2021-05-14) + +### Breaking Changes + +- Drop support for Internet Explorer 11 ([#31110](https://github.com/WordPress/gutenberg/pull/31110)). Learn more at https://make.wordpress.org/core/2021/04/22/ie-11-support-phase-out-plan/. +- Increase the minimum Node.js version to v12 matching Long Term Support releases ([#31270](https://github.com/WordPress/gutenberg/pull/31270)). Learn more at https://nodejs.org/en/about/releases/. + +## 3.25.0 (2021-03-17) + +## 3.24.0 (2020-12-17) + +### New Feature + +- Added a store definition `store` for the core data namespace to use with `@wordpress/data` API ([#26655](https://github.com/WordPress/gutenberg/pull/26655)). + +# 3.1.0 (2019-06-03) + +- The `@wordpress/nux` package has been deprecated. Please use the `Guide` component in `@wordpress/components` to show a user guide. + +## 3.0.6 (2019-01-03) + +## 3.0.5 (2018-12-12) + +## 3.0.4 (2018-11-30) + +## 3.0.3 (2018-11-22) + +## 3.0.2 (2018-11-21) + +## 3.0.1 (2018-11-20) + +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- The id prop of DotTip has been removed. Please use the tipId prop instead. + +## 2.0.13 (2018-11-12) + +## 2.0.12 (2018-11-12) + +## 2.0.11 (2018-11-09) + +## 2.0.10 (2018-11-09) + +## 2.0.9 (2018-11-03) + +## 2.0.8 (2018-10-30) + +## 2.0.7 (2018-10-29) + +### Deprecations + +- The id prop of DotTip has been deprecated. Please use the tipId prop instead. + +## 2.0.6 (2018-10-22) + +## 2.0.5 (2018-10-19) + +## 2.0.4 (2018-10-18) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/packages/nux/README.md b/packages/nux/README.md new file mode 100644 index 00000000000000..c0941ddd0c5f2a --- /dev/null +++ b/packages/nux/README.md @@ -0,0 +1,114 @@ +# New User eXperience (NUX) + +The NUX module exposes components, and `wp.data` methods useful for onboarding a new user to the WordPress admin interface. Specifically, it exposes _tips_ and _guides_. + +A _tip_ is a component that points to an element in the UI and contains text that explains the element's functionality. The user can dismiss a tip, in which case it never shows again. The user can also disable tips entirely. Information about tips is persisted between sessions using `localStorage`. + +A _guide_ allows a series of tips to be presented to the user one by one. When a user dismisses a tip that is in a guide, the next tip in the guide is shown. + +## Installation + +Install the module + +```bash +npm install @wordpress/nux --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._ + +## DotTip + +`DotTip` is a React component that renders a single _tip_ on the screen. The tip will point to the React element that `DotTip` is nested within. Each tip is uniquely identified by a string passed to `tipId`. + +See [the component's README][dot-tip-readme] for more information. + +[dot-tip-readme]: https://github.com/WordPress/gutenberg/tree/HEAD/packages/nux/src/components/dot-tip/README.md + +```jsx +<button onClick={ ... }> + Add to Cart + <DotTip tipId="acme/add-to-cart"> + Click here to add the product to your shopping cart. + </DotTip> +</button> +} +``` + +## Determining if a tip is visible + +You can programmatically determine if a tip is visible using the `isTipVisible` select method. + +```jsx +const isVisible = select( 'core/nux' ).isTipVisible( 'acme/add-to-cart' ); +console.log( isVisible ); // true or false +``` + +## Manually dismissing a tip + +`dismissTip` is a dispatch method that allows you to programmatically dismiss a tip. + +```jsx +<button + onClick={ () => { + dispatch( 'core/nux' ).dismissTip( 'acme/add-to-cart' ); + } } +> + Dismiss tip +</button> +``` + +## Disabling and enabling tips + +Tips can be programatically disabled or enabled using the `disableTips` and `enableTips` dispatch methods. You can query the current setting by using the `areTipsEnabled` select method. + +Calling `enableTips` will also un-dismiss all previously dismissed tips. + +```jsx +const areTipsEnabled = select( 'core/nux' ).areTipsEnabled(); +return ( + <button + onClick={ () => { + if ( areTipsEnabled ) { + dispatch( 'core/nux' ).disableTips(); + } else { + dispatch( 'core/nux' ).enableTips(); + } + } } + > + { areTipsEnabled ? 'Disable tips' : 'Enable tips' } + </button> +); +``` + +## Triggering a guide + +You can group a series of tips into a guide by calling the `triggerGuide` dispatch method. The given tips will then appear one by one. + +A tip cannot be added to more than one guide. + +```jsx +dispatch( 'core/nux' ).triggerGuide( [ + 'acme/product-info', + 'acme/add-to-cart', + 'acme/checkout', +] ); +``` + +## Getting information about a guide + +`getAssociatedGuide` is a select method that returns useful information about the state of the guide that a tip is associated with. + +```jsx +const guide = select( 'core/nux' ).getAssociatedGuide( 'acme/add-to-cart' ); +console.log( 'Tips in this guide:', guide.tipIds ); +console.log( 'Currently showing:', guide.currentTipId ); +console.log( 'Next to show:', guide.nextTipId ); +``` + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/nux/package.json b/packages/nux/package.json new file mode 100644 index 00000000000000..64d56e80ac0ec8 --- /dev/null +++ b/packages/nux/package.json @@ -0,0 +1,50 @@ +{ + "name": "@wordpress/nux", + "version": "8.2.0", + "description": "NUX (New User eXperience) module for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "nux" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/nux/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/nux" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": [ + "build-style/**", + "src/**/*.scss", + "{src,build,build-module}/{index.js,store/index.js}" + ], + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "@wordpress/deprecated": "file:../deprecated", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "rememo": "^4.0.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/nux/src/components/dot-tip/README.md b/packages/nux/src/components/dot-tip/README.md new file mode 100644 index 00000000000000..f143a22a222588 --- /dev/null +++ b/packages/nux/src/components/dot-tip/README.md @@ -0,0 +1,38 @@ +# DotTip + +`DotTip` is a React component that renders a single _tip_ on the screen. The tip will point to the React element that `DotTip` is nested within. Each tip is uniquely identified by a string passed to `tipId`. + +## Usage + +```jsx +<button onClick={ ... }> + Add to Cart + <DotTip tipId="acme/add-to-cart"> + Click here to add the product to your shopping cart. + </DotTip> +</button> +} +``` + +## Props + +The component accepts the following props: + +### tipId + +A string that uniquely identifies the tip. Identifiers should be prefixed with the name of the plugin, followed by a `/`. For example, `acme/add-to-cart`. + +- Type: `string` +- Required: Yes + +### position + +The direction in which the popover should open relative to its parent node. Specify y- and x-axis as a space-separated string. Supports `"top"`, `"middle"`, `"bottom"` y axis, and `"left"`, `"center"`, `"right"` x axis. + +- Type: `String` +- Required: No +- Default: `"middle right"` + +### children + +Any React element or elements can be passed as children. They will be rendered within the tip bubble. diff --git a/packages/nux/src/components/dot-tip/index.js b/packages/nux/src/components/dot-tip/index.js new file mode 100644 index 00000000000000..50de7ddb3be9df --- /dev/null +++ b/packages/nux/src/components/dot-tip/index.js @@ -0,0 +1,93 @@ +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { Popover, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { useCallback, useRef } from '@wordpress/element'; +import { close } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { store as nuxStore } from '../../store'; + +function onClick( event ) { + // Tips are often nested within buttons. We stop propagation so that clicking + // on a tip doesn't result in the button being clicked. + event.stopPropagation(); +} + +export function DotTip( { + position = 'middle right', + children, + isVisible, + hasNextTip, + onDismiss, + onDisable, +} ) { + const anchorParent = useRef( null ); + const onFocusOutsideCallback = useCallback( + ( event ) => { + if ( ! anchorParent.current ) { + return; + } + if ( anchorParent.current.contains( event.relatedTarget ) ) { + return; + } + onDisable(); + }, + [ onDisable, anchorParent ] + ); + if ( ! isVisible ) { + return null; + } + + return ( + <Popover + className="nux-dot-tip" + position={ position } + focusOnMount + role="dialog" + aria-label={ __( 'Editor tips' ) } + onClick={ onClick } + onFocusOutside={ onFocusOutsideCallback } + > + <p>{ children }</p> + <p> + <Button variant="link" onClick={ onDismiss }> + { hasNextTip ? __( 'See next tip' ) : __( 'Got it' ) } + </Button> + </p> + <Button + className="nux-dot-tip__disable" + icon={ close } + label={ __( 'Disable tips' ) } + onClick={ onDisable } + /> + </Popover> + ); +} + +export default compose( + withSelect( ( select, { tipId } ) => { + const { isTipVisible, getAssociatedGuide } = select( nuxStore ); + const associatedGuide = getAssociatedGuide( tipId ); + return { + isVisible: isTipVisible( tipId ), + hasNextTip: !! ( associatedGuide && associatedGuide.nextTipId ), + }; + } ), + withDispatch( ( dispatch, { tipId } ) => { + const { dismissTip, disableTips } = dispatch( nuxStore ); + return { + onDismiss() { + dismissTip( tipId ); + }, + onDisable() { + disableTips(); + }, + }; + } ) +)( DotTip ); diff --git a/packages/nux/src/components/dot-tip/style.scss b/packages/nux/src/components/dot-tip/style.scss new file mode 100644 index 00000000000000..3ab0c1a540fd5a --- /dev/null +++ b/packages/nux/src/components/dot-tip/style.scss @@ -0,0 +1,123 @@ + +$dot-size: 8px; // Size of the indicator dot +$dot-scale: 3; // How much the pulse animation should scale up by in size + +.nux-dot-tip { + &::before, + &::after { + border-radius: 100%; + content: " "; + pointer-events: none; + position: absolute; + } + + &::before { + animation: nux-pulse 1.6s infinite cubic-bezier(0.17, 0.67, 0.92, 0.62); + background: rgba(#00739c, 0.9); + opacity: 0.9; + height: $dot-size * $dot-scale; + left: -($dot-size * $dot-scale) * 0.5; + top: -($dot-size * $dot-scale) * 0.5; + transform: scale(math.div(1, $dot-scale)); + width: $dot-size * $dot-scale; + } + + &::after { + background: #00739c; + height: $dot-size; + left: -$dot-size * 0.5; + top: -$dot-size * 0.5; + width: $dot-size; + } + + @keyframes nux-pulse { + 100% { + background: rgba(#00739c, 0); + transform: scale(1); + } + } + + .components-popover__content { + width: 350px; + padding: 20px 18px; + + @include break-small { + width: 450px; + } + + .nux-dot-tip__disable { + position: absolute; + right: 0; + top: 0; + } + } + + // Position the dot right next to the edge of the button + &[data-y-axis="top"] { + margin-top: -$dot-size * 0.5; + } + &[data-y-axis="bottom"] { + margin-top: $dot-size * 0.5; + } + &[data-y-axis="middle"][data-y-axis="left"] { + margin-left: -$dot-size * 0.5; + } + &[data-y-axis="middle"][data-y-axis="right"] { + margin-left: $dot-size * 0.5; + } + + // Position the tip content away from the dot + &[data-y-axis="top"] .components-popover__content { + margin-bottom: 20px; + } + &[data-y-axis="bottom"] .components-popover__content { + margin-top: 20px; + } + &[data-y-axis="middle"][data-y-axis="left"] .components-popover__content { + margin-right: 20px; + } + &[data-y-axis="middle"][data-y-axis="right"] .components-popover__content { + margin-left: 20px; + } + + // Extra specificity so that we can override the styles in .component-popover + &[data-y-axis="left"], + &[data-y-axis="center"], + &[data-y-axis="right"] { + // Position tips above popovers + z-index: z-index(".nux-dot-tip"); + + // On mobile, always position the tip below the dot and fill the width of the viewport + @media (max-width: $break-small) { + .components-popover__content { + align-self: end; + left: 5px; + margin: 20px 0 0 0; + max-width: none !important; // Override the inline style set by <Popover> + position: fixed; + right: 5px; + width: auto; + } + } + } + + &.components-popover:not([data-y-axis="middle"])[data-y-axis="right"] .components-popover__content { + /*!rtl:ignore*/ + margin-left: 0; + } + + &.components-popover:not([data-y-axis="middle"])[data-y-axis="left"] .components-popover__content { + /*!rtl:ignore*/ + margin-right: 0; + } + + &.components-popover.interface-more-menu-dropdown__content:not([data-y-axis="middle"])[data-y-axis="right"] .components-popover__content { + /*!rtl:ignore*/ + margin-left: -12px; + } + + &.components-popover.interface-more-menu-dropdown__content:not([data-y-axis="middle"])[data-y-axis="left"] .components-popover__content { + /*!rtl:ignore*/ + margin-right: -12px; + } +} diff --git a/packages/nux/src/components/dot-tip/test/__snapshots__/index.js.snap b/packages/nux/src/components/dot-tip/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..bfdb68b78cf578 --- /dev/null +++ b/packages/nux/src/components/dot-tip/test/__snapshots__/index.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DotTip should render correctly 1`] = ` +<div + aria-label="Editor tips" + class="components-popover nux-dot-tip is-positioned" + role="dialog" + style="position: absolute; top: 0px; left: 0px; opacity: 0; transform: translateX(0px) translateY(0px) translateX(-2em) scale(0) translateZ(0); transform-origin: 0% 50% 0;" + tabindex="-1" +> + <div + class="components-popover__content" + style="max-height: 0px; overflow: auto;" + > + <p> + It looks like you’re writing a letter. Would you like help? + </p> + <p> + <button + class="components-button is-link" + type="button" + > + Got it + </button> + </p> + <button + aria-label="Disable tips" + class="components-button nux-dot-tip__disable has-icon" + type="button" + > + <svg + aria-hidden="true" + focusable="false" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" + /> + </svg> + </button> + </div> +</div> +`; diff --git a/packages/nux/src/components/dot-tip/test/index.js b/packages/nux/src/components/dot-tip/test/index.js new file mode 100644 index 00000000000000..8f8fba22dd626a --- /dev/null +++ b/packages/nux/src/components/dot-tip/test/index.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import { DotTip } from '..'; + +describe( 'DotTip', () => { + it( 'should not render anything if invisible', () => { + render( + <DotTip> + It looks like you’re writing a letter. Would you like help? + </DotTip> + ); + + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + } ); + + it( 'should render correctly', async () => { + render( + <DotTip isVisible> + It looks like you’re writing a letter. Would you like help? + </DotTip> + ); + + await waitFor( () => + expect( screen.getByRole( 'dialog' ) ).toBePositionedPopover() + ); + + expect( screen.getByRole( 'dialog' ) ).toMatchSnapshot(); + } ); + + it( 'should call onDismiss when the dismiss button is clicked', async () => { + const user = userEvent.setup(); + const onDismiss = jest.fn(); + + render( + <DotTip isVisible onDismiss={ onDismiss }> + It looks like you’re writing a letter. Would you like help? + </DotTip> + ); + + await waitFor( () => + expect( screen.getByRole( 'dialog' ) ).toBePositionedPopover() + ); + + await user.click( screen.getByRole( 'button', { name: 'Got it' } ) ); + + expect( onDismiss ).toHaveBeenCalled(); + } ); + + it( 'should call onDisable when the X button is clicked', async () => { + const user = userEvent.setup(); + const onDisable = jest.fn(); + + render( + <DotTip isVisible onDisable={ onDisable }> + It looks like you’re writing a letter. Would you like help? + </DotTip> + ); + + await waitFor( () => + expect( screen.getByRole( 'dialog' ) ).toBePositionedPopover() + ); + + await user.click( + screen.getByRole( 'button', { name: 'Disable tips' } ) + ); + + expect( onDisable ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/nux/src/index.js b/packages/nux/src/index.js new file mode 100644 index 00000000000000..a0b3e073503750 --- /dev/null +++ b/packages/nux/src/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + +export { store } from './store'; +export { default as DotTip } from './components/dot-tip'; + +deprecated( 'wp.nux', { + since: '5.4', + hint: 'wp.components.Guide can be used to show a user guide.', + version: '6.2', +} ); diff --git a/packages/nux/src/store/actions.js b/packages/nux/src/store/actions.js new file mode 100644 index 00000000000000..ad8adb79c5530d --- /dev/null +++ b/packages/nux/src/store/actions.js @@ -0,0 +1,52 @@ +/** + * Returns an action object that, when dispatched, presents a guide that takes + * the user through a series of tips step by step. + * + * @param {string[]} tipIds Which tips to show in the guide. + * + * @return {Object} Action object. + */ +export function triggerGuide( tipIds ) { + return { + type: 'TRIGGER_GUIDE', + tipIds, + }; +} + +/** + * Returns an action object that, when dispatched, dismisses the given tip. A + * dismissed tip will not show again. + * + * @param {string} id The tip to dismiss. + * + * @return {Object} Action object. + */ +export function dismissTip( id ) { + return { + type: 'DISMISS_TIP', + id, + }; +} + +/** + * Returns an action object that, when dispatched, prevents all tips from + * showing again. + * + * @return {Object} Action object. + */ +export function disableTips() { + return { + type: 'DISABLE_TIPS', + }; +} + +/** + * Returns an action object that, when dispatched, makes all tips show again. + * + * @return {Object} Action object. + */ +export function enableTips() { + return { + type: 'ENABLE_TIPS', + }; +} diff --git a/packages/nux/src/store/index.js b/packages/nux/src/store/index.js new file mode 100644 index 00000000000000..39fef6c78c7911 --- /dev/null +++ b/packages/nux/src/store/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +const STORE_NAME = 'core/nux'; + +/** + * Store definition for the nux namespace. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore + * + * @type {Object} + */ +export const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, + persist: [ 'preferences' ], +} ); + +// Once we build a more generic persistence plugin that works across types of stores +// we'd be able to replace this with a register call. +registerStore( STORE_NAME, { + reducer, + actions, + selectors, + persist: [ 'preferences' ], +} ); diff --git a/packages/nux/src/store/reducer.js b/packages/nux/src/store/reducer.js new file mode 100644 index 00000000000000..373e4781f52353 --- /dev/null +++ b/packages/nux/src/store/reducer.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; + +/** + * Reducer that tracks which tips are in a guide. Each guide is represented by + * an array which contains the tip identifiers contained within that guide. + * + * @param {Array} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Array} Updated state. + */ +export function guides( state = [], action ) { + switch ( action.type ) { + case 'TRIGGER_GUIDE': + return [ ...state, action.tipIds ]; + } + + return state; +} + +/** + * Reducer that tracks whether or not tips are globally enabled. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function areTipsEnabled( state = true, action ) { + switch ( action.type ) { + case 'DISABLE_TIPS': + return false; + + case 'ENABLE_TIPS': + return true; + } + + return state; +} + +/** + * Reducer that tracks which tips have been dismissed. If the state object + * contains a tip identifier, then that tip is dismissed. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function dismissedTips( state = {}, action ) { + switch ( action.type ) { + case 'DISMISS_TIP': + return { + ...state, + [ action.id ]: true, + }; + + case 'ENABLE_TIPS': + return {}; + } + + return state; +} + +const preferences = combineReducers( { areTipsEnabled, dismissedTips } ); + +export default combineReducers( { guides, preferences } ); diff --git a/packages/nux/src/store/selectors.js b/packages/nux/src/store/selectors.js new file mode 100644 index 00000000000000..e87cf688a1ba32 --- /dev/null +++ b/packages/nux/src/store/selectors.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +/** + * An object containing information about a guide. + * + * @typedef {Object} NUXGuideInfo + * @property {string[]} tipIds Which tips the guide contains. + * @property {?string} currentTipId The guide's currently showing tip. + * @property {?string} nextTipId The guide's next tip to show. + */ + +/** + * Returns an object describing the guide, if any, that the given tip is a part + * of. + * + * @param {Object} state Global application state. + * @param {string} tipId The tip to query. + * + * @return {?NUXGuideInfo} Information about the associated guide. + */ +export const getAssociatedGuide = createSelector( + ( state, tipId ) => { + for ( const tipIds of state.guides ) { + if ( tipIds.includes( tipId ) ) { + const nonDismissedTips = tipIds.filter( + ( tId ) => + ! Object.keys( + state.preferences.dismissedTips + ).includes( tId ) + ); + const [ currentTipId = null, nextTipId = null ] = + nonDismissedTips; + return { tipIds, currentTipId, nextTipId }; + } + } + + return null; + }, + ( state ) => [ state.guides, state.preferences.dismissedTips ] +); + +/** + * Determines whether or not the given tip is showing. Tips are hidden if they + * are disabled, have been dismissed, or are not the current tip in any + * guide that they have been added to. + * + * @param {Object} state Global application state. + * @param {string} tipId The tip to query. + * + * @return {boolean} Whether or not the given tip is showing. + */ +export function isTipVisible( state, tipId ) { + if ( ! state.preferences.areTipsEnabled ) { + return false; + } + + if ( state.preferences.dismissedTips?.hasOwnProperty( tipId ) ) { + return false; + } + + const associatedGuide = getAssociatedGuide( state, tipId ); + if ( associatedGuide && associatedGuide.currentTipId !== tipId ) { + return false; + } + + return true; +} + +/** + * Returns whether or not tips are globally enabled. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether tips are globally enabled. + */ +export function areTipsEnabled( state ) { + return state.preferences.areTipsEnabled; +} diff --git a/packages/nux/src/store/test/actions.js b/packages/nux/src/store/test/actions.js new file mode 100644 index 00000000000000..4e22afe03c8b82 --- /dev/null +++ b/packages/nux/src/store/test/actions.js @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import { triggerGuide, dismissTip, disableTips, enableTips } from '../actions'; + +describe( 'actions', () => { + describe( 'triggerGuide', () => { + it( 'should return a TRIGGER_GUIDE action', () => { + expect( triggerGuide( [ 'test/tip-1', 'test/tip-2' ] ) ).toEqual( { + type: 'TRIGGER_GUIDE', + tipIds: [ 'test/tip-1', 'test/tip-2' ], + } ); + } ); + } ); + + describe( 'dismissTip', () => { + it( 'should return an DISMISS_TIP action', () => { + expect( dismissTip( 'test/tip' ) ).toEqual( { + type: 'DISMISS_TIP', + id: 'test/tip', + } ); + } ); + } ); + + describe( 'disableTips', () => { + it( 'should return an DISABLE_TIPS action', () => { + expect( disableTips() ).toEqual( { + type: 'DISABLE_TIPS', + } ); + } ); + } ); + + describe( 'enableTips', () => { + it( 'should return an ENABLE_TIPS action', () => { + expect( enableTips() ).toEqual( { + type: 'ENABLE_TIPS', + } ); + } ); + } ); +} ); diff --git a/packages/nux/src/store/test/reducer.js b/packages/nux/src/store/test/reducer.js new file mode 100644 index 00000000000000..49172442d8f379 --- /dev/null +++ b/packages/nux/src/store/test/reducer.js @@ -0,0 +1,69 @@ +/** + * Internal dependencies + */ +import { guides, areTipsEnabled, dismissedTips } from '../reducer'; + +describe( 'reducer', () => { + describe( 'guides', () => { + it( 'should start out empty', () => { + expect( guides( undefined, {} ) ).toEqual( [] ); + } ); + + it( 'should add a guide when it is triggered', () => { + const state = guides( [], { + type: 'TRIGGER_GUIDE', + tipIds: [ 'test/tip-1', 'test/tip-2' ], + } ); + expect( state ).toEqual( [ [ 'test/tip-1', 'test/tip-2' ] ] ); + } ); + } ); + + describe( 'areTipsEnabled', () => { + it( 'should default to true', () => { + expect( areTipsEnabled( undefined, {} ) ).toBe( true ); + } ); + + it( 'should flip when tips are disabled', () => { + const state = areTipsEnabled( true, { + type: 'DISABLE_TIPS', + } ); + expect( state ).toBe( false ); + } ); + + it( 'should flip when tips are enabled', () => { + const state = areTipsEnabled( false, { + type: 'ENABLE_TIPS', + } ); + expect( state ).toBe( true ); + } ); + } ); + + describe( 'dismissedTips', () => { + it( 'should start out empty', () => { + expect( dismissedTips( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'should mark tips as dismissed', () => { + const state = dismissedTips( + {}, + { + type: 'DISMISS_TIP', + id: 'test/tip', + } + ); + expect( state ).toEqual( { + 'test/tip': true, + } ); + } ); + + it( 'should reset if tips are enabled', () => { + const initialState = { + 'test/tip': true, + }; + const state = dismissedTips( initialState, { + type: 'ENABLE_TIPS', + } ); + expect( state ).toEqual( {} ); + } ); + } ); +} ); diff --git a/packages/nux/src/store/test/selectors.js b/packages/nux/src/store/test/selectors.js new file mode 100644 index 00000000000000..e2a06c74e08b68 --- /dev/null +++ b/packages/nux/src/store/test/selectors.js @@ -0,0 +1,146 @@ +/** + * Internal dependencies + */ +import { getAssociatedGuide, isTipVisible, areTipsEnabled } from '../selectors'; + +describe( 'selectors', () => { + describe( 'getAssociatedGuide', () => { + const state = { + guides: [ + [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ], + [ 'test/tip-a', 'test/tip-b', 'test/tip-c' ], + [ 'test/tip-α', 'test/tip-β', 'test/tip-γ' ], + ], + preferences: { + dismissedTips: { + 'test/tip-1': true, + 'test/tip-a': true, + 'test/tip-b': true, + 'test/tip-α': true, + 'test/tip-β': true, + 'test/tip-γ': true, + }, + }, + }; + + it( 'should return null when there is no associated guide', () => { + expect( getAssociatedGuide( state, 'test/unknown' ) ).toBeNull(); + } ); + + it( 'should return the associated guide', () => { + expect( getAssociatedGuide( state, 'test/tip-2' ) ).toEqual( { + tipIds: [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ], + currentTipId: 'test/tip-2', + nextTipId: 'test/tip-3', + } ); + } ); + + it( 'should indicate when there is no next tip', () => { + expect( getAssociatedGuide( state, 'test/tip-b' ) ).toEqual( { + tipIds: [ 'test/tip-a', 'test/tip-b', 'test/tip-c' ], + currentTipId: 'test/tip-c', + nextTipId: null, + } ); + } ); + + it( 'should indicate when there is no current or next tip', () => { + expect( getAssociatedGuide( state, 'test/tip-β' ) ).toEqual( { + tipIds: [ 'test/tip-α', 'test/tip-β', 'test/tip-γ' ], + currentTipId: null, + nextTipId: null, + } ); + } ); + } ); + + describe( 'isTipVisible', () => { + it( 'is tolerant to individual preferences being undefined', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + guides: [], + preferences: {}, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'is tolerant to undefined dismissedTips', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( true ); + } ); + + it( 'should return true by default', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( true ); + } ); + + it( 'should return false if tips are disabled', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: false, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'should return false if the tip is dismissed', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + dismissedTips: { + 'test/tip': true, + }, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'should return false if the tip is in a guide and it is not the current tip', () => { + const state = { + guides: [ [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ] ], + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip-2' ) ).toBe( false ); + } ); + } ); + + describe( 'areTipsEnabled', () => { + it( 'should return true if tips are enabled', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( areTipsEnabled( state ) ).toBe( true ); + } ); + + it( 'should return false if tips are disabled', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: false, + dismissedTips: {}, + }, + }; + expect( areTipsEnabled( state ) ).toBe( false ); + } ); + } ); +} ); diff --git a/packages/nux/src/style.scss b/packages/nux/src/style.scss new file mode 100644 index 00000000000000..0df73ff851e9f9 --- /dev/null +++ b/packages/nux/src/style.scss @@ -0,0 +1 @@ +@import "./components/dot-tip/style.scss"; diff --git a/packages/patterns/.npmrc b/packages/patterns/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/patterns/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/patterns/CHANGELOG.md b/packages/patterns/CHANGELOG.md new file mode 100644 index 00000000000000..694450fd27090c --- /dev/null +++ b/packages/patterns/CHANGELOG.md @@ -0,0 +1,7 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased + +## 1.1.0 (2023-08-16) + +Initial release. diff --git a/packages/patterns/README.md b/packages/patterns/README.md new file mode 100644 index 00000000000000..1123805836f2ab --- /dev/null +++ b/packages/patterns/README.md @@ -0,0 +1,26 @@ +# Patterns + +> **Note** +> This package is currently only used internally by the Gutenberg project to manage the creation and editing of user patterns using the `wp_block` CPT in the context of the block editor. The likes of the `PatternsMenuItems` component expect to be rendered within a `BlockEditorProvider` in order to work. + +## Installation + +Install the module + +```bash +npm install @wordpress/patterns --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._ + +## Components + +This package doesn't currently have any publically exported components. + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/patterns/package.json b/packages/patterns/package.json new file mode 100644 index 00000000000000..59a783cc436b59 --- /dev/null +++ b/packages/patterns/package.json @@ -0,0 +1,54 @@ +{ + "name": "@wordpress/patterns", + "version": "1.1.0", + "description": "Management of user pattern editing.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "patterns" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/patterns/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/patterns" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8 <9" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": [ + "build-style/**", + "src/**/*.scss", + "{src,build,build-module}/{index.js,store/index.js,hooks/**}" + ], + "dependencies": { + "@wordpress/block-editor": "file:../block-editor", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/notices": "file:../notices", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/url": "file:../url" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/patterns/src/components/create-pattern-modal.js b/packages/patterns/src/components/create-pattern-modal.js new file mode 100644 index 00000000000000..a6aa4007764fcc --- /dev/null +++ b/packages/patterns/src/components/create-pattern-modal.js @@ -0,0 +1,121 @@ +/** + * WordPress dependencies + */ +import { + Modal, + Button, + TextControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + ToggleControl, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useState, useCallback } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; + +export const USER_PATTERN_CATEGORY = 'my-patterns'; + +export const SYNC_TYPES = { + full: undefined, + unsynced: 'unsynced', +}; + +/** + * Internal dependencies + */ +import { store } from '../store'; + +export default function CreatePatternModal( { + onSuccess, + onError, + clientIds, + onClose, + className = 'patterns-menu-items__convert-modal', +} ) { + const [ syncType, setSyncType ] = useState( SYNC_TYPES.full ); + const [ title, setTitle ] = useState( '' ); + const { __experimentalCreatePattern: createPattern } = useDispatch( store ); + + const { createErrorNotice } = useDispatch( noticesStore ); + const onCreate = useCallback( + async function ( patternTitle, sync ) { + try { + const newPattern = await createPattern( + patternTitle, + sync, + clientIds + ); + onSuccess( { + pattern: newPattern, + categoryId: USER_PATTERN_CATEGORY, + } ); + } catch ( error ) { + createErrorNotice( error.message, { + type: 'snackbar', + id: 'convert-to-pattern-error', + } ); + onError(); + } + }, + [ createPattern, clientIds, onSuccess, createErrorNotice, onError ] + ); + return ( + <Modal + title={ __( 'Create pattern' ) } + onRequestClose={ () => { + onClose(); + setTitle( '' ); + } } + overlayClassName={ className } + > + <form + onSubmit={ ( event ) => { + event.preventDefault(); + onCreate( title, syncType ); + setTitle( '' ); + } } + > + <VStack spacing="5"> + <TextControl + __nextHasNoMarginBottom + label={ __( 'Name' ) } + value={ title } + onChange={ setTitle } + placeholder={ __( 'My pattern' ) } + /> + + <ToggleControl + label={ __( 'Synced' ) } + help={ __( + 'Editing the pattern will update it anywhere it is used.' + ) } + checked={ ! syncType } + onChange={ () => { + setSyncType( + syncType === SYNC_TYPES.full + ? SYNC_TYPES.unsynced + : SYNC_TYPES.full + ); + } } + /> + <HStack justify="right"> + <Button + variant="tertiary" + onClick={ () => { + onClose(); + setTitle( '' ); + } } + > + { __( 'Cancel' ) } + </Button> + + <Button variant="primary" type="submit"> + { __( 'Create' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> + ); +} diff --git a/packages/patterns/src/components/index.js b/packages/patterns/src/components/index.js new file mode 100644 index 00000000000000..a00d2d2bd262e2 --- /dev/null +++ b/packages/patterns/src/components/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { BlockSettingsMenuControls } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import PatternConvertButton from './pattern-convert-button'; +import PatternsManageButton from './patterns-manage-button'; + +export default function PatternsMenuItems( { rootClientId } ) { + return ( + <BlockSettingsMenuControls> + { ( { selectedClientIds } ) => ( + <> + <PatternConvertButton + clientIds={ selectedClientIds } + rootClientId={ rootClientId } + /> + { selectedClientIds.length === 1 && ( + <PatternsManageButton + clientId={ selectedClientIds[ 0 ] } + /> + ) } + </> + ) } + </BlockSettingsMenuControls> + ); +} diff --git a/packages/patterns/src/components/pattern-convert-button.js b/packages/patterns/src/components/pattern-convert-button.js new file mode 100644 index 00000000000000..b2164877a18fc2 --- /dev/null +++ b/packages/patterns/src/components/pattern-convert-button.js @@ -0,0 +1,128 @@ +/** + * WordPress dependencies + */ +import { hasBlockSupport, isReusableBlock } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; +import { MenuItem } from '@wordpress/components'; +import { symbol } from '@wordpress/icons'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +/** + * Internal dependencies + */ +import CreatePatternModal from './create-pattern-modal'; + +/** + * Menu control to convert block(s) to a pattern block. + * + * @param {Object} props Component props. + * @param {string[]} props.clientIds Client ids of selected blocks. + * @param {string} props.rootClientId ID of the currently selected top-level block. + * @return {import('@wordpress/element').WPComponent} The menu control or null. + */ +export default function PatternConvertButton( { clientIds, rootClientId } ) { + const { createSuccessNotice } = useDispatch( noticesStore ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const canConvert = useSelect( + ( select ) => { + const { canUser } = select( coreStore ); + const { + getBlocksByClientId, + canInsertBlockType, + getBlockRootClientId, + } = select( blockEditorStore ); + + const rootId = + rootClientId || + ( clientIds.length > 0 + ? getBlockRootClientId( clientIds[ 0 ] ) + : undefined ); + + const blocks = getBlocksByClientId( clientIds ) ?? []; + + const isReusable = + blocks.length === 1 && + blocks[ 0 ] && + isReusableBlock( blocks[ 0 ] ) && + !! select( coreStore ).getEntityRecord( + 'postType', + 'wp_block', + blocks[ 0 ].attributes.ref + ); + + const _canConvert = + // Hide when this is already a synced pattern. + ! isReusable && + // Hide when patterns are disabled. + canInsertBlockType( 'core/block', rootId ) && + blocks.every( + ( block ) => + // Guard against the case where a regular block has *just* been converted. + !! block && + // Hide on invalid blocks. + block.isValid && + // Hide when block doesn't support being made into a pattern. + hasBlockSupport( block.name, 'reusable', true ) + ) && + // Hide when current doesn't have permission to do that. + !! canUser( 'create', 'blocks' ); + + return _canConvert; + }, + [ clientIds, rootClientId ] + ); + + if ( ! canConvert ) { + return null; + } + + const handleSuccess = ( { pattern } ) => { + createSuccessNotice( + pattern.wp_pattern_sync_status === 'unsynced' + ? sprintf( + // translators: %s: the name the user has given to the pattern. + __( 'Unsynced Pattern created: %s' ), + pattern.title.raw + ) + : sprintf( + // translators: %s: the name the user has given to the pattern. + __( 'Synced Pattern created: %s' ), + pattern.title.raw + ), + { + type: 'snackbar', + id: 'convert-to-pattern-success', + } + ); + setIsModalOpen( false ); + }; + return ( + <> + <MenuItem + icon={ symbol } + onClick={ () => setIsModalOpen( true ) } + aria-expanded={ isModalOpen } + aria-haspopup="dialog" + > + { __( 'Create pattern' ) } + </MenuItem> + { isModalOpen && ( + <CreatePatternModal + clientIds={ clientIds } + onSuccess={ ( pattern ) => { + handleSuccess( pattern ); + } } + onError={ () => { + setIsModalOpen( false ); + } } + onClose={ () => { + setIsModalOpen( false ); + } } + /> + ) } + </> + ); +} diff --git a/packages/patterns/src/components/patterns-manage-button.js b/packages/patterns/src/components/patterns-manage-button.js new file mode 100644 index 00000000000000..bfa36521a28c1f --- /dev/null +++ b/packages/patterns/src/components/patterns-manage-button.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { isReusableBlock } from '@wordpress/blocks'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { addQueryArgs } from '@wordpress/url'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; + +function PatternsManageButton( { clientId } ) { + const { canRemove, isVisible, innerBlockCount, managePatternsUrl } = + useSelect( + ( select ) => { + const { getBlock, canRemoveBlock, getBlockCount, getSettings } = + select( blockEditorStore ); + const { canUser } = select( coreStore ); + const reusableBlock = getBlock( clientId ); + const isBlockTheme = getSettings().__unstableIsBlockBasedTheme; + + return { + canRemove: canRemoveBlock( clientId ), + isVisible: + !! reusableBlock && + isReusableBlock( reusableBlock ) && + !! canUser( + 'update', + 'blocks', + reusableBlock.attributes.ref + ), + innerBlockCount: getBlockCount( clientId ), + // The site editor and templates both check whether the user + // has edit_theme_options capabilities. We can leverage that here + // and omit the manage patterns link if the user can't access it. + managePatternsUrl: + isBlockTheme && canUser( 'read', 'templates' ) + ? addQueryArgs( 'site-editor.php', { + path: '/patterns', + } ) + : addQueryArgs( 'edit.php', { + post_type: 'wp_block', + } ), + }; + }, + [ clientId ] + ); + + const { __experimentalConvertSyncedPatternToStatic: convertBlockToStatic } = + useDispatch( editorStore ); + + if ( ! isVisible ) { + return null; + } + + return ( + <> + <MenuItem href={ managePatternsUrl }> + { __( 'Manage patterns' ) } + </MenuItem> + { canRemove && ( + <MenuItem onClick={ () => convertBlockToStatic( clientId ) }> + { innerBlockCount > 1 + ? __( 'Detach patterns' ) + : __( 'Detach pattern' ) } + </MenuItem> + ) } + </> + ); +} + +export default PatternsManageButton; diff --git a/packages/patterns/src/components/style.scss b/packages/patterns/src/components/style.scss new file mode 100644 index 00000000000000..ddd2656dc7d41d --- /dev/null +++ b/packages/patterns/src/components/style.scss @@ -0,0 +1,3 @@ +.patterns-menu-items__convert-modal { + z-index: z-index(".patterns-menu-items__convert-modal"); +} diff --git a/packages/patterns/src/index.js b/packages/patterns/src/index.js new file mode 100644 index 00000000000000..ed74eba99ffae2 --- /dev/null +++ b/packages/patterns/src/index.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import './store'; + +export * from './private-apis'; diff --git a/packages/patterns/src/index.native.js b/packages/patterns/src/index.native.js new file mode 100644 index 00000000000000..2a9451884c69fd --- /dev/null +++ b/packages/patterns/src/index.native.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import './store'; +import { lock } from './lock-unlock'; + +export const privateApis = {}; +lock( privateApis, { + CreatePatternModal: () => null, + PatternsMenuItems: () => null, +} ); diff --git a/packages/patterns/src/lock-unlock.js b/packages/patterns/src/lock-unlock.js new file mode 100644 index 00000000000000..51adc98f32cac8 --- /dev/null +++ b/packages/patterns/src/lock-unlock.js @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/patterns' + ); diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js new file mode 100644 index 00000000000000..31f507c2b99143 --- /dev/null +++ b/packages/patterns/src/private-apis.js @@ -0,0 +1,12 @@ +/** + * Internal dependencies + */ +import { lock } from './lock-unlock'; +import CreatePatternModal from './components/create-pattern-modal'; +import PatternsMenuItems from './components'; + +export const privateApis = {}; +lock( privateApis, { + CreatePatternModal, + PatternsMenuItems, +} ); diff --git a/packages/patterns/src/store/actions.js b/packages/patterns/src/store/actions.js new file mode 100644 index 00000000000000..2128526b2b3194 --- /dev/null +++ b/packages/patterns/src/store/actions.js @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ + +import { parse, serialize, createBlock } from '@wordpress/blocks'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Returns a generator converting one or more static blocks into a pattern, or creating a new empty pattern. + * + * @param {string} title Pattern title. + * @param {'full'|'unsynced'} syncType They way block is synced, 'full' or 'unsynced'. + * @param {string[]|undefined} clientIds Optional client IDs of blocks to convert to pattern. + */ +export const __experimentalCreatePattern = + ( title, syncType, clientIds ) => + async ( { registry, dispatch } ) => { + const meta = + syncType === 'unsynced' + ? { + wp_pattern_sync_status: syncType, + } + : undefined; + + const reusableBlock = { + title, + content: clientIds + ? serialize( + registry + .select( blockEditorStore ) + .getBlocksByClientId( clientIds ) + ) + : undefined, + status: 'publish', + meta, + }; + + const updatedRecord = await registry + .dispatch( coreStore ) + .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); + + if ( syncType === 'unsynced' || ! clientIds ) { + return updatedRecord; + } + + const newBlock = createBlock( 'core/block', { + ref: updatedRecord.id, + } ); + registry + .dispatch( blockEditorStore ) + .replaceBlocks( clientIds, newBlock ); + dispatch.__experimentalSetEditingPattern( newBlock.clientId, true ); + return updatedRecord; + }; + +/** + * Returns a generator converting a synced pattern block into a static block. + * + * @param {string} clientId The client ID of the block to attach. + */ +export const __experimentalConvertSyncedPatternToStatic = + ( clientId ) => + ( { registry } ) => { + const oldBlock = registry + .select( blockEditorStore ) + .getBlock( clientId ); + const pattern = registry + .select( 'core' ) + .getEditedEntityRecord( + 'postType', + 'wp_block', + oldBlock.attributes.ref + ); + + const newBlocks = parse( + typeof pattern.content === 'function' + ? pattern.content( pattern ) + : pattern.content + ); + registry + .dispatch( blockEditorStore ) + .replaceBlocks( oldBlock.clientId, newBlocks ); + }; + +/** + * Returns an action descriptor for SET_EDITING_PATTERN action. + * + * @param {string} clientId The clientID of the pattern to target. + * @param {boolean} isEditing Whether the block should be in editing state. + * @return {Object} Action descriptor. + */ +export function __experimentalSetEditingPattern( clientId, isEditing ) { + return { + type: 'SET_EDITING_PATTERN', + clientId, + isEditing, + }; +} diff --git a/packages/patterns/src/store/constants.js b/packages/patterns/src/store/constants.js new file mode 100644 index 00000000000000..138fc6d21d1f2f --- /dev/null +++ b/packages/patterns/src/store/constants.js @@ -0,0 +1,4 @@ +/** + * Module Constants + */ +export const STORE_NAME = 'core/patterns'; diff --git a/packages/patterns/src/store/index.js b/packages/patterns/src/store/index.js new file mode 100644 index 00000000000000..6293a7b33408e1 --- /dev/null +++ b/packages/patterns/src/store/index.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; + +/** + * Post editor data store configuration. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#registerStore + * + * @type {Object} + */ +export const storeConfig = { + reducer, + selectors, + actions, +}; + +/** + * Store definition for the editor namespace. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore + * + * @type {Object} + */ +export const store = createReduxStore( STORE_NAME, { + ...storeConfig, +} ); + +register( store ); diff --git a/packages/patterns/src/store/reducer.js b/packages/patterns/src/store/reducer.js new file mode 100644 index 00000000000000..b74cd29f55dc4d --- /dev/null +++ b/packages/patterns/src/store/reducer.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; + +export function isEditingPattern( state = {}, action ) { + if ( action?.type === 'SET_EDITING_PATTERN' ) { + return { + ...state, + [ action.clientId ]: action.isEditing, + }; + } + + return state; +} + +export default combineReducers( { + isEditingPattern, +} ); diff --git a/packages/patterns/src/store/selectors.js b/packages/patterns/src/store/selectors.js new file mode 100644 index 00000000000000..3089737c856c86 --- /dev/null +++ b/packages/patterns/src/store/selectors.js @@ -0,0 +1,10 @@ +/** + * Returns true if pattern is in the editing state. + * + * @param {Object} state Global application state. + * @param {number} clientId the clientID of the block. + * @return {boolean} Whether the pattern is in the editing state. + */ +export function __experimentalIsEditingPattern( state, clientId ) { + return state.isEditingPattern[ clientId ]; +} diff --git a/packages/patterns/src/style.scss b/packages/patterns/src/style.scss new file mode 100644 index 00000000000000..e8cce79ec9f956 --- /dev/null +++ b/packages/patterns/src/style.scss @@ -0,0 +1 @@ +@import "./components/style.scss"; diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md index 780cbe9bf6e565..2fe5c6cca77b88 100644 --- a/packages/plugins/CHANGELOG.md +++ b/packages/plugins/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 6.8.0 (2023-08-16) + +## 6.7.0 (2023-08-10) + +## 6.6.0 (2023-07-20) + +## 6.5.0 (2023-07-05) + +## 6.4.0 (2023-06-23) + +## 6.3.0 (2023-06-07) + ## 6.2.0 (2023-05-24) ## 6.1.0 (2023-05-10) diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 99bfd3d7bd74de..a0e8441513e5d7 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -182,6 +182,14 @@ _Returns_ - `WPPlugin | undefined`: The previous plugin settings object, if it has been successfully unregistered; otherwise `undefined`. +#### usePluginContext + +A hook that returns the plugin context. + +_Returns_ + +- `PluginContext`: Plugin context + #### withPluginContext A Higher Order Component used to inject Plugin context to the wrapped component. diff --git a/packages/plugins/package.json b/packages/plugins/package.json index a0899d2f10c016..83a3cf295447a3 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/plugins", - "version": "6.2.0", + "version": "6.8.0", "description": "Plugins module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -36,7 +36,8 @@ "memize": "^2.0.1" }, "peerDependencies": { - "react": "^18.0.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/src/components/index.js b/packages/plugins/src/components/index.js index dae012a5cadefc..12a9a96808bc6a 100644 --- a/packages/plugins/src/components/index.js +++ b/packages/plugins/src/components/index.js @@ -1,2 +1,2 @@ export { default as PluginArea } from './plugin-area'; -export { withPluginContext } from './plugin-context'; +export { usePluginContext, withPluginContext } from './plugin-context'; diff --git a/packages/plugins/src/components/plugin-context/index.tsx b/packages/plugins/src/components/plugin-context/index.tsx index fe4fa4cecfa074..76fbdabe048293 100644 --- a/packages/plugins/src/components/plugin-context/index.tsx +++ b/packages/plugins/src/components/plugin-context/index.tsx @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { createContext } from '@wordpress/element'; +import { createContext, useContext } from '@wordpress/element'; import { createHigherOrderComponent } from '@wordpress/compose'; /** @@ -14,12 +14,21 @@ export interface PluginContext { icon: null | WPPlugin[ 'icon' ]; } -const { Consumer, Provider } = createContext< PluginContext >( { +const Context = createContext< PluginContext >( { name: null, icon: null, } ); -export { Provider as PluginContextProvider }; +export const PluginContextProvider = Context.Provider; + +/** + * A hook that returns the plugin context. + * + * @return {PluginContext} Plugin context + */ +export function usePluginContext() { + return useContext( Context ); +} /** * A Higher Order Component used to inject Plugin context to the @@ -39,13 +48,13 @@ export const withPluginContext = ( ) => createHigherOrderComponent( ( OriginalComponent ) => { return ( props ) => ( - <Consumer> + <Context.Consumer> { ( context ) => ( <OriginalComponent { ...props } { ...mapContextToProps( context, props ) } /> ) } - </Consumer> + </Context.Consumer> ); }, 'withPluginContext' ); diff --git a/packages/postcss-plugins-preset/CHANGELOG.md b/packages/postcss-plugins-preset/CHANGELOG.md index 8bb9da3b6cedaf..b9e20bdb6fc0e2 100644 --- a/packages/postcss-plugins-preset/CHANGELOG.md +++ b/packages/postcss-plugins-preset/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.24.0 (2023-08-16) + +## 4.23.0 (2023-08-10) + +## 4.22.0 (2023-07-20) + +## 4.21.0 (2023-07-05) + +## 4.20.0 (2023-06-23) + +## 4.19.0 (2023-06-07) + ## 4.18.0 (2023-05-24) ## 4.17.0 (2023-05-10) diff --git a/packages/postcss-plugins-preset/package.json b/packages/postcss-plugins-preset/package.json index fa7289e1418c31..642fd74ff72e9f 100644 --- a/packages/postcss-plugins-preset/package.json +++ b/packages/postcss-plugins-preset/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-plugins-preset", - "version": "4.18.0", + "version": "4.24.0", "description": "PostCSS sharable plugins preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/postcss-themes/CHANGELOG.md b/packages/postcss-themes/CHANGELOG.md index 8c4c735fd4fb70..4bde8d7a96fe24 100644 --- a/packages/postcss-themes/CHANGELOG.md +++ b/packages/postcss-themes/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 5.23.0 (2023-08-16) + +## 5.22.0 (2023-08-10) + +## 5.21.0 (2023-07-20) + +## 5.20.0 (2023-07-05) + +## 5.19.0 (2023-06-23) + +## 5.18.0 (2023-06-07) + ## 5.17.0 (2023-05-24) ## 5.16.0 (2023-05-10) diff --git a/packages/postcss-themes/package.json b/packages/postcss-themes/package.json index 32c246c784deb2..278a50a6c14d22 100644 --- a/packages/postcss-themes/package.json +++ b/packages/postcss-themes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-themes", - "version": "5.17.0", + "version": "5.23.0", "description": "PostCSS plugin to generate theme colors.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/preferences-persistence/CHANGELOG.md b/packages/preferences-persistence/CHANGELOG.md index 21d8edb2908110..2ce6d991ba5ad2 100644 --- a/packages/preferences-persistence/CHANGELOG.md +++ b/packages/preferences-persistence/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 1.32.0 (2023-08-16) + +## 1.31.0 (2023-08-10) + +## 1.30.0 (2023-07-20) + +## 1.29.0 (2023-07-05) + +## 1.28.0 (2023-06-23) + +## 1.27.0 (2023-06-07) + ## 1.26.0 (2023-05-24) ## 1.25.0 (2023-05-10) diff --git a/packages/preferences-persistence/package.json b/packages/preferences-persistence/package.json index 0dae87660cbe77..d43098c3908c4f 100644 --- a/packages/preferences-persistence/package.json +++ b/packages/preferences-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences-persistence", - "version": "1.26.0", + "version": "1.32.0", "description": "Persistence utilities for `wordpress/preferences`.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/preferences/CHANGELOG.md b/packages/preferences/CHANGELOG.md index a384684ac40289..cf151618f2e357 100644 --- a/packages/preferences/CHANGELOG.md +++ b/packages/preferences/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.17.0 (2023-08-16) + +## 3.16.0 (2023-08-10) + +## 3.15.0 (2023-07-20) + +## 3.14.0 (2023-07-05) + +## 3.13.0 (2023-06-23) + +## 3.12.0 (2023-06-07) + ## 3.11.0 (2023-05-24) ## 3.10.0 (2023-05-10) diff --git a/packages/preferences/package.json b/packages/preferences/package.json index ee9e84160836e8..a452fea6f113bd 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences", - "version": "3.11.0", + "version": "3.17.0", "description": "Utilities for managing WordPress preferences.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -32,6 +32,7 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/components": "file:../components", "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", "classnames": "^2.3.1" diff --git a/packages/preferences/src/components/preference-toggle-menu-item/index.js b/packages/preferences/src/components/preference-toggle-menu-item/index.js index 2797e6455bc0b1..9d2b8d353e9448 100644 --- a/packages/preferences/src/components/preference-toggle-menu-item/index.js +++ b/packages/preferences/src/components/preference-toggle-menu-item/index.js @@ -25,7 +25,7 @@ export default function PreferenceToggleMenuItem( { } ) { const isActive = useSelect( ( select ) => !! select( preferencesStore ).get( scope, name ), - [ name ] + [ scope, name ] ); const { toggle } = useDispatch( preferencesStore ); const speakMessage = () => { diff --git a/packages/prettier-config/CHANGELOG.md b/packages/prettier-config/CHANGELOG.md index 05c7ff23ab26cb..96744789fa9e56 100644 --- a/packages/prettier-config/CHANGELOG.md +++ b/packages/prettier-config/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.23.0 (2023-08-16) + +## 2.22.0 (2023-08-10) + +## 2.21.0 (2023-07-20) + +## 2.20.0 (2023-07-05) + +## 2.19.0 (2023-06-23) + +## 2.18.0 (2023-06-07) + ## 2.17.0 (2023-05-24) ## 2.16.0 (2023-05-10) diff --git a/packages/prettier-config/package.json b/packages/prettier-config/package.json index 1a12dc941e1888..32d0d1347f62c6 100644 --- a/packages/prettier-config/package.json +++ b/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/prettier-config", - "version": "2.17.0", + "version": "2.23.0", "description": "WordPress Prettier shared configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/primitives/CHANGELOG.md b/packages/primitives/CHANGELOG.md index a41bf8ee46c13f..9fa6f6f9dc02b2 100644 --- a/packages/primitives/CHANGELOG.md +++ b/packages/primitives/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.38.0 (2023-08-16) + +## 3.37.0 (2023-08-10) + +## 3.36.0 (2023-07-20) + +## 3.35.0 (2023-07-05) + +## 3.34.0 (2023-06-23) + +## 3.33.0 (2023-06-07) + ## 3.32.0 (2023-05-24) ## 3.31.0 (2023-05-10) diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 1fa1f2476d2367..05167c44277594 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/primitives", - "version": "3.32.0", + "version": "3.38.0", "description": "WordPress cross-platform primitives.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/primitives/src/horizontal-rule/index.native.js b/packages/primitives/src/horizontal-rule/index.native.js index 853b57e76d0c1a..67906b692935e9 100644 --- a/packages/primitives/src/horizontal-rule/index.native.js +++ b/packages/primitives/src/horizontal-rule/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import Hr from 'react-native-hr'; +import { Text, View } from 'react-native'; /** * WordPress dependencies @@ -13,16 +13,46 @@ import { withPreferredColorScheme } from '@wordpress/compose'; */ import styles from './styles.scss'; -const HR = ( { getStylesFromColorScheme, ...props } ) => { - const lineStyle = getStylesFromColorScheme( styles.line, styles.lineDark ); +const HR = ( { + getStylesFromColorScheme, + lineStyle, + marginLeft, + marginRight, + style, + textStyle, + text, + ...props +} ) => { + const renderLine = ( key ) => ( + <View + key={ key } + style={ [ + getStylesFromColorScheme( styles.line, styles.lineDark ), + lineStyle, + ] } + /> + ); + + const renderText = ( key ) => ( + <View key={ key } style={ styles.textContainer }> + <Text style={ [ styles.text, textStyle ] }>{ text }</Text> + </View> + ); + + const renderInner = () => { + if ( ! text ) { + return renderLine(); + } + return [ renderLine( 1 ), renderText( 2 ), renderLine( 3 ) ]; + }; return ( - <Hr + <View + style={ [ styles.container, { marginLeft, marginRight }, style ] } { ...props } - lineStyle={ [ lineStyle, props.lineStyle ] } - marginLeft={ 0 } - marginRight={ 0 } - /> + > + { renderInner() } + </View> ); }; diff --git a/packages/primitives/src/horizontal-rule/styles.native.scss b/packages/primitives/src/horizontal-rule/styles.native.scss index dabb62a8336542..3e58d40e04b1ed 100644 --- a/packages/primitives/src/horizontal-rule/styles.native.scss +++ b/packages/primitives/src/horizontal-rule/styles.native.scss @@ -1,8 +1,26 @@ +.container { + align-items: center; + flex-direction: row; + margin-left: 0; + margin-right: 0; +} + .line { background-color: $gray-lighten-20; + flex: 1 0 10px; height: 2; } .lineDark { background-color: $gray-50; } + +.textContainer { + flex: 0 1 auto; + margin-left: 15px; + margin-right: 15px; +} + +.text { + text-align: center; +} diff --git a/packages/primitives/src/svg/index.native.js b/packages/primitives/src/svg/index.native.js index 79a0c33cde8d6e..c8c735283c05a0 100644 --- a/packages/primitives/src/svg/index.native.js +++ b/packages/primitives/src/svg/index.native.js @@ -8,6 +8,7 @@ import { Animated } from 'react-native'; * WordPress dependencies */ import { forwardRef } from '@wordpress/element'; +import { usePreferredColorScheme } from '@wordpress/compose'; /** * Internal dependencies @@ -37,13 +38,13 @@ export const SVG = ( { animated = false, ...props } ) => { - const colorScheme = props.colorScheme || 'light'; + const colorScheme = usePreferredColorScheme(); const stylesFromClasses = className .split( ' ' ) .map( ( element ) => styles[ element ] ) .filter( Boolean ); const defaultStyle = isPressed - ? styles[ 'is-pressed' ] + ? styles[ `is-pressed--${ colorScheme }` ] : styles[ 'components-toolbar__control-' + colorScheme ]; const propStyle = Array.isArray( props.style ) ? props.style.reduce( ( acc, el ) => { diff --git a/packages/primitives/src/svg/style.native.scss b/packages/primitives/src/svg/style.native.scss index ea8e7845174aaa..2e7dfdbfaff7a6 100644 --- a/packages/primitives/src/svg/style.native.scss +++ b/packages/primitives/src/svg/style.native.scss @@ -1,21 +1,30 @@ .dashicon-light, .components-toolbar__control-light { - color: $toolbar-button; + color: $light-primary; fill: currentColor; } .dashicon-dark, .components-toolbar__control-dark { - color: $gray_20; + color: $dark-primary; fill: currentColor; } -.dashicon-active, -.is-pressed { +.dashicon-active { color: #fff; fill: currentColor; } +.is-pressed--light { + color: $light-primary; + fill: currentColor; +} + +.is-pressed--dark { + color: $dark-primary; + fill: currentColor; +} + .dashicons-insert { color: #87a6bc; fill: currentColor; diff --git a/packages/priority-queue/CHANGELOG.md b/packages/priority-queue/CHANGELOG.md index 19aa9ae7f8dc11..7b256496708d6a 100644 --- a/packages/priority-queue/CHANGELOG.md +++ b/packages/priority-queue/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.40.0 (2023-08-16) + +## 2.39.0 (2023-08-10) + +## 2.38.0 (2023-07-20) + +## 2.37.0 (2023-07-05) + +## 2.36.0 (2023-06-23) + +## 2.35.0 (2023-06-07) + ## 2.34.0 (2023-05-24) ## 2.33.0 (2023-05-10) diff --git a/packages/priority-queue/package.json b/packages/priority-queue/package.json index b652e34c2eeae6..94e68e7c7ce1b5 100644 --- a/packages/priority-queue/package.json +++ b/packages/priority-queue/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/priority-queue", - "version": "2.34.0", + "version": "2.40.0", "description": "Generic browser priority queue.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/private-apis/CHANGELOG.md b/packages/private-apis/CHANGELOG.md index 7a39d3b7fe9764..59966f0881d651 100644 --- a/packages/private-apis/CHANGELOG.md +++ b/packages/private-apis/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.22.0 (2023-08-16) + +## 0.21.0 (2023-08-10) + +## 0.20.0 (2023-07-20) + +## 0.19.0 (2023-07-05) + +## 0.18.0 (2023-06-23) + +## 0.17.0 (2023-06-07) + ## 0.16.0 (2023-05-24) ## 0.15.0 (2023-05-10) diff --git a/packages/private-apis/README.md b/packages/private-apis/README.md index 828663dc760de6..9faaada8532003 100644 --- a/packages/private-apis/README.md +++ b/packages/private-apis/README.md @@ -56,7 +56,7 @@ Use `lock()` and `unlock()` to privately distribute the `__experimental` APIs ac ```js // In packages/package1/index.js: -import { lock } from './private-apis'; +import { lock } from './lock-unlock'; export const privateApis = {}; /* Attach private data to the exported object */ @@ -66,7 +66,7 @@ lock( privateApis, { // In packages/package2/index.js: import { privateApis } from '@wordpress/package1'; -import { unlock } from './private-apis'; +import { unlock } from './lock-unlock'; const { __experimentalFunction } = unlock( privateApis ); ``` diff --git a/packages/private-apis/package.json b/packages/private-apis/package.json index 3e201d7b046957..78afafd14a6192 100644 --- a/packages/private-apis/package.json +++ b/packages/private-apis/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/private-apis", - "version": "0.16.0", + "version": "0.22.0", "description": "Internal experimental APIs for WordPress core.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js index c12b431c4ceefe..4f1877bff569e9 100644 --- a/packages/private-apis/src/implementation.js +++ b/packages/private-apis/src/implementation.js @@ -16,12 +16,15 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/commands', '@wordpress/components', '@wordpress/core-commands', + '@wordpress/core-data', '@wordpress/customize-widgets', '@wordpress/data', '@wordpress/edit-post', '@wordpress/edit-site', '@wordpress/edit-widgets', '@wordpress/editor', + '@wordpress/patterns', + '@wordpress/reusable-blocks', '@wordpress/router', ]; diff --git a/packages/project-management-automation/CHANGELOG.md b/packages/project-management-automation/CHANGELOG.md index c762137118c7b1..bdb4343242f423 100644 --- a/packages/project-management-automation/CHANGELOG.md +++ b/packages/project-management-automation/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 1.39.0 (2023-08-16) + +## 1.38.0 (2023-08-10) + +## 1.37.0 (2023-07-20) + +## 1.36.0 (2023-07-05) + +## 1.35.0 (2023-06-23) + +## 1.34.0 (2023-06-07) + ## 1.33.0 (2023-05-24) ## 1.32.0 (2023-05-10) diff --git a/packages/project-management-automation/package.json b/packages/project-management-automation/package.json index 16257f943090cd..0d426fab0dc28f 100644 --- a/packages/project-management-automation/package.json +++ b/packages/project-management-automation/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/project-management-automation", - "version": "1.33.0", + "version": "1.39.0", "description": "GitHub Action that implements various automation to assist with managing the Gutenberg GitHub repository.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -26,7 +26,7 @@ "@actions/github": "^5.0.0", "@babel/runtime": "^7.16.0", "@octokit/request-error": "^2.1.0", - "@octokit/webhooks": "^7.1.0" + "@octokit/webhooks": "7.1.0" }, "publishConfig": { "access": "public" diff --git a/packages/react-i18n/CHANGELOG.md b/packages/react-i18n/CHANGELOG.md index 8102636875d9a9..9da0ce01a7deac 100644 --- a/packages/react-i18n/CHANGELOG.md +++ b/packages/react-i18n/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.38.0 (2023-08-16) + +## 3.37.0 (2023-08-10) + +## 3.36.0 (2023-07-20) + +## 3.35.0 (2023-07-05) + +## 3.34.0 (2023-06-23) + +## 3.33.0 (2023-06-07) + ## 3.32.0 (2023-05-24) ## 3.31.0 (2023-05-10) diff --git a/packages/react-i18n/package.json b/packages/react-i18n/package.json index 50a06bc0c35705..185d009525530f 100644 --- a/packages/react-i18n/package.json +++ b/packages/react-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-i18n", - "version": "3.32.0", + "version": "3.38.0", "description": "React bindings for @wordpress/i18n.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/react-native-aztec/android/.java-version b/packages/react-native-aztec/android/.java-version new file mode 100644 index 00000000000000..03b6389f32ad57 --- /dev/null +++ b/packages/react-native-aztec/android/.java-version @@ -0,0 +1 @@ +17.0 diff --git a/packages/react-native-aztec/android/build.gradle b/packages/react-native-aztec/android/build.gradle index 991fb23e51b9ad..71cb631a6cbf6d 100644 --- a/packages/react-native-aztec/android/build.gradle +++ b/packages/react-native-aztec/android/build.gradle @@ -1,5 +1,6 @@ buildscript { ext { + // node modules supportLibVersion = '29.0.2' tagSoupVersion = '1.2.1' glideVersion = '3.7.0' @@ -7,9 +8,21 @@ buildscript { robolectricVersion = '3.5.1' jUnitVersion = '4.12' jSoupVersion = '1.10.3' - wordpressUtilsVersion = '3.3.0' espressoVersion = '3.0.1' - aztecVersion = 'v1.6.3' + + // libs + aztecVersion = 'v1.6.4' + wordpressUtilsVersion = '3.3.0' + + // main + androidxAppcompatVersion = '1.2.0' + androidxCardviewVersion = '1.0.0' + androidxGridlayoutVersion = '1.0.0' + androidxLegacyVersion = '1.0.0' + androidxRecyclerviewVersion = '1.1.0' + + // test + junitAztecVersion = '4.13' } } @@ -34,11 +47,13 @@ List<String> dirs = [ 'template'] // boilerplate code that is generated by the sample template process android { - compileSdkVersion 31 + namespace "org.wordpress.mobile.ReactNativeAztec" + + compileSdkVersion 33 defaultConfig { minSdkVersion 24 - targetSdkVersion 31 + targetSdkVersion 33 } compileOptions { @@ -59,7 +74,7 @@ android { androidTest.java.srcDirs = ['tests/src'] } - lintOptions { + lint { disable 'GradleCompatible' abortOnError false } @@ -75,13 +90,7 @@ repositories { } maven { url "https://a8c-libs.s3.amazonaws.com/android/react-native-mirror" } google() - mavenCentral { - // We don't want to fetch react-native from Maven Central as there are - // older versions over there. - content { - excludeGroup "com.facebook.react" - } - } + mavenCentral() } dependencies { @@ -91,16 +100,17 @@ dependencies { api "org.wordpress.aztec:glide-loader:$aztecVersion" implementation "org.wordpress:utils:$wordpressUtilsVersion" - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.gridlayout:gridlayout:1.0.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'androidx.recyclerview:recyclerview:1.1.0' - testImplementation 'junit:junit:4.13' + implementation "androidx.legacy:legacy-support-v4:$androidxLegacyVersion" + implementation "androidx.gridlayout:gridlayout:$androidxGridlayoutVersion" + implementation "androidx.cardview:cardview:$androidxCardviewVersion" + implementation "androidx.appcompat:appcompat:$androidxAppcompatVersion" + implementation "androidx.recyclerview:recyclerview:$androidxRecyclerviewVersion" + + testImplementation "junit:junit:$junitAztecVersion" def rnVersion = readReactNativeVersion('../../../package.json', 'devDependencies') println "react-native version for react-native-aztec: $rnVersion" - implementation "com.facebook.react:react-native:$rnVersion" + implementation "com.facebook.react:react-android:$rnVersion" } project.afterEvaluate { @@ -111,7 +121,6 @@ project.afterEvaluate { groupId 'org.wordpress-mobile.gutenberg-mobile' artifactId 'react-native-aztec' - artifact tasks.named("androidSourcesJar") // version is set by 'publish-to-s3' plugin addDependenciesToPom(pom) diff --git a/packages/react-native-aztec/android/gradle.properties b/packages/react-native-aztec/android/gradle.properties index ce566007126c27..81529680627fbb 100644 --- a/packages/react-native-aztec/android/gradle.properties +++ b/packages/react-native-aztec/android/gradle.properties @@ -1,5 +1,12 @@ +# Project-wide Gradle settings. + org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError + +# React Native Aztec properties. + android.useAndroidX=true android.enableJetifier=false -shouldPublishBinary=false +# React Native Aztec publishing settings. + +shouldPublishBinary=false diff --git a/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.jar b/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.jar index 41d9927a4d4fb3..033e24c4cdf41a 100644 Binary files a/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.jar and b/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.properties b/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.properties index 92f06b50fd65b4..c747538fb38b53 100644 --- a/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/react-native-aztec/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/packages/react-native-aztec/android/gradlew b/packages/react-native-aztec/android/gradlew index 1b6c787337ffb7..fcb6fca147c0cd 100755 --- a/packages/react-native-aztec/android/gradlew +++ b/packages/react-native-aztec/android/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +130,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +197,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in @@ -205,6 +213,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/packages/react-native-aztec/android/gradlew.bat b/packages/react-native-aztec/android/gradlew.bat index ac1b06f93825db..6689b85beecde6 100644 --- a/packages/react-native-aztec/android/gradlew.bat +++ b/packages/react-native-aztec/android/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/packages/react-native-aztec/android/settings.gradle b/packages/react-native-aztec/android/settings.gradle index 8b0c3d17fa200d..11cf7c1990544b 100644 --- a/packages/react-native-aztec/android/settings.gradle +++ b/packages/react-native-aztec/android/settings.gradle @@ -1,10 +1,12 @@ pluginManagement { - gradle.ext.kotlinVersion = '1.5.32' + gradle.ext.kotlinVersion = '1.6.10' + gradle.ext.agpVersion = '8.1.0' + gradle.ext.automatticPublishToS3Version = '0.8.0' plugins { - id "com.android.library" version "7.2.1" + id "com.android.library" version gradle.ext.agpVersion id "org.jetbrains.kotlin.android" version gradle.ext.kotlinVersion - id "com.automattic.android.publish-to-s3" version "0.7.0" + id "com.automattic.android.publish-to-s3" version gradle.ext.automatticPublishToS3Version } repositories { maven { diff --git a/packages/react-native-aztec/android/src/main/AndroidManifest.xml b/packages/react-native-aztec/android/src/main/AndroidManifest.xml deleted file mode 100644 index d937b1806d40fe..00000000000000 --- a/packages/react-native-aztec/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- - Copyright 2014 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> - -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="org.wordpress.mobile.ReactNativeAztec"> -</manifest> diff --git a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift index c6e928b3964404..c5c2baec5988d5 100644 --- a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift +++ b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift @@ -95,12 +95,6 @@ class RCTAztecView: Aztec.TextView { let placeholderWidthInset = 2 * leftTextInset return placeholderLabel.widthAnchor.constraint(equalTo: widthAnchor, constant: -placeholderWidthInset) }() - - /// If a dictation start with an empty UITextView, - /// the dictation engine refreshes the TextView with an empty string when the dictation finishes. - /// This helps to avoid propagating that unwanted empty string to RN. (Solving #606) - /// on `textViewDidChange` and `textViewDidChangeSelection` - private var isInsertingDictationResult = false // MARK: - Font @@ -355,16 +349,20 @@ class RCTAztecView: Aztec.TextView { } // MARK: - Dictation - - override func dictationRecordingDidEnd() { - isInsertingDictationResult = true - } - - public override func insertDictationResult(_ dictationResult: [UIDictationPhrase]) { - let objectPlaceholder = "\u{FFFC}" - let dictationText = dictationResult.reduce("") { $0 + $1.text } - isInsertingDictationResult = false - self.text = self.text?.replacingOccurrences(of: objectPlaceholder, with: dictationText) + + func removeUnicodeAndRestoreCursor(from textView: UITextView) { + // Capture current cursor position + let originalPosition = textView.offset(from: textView.beginningOfDocument, to: textView.selectedTextRange?.start ?? textView.beginningOfDocument) + + // Replace occurrences of the obj symbol ("\u{FFFC}") + textView.text = textView.text?.replacingOccurrences(of: "\u{FFFC}", with: "") + + // Detect if cursor is off-by-one and correct, if so + let newPositionOffset = originalPosition > 0 ? originalPosition - 1 : originalPosition + if let newPosition = textView.position(from: textView.beginningOfDocument, offset: newPositionOffset) { + // Move the cursor to the correct, new position following dictation + textView.selectedTextRange = textView.textRange(from: newPosition, to: newPosition) + } } // MARK: - Custom Edit Intercepts @@ -691,7 +689,13 @@ class RCTAztecView: Aztec.TextView { /// private func refreshFont() { let newFont = applyFontConstraints(to: defaultFont) + font = newFont + placeholderLabel.font = newFont defaultFont = newFont + + if textStorage.length > 0 { + typingAttributes[NSAttributedString.Key.font] = newFont + } } /// This method refreshes the font for the palceholder field and typing attributes. @@ -771,7 +775,7 @@ class RCTAztecView: Aztec.TextView { extension RCTAztecView: UITextViewDelegate { func textViewDidChangeSelection(_ textView: UITextView) { - guard isFirstResponder, isInsertingDictationResult == false else { + guard isFirstResponder else { return } @@ -784,10 +788,13 @@ extension RCTAztecView: UITextViewDelegate { } func textViewDidChange(_ textView: UITextView) { - guard isInsertingDictationResult == false else { - return + // Workaround for RN dictation bug that adds obj symbol. + // Ref: https://github.com/facebook/react-native/issues/36521 + // TODO: Remove workaround when RN issue is fixed + if textView.text?.contains("\u{FFFC}") == true { + removeUnicodeAndRestoreCursor(from: textView) } - + propagateContentChanges() updatePlaceholderVisibility() //Necessary to send height information to JS after pasting text. diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index c9f769f306a705..5b2c1b19adeaf5 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-aztec", - "version": "1.95.0", + "version": "1.102.1", "description": "Aztec view for react-native.", "private": true, "author": "The WordPress Contributors", @@ -37,7 +37,7 @@ "clean-metro": "rm -rf $TMPDIR/metro-cache-*; rm -rf $TMPDIR/metro-bundler-cache-*;", "clean-node": "rm -rf node_modules/;", "clean-react": "rm -rf $TMPDIR/react-*; rm -rf $TMPDIR/react-native-packager-cache-*;", - "clean-watchman": "command -v watchman >/dev/null 2>&1 && watchman watch-del-all;", + "clean-watchman": "command -v watchman >/dev/null 2>&1 && watchman watch-del-all; true", "clean:install": "npm run clean && npm install" } } diff --git a/packages/react-native-aztec/src/AztecInputState.js b/packages/react-native-aztec/src/AztecInputState.js index 8a1737118d1d13..5f0a1dd7596284 100644 --- a/packages/react-native-aztec/src/AztecInputState.js +++ b/packages/react-native-aztec/src/AztecInputState.js @@ -116,6 +116,15 @@ export const notifyInputChange = () => { } }; +/** + * Sets the current focused element ref held within TextInputState. + * + * @param {RefObject} element Element to be set as the focused element. + */ +export const focusInput = ( element ) => { + TextInputState.focusInput( element ); +}; + /** * Focuses the specified element. * diff --git a/packages/react-native-aztec/src/AztecView.js b/packages/react-native-aztec/src/AztecView.js index 4d90d13974c8ec..3e3a5b6e626209 100644 --- a/packages/react-native-aztec/src/AztecView.js +++ b/packages/react-native-aztec/src/AztecView.js @@ -4,7 +4,7 @@ import { requireNativeComponent, UIManager, - TouchableWithoutFeedback, + Pressable, Platform, } from 'react-native'; @@ -237,14 +237,49 @@ class AztecView extends Component { } _onAztecFocus( event ) { - // IMPORTANT: the onFocus events from Aztec are thrown away on Android as these are handled by onPress() in the upper level. - // It's necessary to do this otherwise onFocus may be set by `{...otherProps}` and thus the onPress + onFocus - // combination generate an infinite loop as described in https://github.com/wordpress-mobile/gutenberg-mobile/issues/302 - // For iOS, this is necessary to let the system know when Aztec was focused programatically. - if ( Platform.OS === 'ios' ) { + // IMPORTANT: This function serves two purposes: + // + // Android: This intentional no-op function prevents focus loops originating + // when the native Aztec module programmatically focuses the instance. The + // no-op is explicitly passed as an `onFocus` prop to avoid future prop + // spreading from inadvertently introducing focus loops. The user-facing + // focus of the element is handled by `onPress` instead. + // + // See: https://github.com/wordpress-mobile/gutenberg-mobile/issues/302 + // + // iOS: Programmatic focus from the native Aztec module is required to + // ensure the React-based `TextStateInput` ref is properly set when focus + // is *returned* to an instance, e.g. dismissing a bottom sheet. If the ref + // is not updated, attempts to dismiss the keyboard via the `ToolbarButton` + // will fail. + // + // See: https://github.com/wordpress-mobile/gutenberg-mobile/issues/702 + if ( + // The Android keyboard is, likely erroneously, already dismissed in the + // contexts where programmatic focus may be required on iOS. + // + // - https://github.com/WordPress/gutenberg/issues/28748 + // - https://github.com/WordPress/gutenberg/issues/29048 + // - https://github.com/wordpress-mobile/WordPress-Android/issues/16167 + Platform.OS === 'ios' + ) { this.updateCaretData( event ); - this._onPress( event ); + if ( ! this.isFocused() ) { + // Programmatically swapping input focus creates an infinite loop if the + // user taps a different input in between the programmatic focus and + // the resulting update to the React Native TextInputState focused element + // ref. To mitigate this, the below updates the focused element ref, but + // does not call the native focus methods. + // + // See: https://github.com/wordpress-mobile/WordPress-iOS/issues/18783 + AztecInputState.focusInput( this.aztecViewRef.current ); + + // Calling _onFocus is needed to trigger provided onFocus callbacks + // which are needed to prevent undesired results like having a focused + // TextInput when another element has the focus. + this._onFocus( event ); + } } } @@ -256,9 +291,6 @@ class AztecView extends Component { if ( style.hasOwnProperty( 'lineHeight' ) ) { delete style.lineHeight; - window.console.warn( - "Removing lineHeight style as it's not supported by native AztecView" - ); // Prevents passing line-height within styles to avoid a crash due to values without units // We now support this but passing line-height as a prop instead. } @@ -273,7 +305,7 @@ class AztecView extends Component { } return ( - <TouchableWithoutFeedback onPress={ this._onPress }> + <Pressable accessible={ false } onPress={ this._onPress }> <RCTAztecView { ...otherProps } style={ style } @@ -285,14 +317,12 @@ class AztecView extends Component { onBackspace={ this.props.onKeyDown && this._onBackspace } onKeyDown={ this.props.onKeyDown && this._onKeyDown } deleteEnter={ this.props.deleteEnter } - // IMPORTANT: the onFocus events are thrown away as these are handled by onPress() in the upper level. - // It's necessary to do this otherwise onFocus may be set by `{...otherProps}` and thus the onPress + onFocus - // combination generate an infinite loop as described in https://github.com/wordpress-mobile/gutenberg-mobile/issues/302 + // IMPORTANT: Do not remove the `onFocus` prop, see `_onAztecFocus` onFocus={ this._onAztecFocus } onBlur={ this._onBlur } ref={ this.aztecViewRef } /> - </TouchableWithoutFeedback> + </Pressable> ); } } diff --git a/packages/react-native-bridge/android/.java-version b/packages/react-native-bridge/android/.java-version new file mode 100644 index 00000000000000..03b6389f32ad57 --- /dev/null +++ b/packages/react-native-bridge/android/.java-version @@ -0,0 +1 @@ +17.0 diff --git a/packages/react-native-bridge/android/build.gradle b/packages/react-native-bridge/android/build.gradle index 99069d55cfb6bf..2e7c3c822c42fb 100644 --- a/packages/react-native-bridge/android/build.gradle +++ b/packages/react-native-bridge/android/build.gradle @@ -12,6 +12,12 @@ plugins { allprojects { repositories { + mavenCentral() + // Starting from React Native 0.70, we no longer need to publish React Native binaries + // because they are already published to Maven (reference: https://t.ly/Biea). + // However, the third-party dependencies from forked repositories still point to older + // versions of React Native, hence we need to keep this Maven repository. maven { url "https://a8c-libs.s3.amazonaws.com/android/react-native-mirror" } + mavenLocal() } } diff --git a/packages/react-native-bridge/android/gradle.properties b/packages/react-native-bridge/android/gradle.properties index 570019612adf35..eb15f4b384486b 100644 --- a/packages/react-native-bridge/android/gradle.properties +++ b/packages/react-native-bridge/android/gradle.properties @@ -1,4 +1,8 @@ +# Project-wide Gradle settings. + org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError + +# React Native Bridge properties. + android.useAndroidX=true android.enableJetifier=false - diff --git a/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.jar b/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.jar index 41d9927a4d4fb3..033e24c4cdf41a 100644 Binary files a/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.jar and b/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.properties b/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.properties index 92f06b50fd65b4..c747538fb38b53 100644 --- a/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/react-native-bridge/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/packages/react-native-bridge/android/gradlew b/packages/react-native-bridge/android/gradlew index 1b6c787337ffb7..fcb6fca147c0cd 100755 --- a/packages/react-native-bridge/android/gradlew +++ b/packages/react-native-bridge/android/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +130,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +197,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in @@ -205,6 +213,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/packages/react-native-bridge/android/gradlew.bat b/packages/react-native-bridge/android/gradlew.bat index ac1b06f93825db..6689b85beecde6 100644 --- a/packages/react-native-bridge/android/gradlew.bat +++ b/packages/react-native-bridge/android/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/packages/react-native-bridge/android/react-native-bridge/build.gradle b/packages/react-native-bridge/android/react-native-bridge/build.gradle index ae22807d9495ad..4c7882ad0ffdba 100644 --- a/packages/react-native-bridge/android/react-native-bridge/build.gradle +++ b/packages/react-native-bridge/android/react-native-bridge/build.gradle @@ -1,3 +1,19 @@ +buildscript { + ext { + // libs + wordpressUtilsVersion = '3.3.0' + + // main + googleMaterialVersion = '1.2.1' + + // react native + facebookFrescoVersion = '2.0.0' + + // test + junitVersion = '4.13' + } +} + plugins { id "com.android.library" id "org.jetbrains.kotlin.android" @@ -14,15 +30,20 @@ group='org.wordpress-mobile.gutenberg-mobile' def buildAssetsFolder = 'build/assets' android { - compileSdkVersion 31 + // IMPORTANT: Any updates to the namespace should be reflected in + // the `package` attribute of the main `AndroidManifest.xml` file. + // File reference: `react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml` + namespace "org.wordpress.mobile.ReactNativeGutenbergBridge" + + compileSdkVersion 33 defaultConfig { minSdkVersion 24 - targetSdkVersion 31 + targetSdkVersion 33 buildConfigField "boolean", "SHOULD_ATTACH_JS_BUNDLE", willPublishReactNativeBridgeBinary.toString() } - lintOptions { + lint { abortOnError false } @@ -41,48 +62,39 @@ android { assets.srcDirs += '../../../../../resources/unsupported-block-editor' } } + + buildFeatures { + buildConfig true + } } repositories { maven { url "https://a8c-libs.s3.amazonaws.com/android" } maven { url "https://jitpack.io" } google() - mavenCentral { - // We don't want to fetch react-native from Maven Central as there are - // older versions over there. - content { - excludeGroup "com.facebook.react" - } - } + mavenCentral() } dependencies { // For animated GIF support - implementation 'com.facebook.fresco:animated-gif:2.0.0' - implementation 'com.google.android.material:material:1.2.1' - implementation "org.wordpress:utils:3.3.0" + implementation "com.facebook.fresco:animated-gif:$facebookFrescoVersion" + implementation "com.google.android.material:material:$googleMaterialVersion" + implementation "org.wordpress:utils:$wordpressUtilsVersion" - testImplementation "junit:junit:4.13" + testImplementation "junit:junit:$junitVersion" def rnVersion = readReactNativeVersion('../../../../package.json', 'devDependencies') println "react-native version for react-native-bridge: $rnVersion" def packageJson = '../../../react-native-editor/package.json' - implementation "com.facebook.react:react-native:$rnVersion" + implementation "com.facebook.react:react-android:$rnVersion" implementation "com.github.wordpress-mobile:react-native-video:${extractPackageVersion(packageJson, 'react-native-video', 'dependencies')}" - implementation "com.github.wordpress-mobile:react-native-linear-gradient:${extractPackageVersion(packageJson, 'react-native-linear-gradient', 'dependencies')}" implementation "com.github.wordpress-mobile:react-native-slider:${extractPackageVersion(packageJson, '@react-native-community/slider', 'dependencies')}" - implementation "com.github.wordpress-mobile:react-native-reanimated:${extractPackageVersion(packageJson, 'react-native-reanimated', 'dependencies')}" implementation "com.github.wordpress-mobile:react-native-prompt-android:${extractPackageVersion(packageJson, 'react-native-prompt-android', 'dependencies')}" - implementation("com.github.wordpress-mobile:react-native-gesture-handler:${extractPackageVersion(packageJson, 'react-native-gesture-handler', 'dependencies')}", { - // Remove Reanimated transitive dependency as it's already defined here - exclude group: 'com.github.wordpress-mobile', module: 'react-native-reanimated' - }) - // Published by `wordpress-mobile/react-native-libraries-publisher` // See the documentation for this value in `build.gradle.kts` of `wordpress-mobile/react-native-libraries-publisher` - def reactNativeLibrariesPublisherVersion = "v1" + def reactNativeLibrariesPublisherVersion = "v3" def reactNativeLibrariesGroupId = "org.wordpress-mobile.react-native-libraries.$reactNativeLibrariesPublisherVersion" implementation "$reactNativeLibrariesGroupId:react-native-get-random-values:${extractPackageVersion(packageJson, 'react-native-get-random-values', 'dependencies')}" implementation "$reactNativeLibrariesGroupId:react-native-safe-area-context:${extractPackageVersion(packageJson, 'react-native-safe-area-context', 'dependencies')}" @@ -92,10 +104,11 @@ dependencies { implementation "$reactNativeLibrariesGroupId:react-native-masked-view:${extractPackageVersion(packageJson, '@react-native-masked-view/masked-view', 'dependencies')}" implementation "$reactNativeLibrariesGroupId:react-native-clipboard:${extractPackageVersion(packageJson, '@react-native-clipboard/clipboard', 'dependencies')}" implementation "$reactNativeLibrariesGroupId:react-native-fast-image:${extractPackageVersion(packageJson, 'react-native-fast-image', 'dependencies')}" + implementation "$reactNativeLibrariesGroupId:react-native-reanimated:${extractPackageVersion(packageJson, 'react-native-reanimated', 'dependencies')}" + implementation "$reactNativeLibrariesGroupId:react-native-gesture-handler:${extractPackageVersion(packageJson, 'react-native-gesture-handler', 'dependencies')}" + implementation "$reactNativeLibrariesGroupId:react-native-linear-gradient:${extractPackageVersion(packageJson, 'react-native-linear-gradient', 'dependencies')}" - runtimeOnly("com.facebook.react:hermes-engine:$rnVersion", { - exclude group:'com.facebook.fbjni' - }) + runtimeOnly "com.facebook.react:hermes-android:$rnVersion" if (willPublishReactNativeBridgeBinary) { implementation "org.wordpress-mobile.gutenberg-mobile:react-native-aztec:$reactNativeAztecVersion" @@ -112,7 +125,6 @@ project.afterEvaluate { groupId 'org.wordpress-mobile.gutenberg-mobile' artifactId 'react-native-gutenberg-bridge' - artifact tasks.named("androidSourcesJar") // version is set by 'publish-to-s3' plugin addDependenciesToPom(pom) diff --git a/packages/react-native-bridge/android/react-native-bridge/src/debug/AndroidManifest.xml b/packages/react-native-bridge/android/react-native-bridge/src/debug/AndroidManifest.xml index 49ddd2f355c40e..918ee013ea2711 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/debug/AndroidManifest.xml +++ b/packages/react-native-bridge/android/react-native-bridge/src/debug/AndroidManifest.xml @@ -1,10 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - package="org.wordpress.mobile.ReactNativeGutenbergBridge"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application> - <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" /> </application> diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml b/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml index 7d31a2d03bd6cf..a26c55b7434e35 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ - -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="org.wordpress.mobile.ReactNativeGutenbergBridge"> +<!-- + IMPORTANT: Any updates to the package name should be reflected in + the `namespace` property of the `build.gradle` file. + File reference: `react-native-bridge/android/react-native-bridge/build.gradle` +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.wordpress.mobile.ReactNativeGutenbergBridge"> <queries> <intent> diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java index f5c0d325990b46..c6e20b29db072e 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java @@ -179,4 +179,8 @@ void gutenbergDidRequestUnsupportedBlockFallback(ReplaceUnsupportedBlockCallback void requestGotoCustomerSupportOptions(); void sendEventToHost(String eventName, ReadableMap properties); + + void toggleUndoButton(boolean isDisabled); + + void toggleRedoButton(boolean isDisabled); } diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java index 14a0a0a8e82771..d922d863cb3011 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java @@ -55,6 +55,10 @@ public class RNReactNativeGutenbergBridgeModule extends ReactContextBaseJavaModu private static final String EVENT_NAME_SHOW_NOTICE = "showNotice"; private static final String EVENT_NAME_SHOW_EDITOR_HELP = "showEditorHelp"; + private static final String EVENT_NAME_ON_UNDO_PRESSED = "onUndoPressed"; + + private static final String EVENT_NAME_ON_REDO_PRESSED = "onRedoPressed"; + private static final String MAP_KEY_UPDATE_HTML = "html"; private static final String MAP_KEY_UPDATE_TITLE = "title"; public static final String MAP_KEY_MEDIA_FILE_UPLOAD_MEDIA_NEW_ID = "newId"; @@ -192,6 +196,14 @@ public void showEditorHelp() { emitToJS(EVENT_NAME_SHOW_EDITOR_HELP, null); } + public void onUndoPressed() { + emitToJS(EVENT_NAME_ON_UNDO_PRESSED, null); + } + + public void onRedoPressed() { + emitToJS(EVENT_NAME_ON_REDO_PRESSED, null); + } + @ReactMethod public void addListener(String eventName) { // Keep: Required for RN built in Event Emitter Calls. @@ -497,6 +509,16 @@ public void sendEventToHost(final String eventName, final ReadableMap properties mGutenbergBridgeJS2Parent.sendEventToHost(eventName, properties); } + @ReactMethod + public void toggleUndoButton(final boolean isDisabled) { + mGutenbergBridgeJS2Parent.toggleUndoButton(isDisabled); + } + + @ReactMethod + public void toggleRedoButton(final boolean isDisabled) { + mGutenbergBridgeJS2Parent.toggleRedoButton(isDisabled); + } + @ReactMethod public void generateHapticFeedback() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt index 4e97a3974d6141..87a19066f9d15f 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt @@ -92,7 +92,6 @@ data class GutenbergProps @JvmOverloads constructor( content?.let { putString(PROP_INITIAL_DATA, it) } } - private const val PROP_INITIAL_DATA = "initialData" private const val PROP_INITIAL_TITLE = "initialTitle" private const val PROP_INITIAL_HTML_MODE_ENABLED = "initialHtmlModeEnabled" private const val PROP_POST_TYPE = "postType" @@ -108,6 +107,7 @@ data class GutenbergProps @JvmOverloads constructor( private const val PROP_QUOTE_BLOCK_V2 = "quoteBlockV2" private const val PROP_LIST_BLOCK_V2 = "listBlockV2" + const val PROP_INITIAL_DATA = "initialData" const val PROP_LOCALE = "locale" const val PROP_CAPABILITIES = "capabilities" const val PROP_CAPABILITIES_CONTACT_INFO_BLOCK = "contactInfoBlock" diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java index e270f91dbcae15..0e8962cc704829 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java @@ -109,6 +109,10 @@ public class WPAndroidGlueCode { private OnBlockTypeImpressionsEventListener mOnBlockTypeImpressionsEventListener; private OnCustomerSupportOptionsListener mOnCustomerSupportOptionsListener; private OnSendEventToHostListener mOnSendEventToHostListener; + + private OnToggleUndoButtonListener mOnToggleUndoButtonListener; + + private OnToggleRedoButtonListener mOnToggleRedoButtonListener; private boolean mIsEditorMounted; private String mContentHtml = ""; @@ -248,6 +252,14 @@ public interface OnSendEventToHostListener { void onSendEventToHost(String eventName, Map<String, Object> properties); } + public interface OnToggleUndoButtonListener { + void onToggleUndoButton(boolean isDisabled); + } + + public interface OnToggleRedoButtonListener { + void onToggleRedoButton(boolean isDisabled); + } + public void mediaSelectionCancelled() { mAppendsMultipleSelectedToSiblingBlocks = false; } @@ -573,6 +585,16 @@ public void requestGotoCustomerSupportOptions() { public void sendEventToHost(String eventName, ReadableMap properties) { mOnSendEventToHostListener.onSendEventToHost(eventName, properties.toHashMap()); } + + @Override + public void toggleUndoButton(boolean isDisabled) { + mOnToggleUndoButtonListener.onToggleUndoButton(isDisabled); + } + + @Override + public void toggleRedoButton(boolean isDisabled) { + mOnToggleRedoButtonListener.onToggleRedoButton(isDisabled); + } }, mIsDarkMode); return Arrays.asList( @@ -666,6 +688,8 @@ public void attachToContainer(ViewGroup viewGroup, OnBlockTypeImpressionsEventListener onBlockTypeImpressionsEventListener, OnCustomerSupportOptionsListener onCustomerSupportOptionsListener, OnSendEventToHostListener onSendEventToHostListener, + OnToggleUndoButtonListener onToggleUndoButtonListener, + OnToggleRedoButtonListener onToggleRedoButtonListener, boolean isDarkMode) { MutableContextWrapper contextWrapper = (MutableContextWrapper) mReactRootView.getContext(); contextWrapper.setBaseContext(viewGroup.getContext()); @@ -689,6 +713,8 @@ public void attachToContainer(ViewGroup viewGroup, mOnBlockTypeImpressionsEventListener = onBlockTypeImpressionsEventListener; mOnCustomerSupportOptionsListener = onCustomerSupportOptionsListener; mOnSendEventToHostListener = onSendEventToHostListener; + mOnToggleUndoButtonListener = onToggleUndoButtonListener; + mOnToggleRedoButtonListener = onToggleRedoButtonListener; sAddCookiesInterceptor.setOnAuthHeaderRequestedListener(onAuthHeaderRequestedListener); @@ -819,6 +845,14 @@ public void showEditorHelp() { mRnReactNativeGutenbergBridgePackage.getRNReactNativeGutenbergBridgeModule().showEditorHelp(); } + public void onUndoPressed() { + mRnReactNativeGutenbergBridgePackage.getRNReactNativeGutenbergBridgeModule().onUndoPressed(); + } + + public void onRedoPressed() { + mRnReactNativeGutenbergBridgePackage.getRNReactNativeGutenbergBridgeModule().onRedoPressed(); + } + public void setTitle(String title) { mTitleInitialized = true; mTitle = title; diff --git a/packages/react-native-bridge/android/settings.gradle b/packages/react-native-bridge/android/settings.gradle index d7374ea3743bbf..32d592f7176160 100644 --- a/packages/react-native-bridge/android/settings.gradle +++ b/packages/react-native-bridge/android/settings.gradle @@ -1,10 +1,12 @@ pluginManagement { - gradle.ext.kotlinVersion = '1.5.32' + gradle.ext.kotlinVersion = '1.6.10' + gradle.ext.agpVersion = '8.1.0' + gradle.ext.automatticPublishToS3Version = '0.8.0' plugins { - id "com.android.library" version "7.2.1" + id "com.android.library" version gradle.ext.agpVersion id "org.jetbrains.kotlin.android" version gradle.ext.kotlinVersion - id "com.automattic.android.publish-to-s3" version "0.7.0" + id "com.automattic.android.publish-to-s3" version gradle.ext.automatticPublishToS3Version } repositories { maven { @@ -22,11 +24,10 @@ pluginManagement { rootProject.name = '@wordpress_react-native-bridge' include ':react-native-bridge' -include ':@wordpress_react-native-aztec' -project(':@wordpress_react-native-aztec').projectDir = new File(rootProject.projectDir, '../../react-native-aztec/android') -include ':react-native-aztec' -project(':react-native-aztec').projectDir = new File(rootProject.projectDir, '../../../packages/react-native-aztec/android') if (hasProperty("willPublishReactNativeBridgeBinary")) { assert file("./react-native-bridge/build/assets/index.android.bundle").exists() : "index.android.bundle is necessary to publish a new version!" +} else { + include ':@wordpress_react-native-aztec' + project(':@wordpress_react-native-aztec').projectDir = new File(rootProject.projectDir, '../../react-native-aztec/android') } diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js index 09dcd6447824d7..1a4f4422a47ba2 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js @@ -77,9 +77,8 @@ if ( isAndroid() ) { manageTextSelectonContextMenu(); } -const editor = document.querySelector( '#editor' ); - function _toggleBlockSelectedClass( isBlockSelected ) { + const editor = document.querySelector( '#editor' ); if ( isBlockSelected ) { editor.classList.add( 'is-block-selected' ); } else { diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json b/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json index 28c0c1b17b4adc..f9cee4142d11ac 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json @@ -13,5 +13,11 @@ "hiddenBlockTypes": [], "preferredStyleVariations": {} } + }, + "core/nux": { + "preferences": { + "areTipsEnabled": false, + "dismissedTips": {} + } } } diff --git a/packages/react-native-bridge/index.js b/packages/react-native-bridge/index.js index d432f0de9e2383..89f9f029901f9a 100644 --- a/packages/react-native-bridge/index.js +++ b/packages/react-native-bridge/index.js @@ -177,6 +177,14 @@ export function subscribeShowEditorHelp( callback ) { return gutenbergBridgeEvents.addListener( 'showEditorHelp', callback ); } +export function subscribeOnUndoPressed( callback ) { + return gutenbergBridgeEvents.addListener( 'onUndoPressed', callback ); +} + +export function subscribeOnRedoPressed( callback ) { + return gutenbergBridgeEvents.addListener( 'onRedoPressed', callback ); +} + /** * Request media picker for the given media source. * @@ -466,4 +474,12 @@ export function generateHapticFeedback() { RNReactNativeGutenbergBridge.generateHapticFeedback(); } +export function toggleUndoButton( isDisabled ) { + RNReactNativeGutenbergBridge.toggleUndoButton( isDisabled ); +} + +export function toggleRedoButton( isDisabled ) { + RNReactNativeGutenbergBridge.toggleRedoButton( isDisabled ); +} + export default RNReactNativeGutenbergBridge; diff --git a/packages/react-native-bridge/ios/Gutenberg.swift b/packages/react-native-bridge/ios/Gutenberg.swift index adf0eb667f9767..4175c1e2343c32 100644 --- a/packages/react-native-bridge/ios/Gutenberg.swift +++ b/packages/react-native-bridge/ios/Gutenberg.swift @@ -201,6 +201,14 @@ public class Gutenberg: UIResponder { public func showEditorHelp() { bridgeModule.sendEventIfNeeded(.showEditorHelp, body: nil) } + + public func onUndoPressed() { + bridgeModule.sendEventIfNeeded(.onUndoPressed, body: nil) + } + + public func onRedoPressed() { + bridgeModule.sendEventIfNeeded(.onRedoPressed, body: nil) + } private func properties(from editorSettings: GutenbergEditorSettings?) -> [String : Any] { var settingsUpdates = [String : Any]() diff --git a/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift b/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift index 4b13e4e6a15a9e..83d087bccab9d1 100644 --- a/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift +++ b/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift @@ -279,6 +279,10 @@ public protocol GutenbergBridgeDelegate: AnyObject { /// Tells the delegate the editor requested sending an event func gutenbergDidRequestSendEventToHost(_ eventName: String, properties: [AnyHashable: Any]) + + func gutenbergDidRequestToggleUndoButton(_ isDisabled: Bool) + + func gutenbergDidRequestToggleRedoButton(_ isDisabled: Bool) } // MARK: - Optional GutenbergBridgeDelegate methods diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m index c1990035b776c0..d333f8c1722ad9 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m @@ -40,5 +40,7 @@ @interface RCT_EXTERN_MODULE(RNReactNativeGutenbergBridge, NSObject) RCT_EXTERN_METHOD(requestGotoCustomerSupportOptions) RCT_EXTERN_METHOD(sendEventToHost:(NSString)eventName properties:(NSDictionary *)properties) RCT_EXTERN_METHOD(generateHapticFeedback) +RCT_EXTERN_METHOD(toggleUndoButton:(BOOL)isDisabled) +RCT_EXTERN_METHOD(toggleRedoButton:(BOOL)isDisabled) @end diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift index bfc3507732138d..8cf4f685bd22c4 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift @@ -411,6 +411,16 @@ public class RNReactNativeGutenbergBridge: RCTEventEmitter { func generateHapticFeedback() { UISelectionFeedbackGenerator().selectionChanged() } + + @objc + func toggleUndoButton(_ isDisabled: Bool) { + self.delegate?.gutenbergDidRequestToggleUndoButton(isDisabled) + } + + @objc + func toggleRedoButton(_ isDisabled: Bool) { + self.delegate?.gutenbergDidRequestToggleRedoButton(isDisabled) + } } // MARK: - RCTBridgeModule delegate @@ -438,6 +448,8 @@ extension RNReactNativeGutenbergBridge { case showNotice case mediaSave case showEditorHelp + case onUndoPressed + case onRedoPressed } public override func supportedEvents() -> [String]! { diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json index e632331fa2485e..ef36503a2ddbec 100644 --- a/packages/react-native-bridge/package.json +++ b/packages/react-native-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-bridge", - "version": "1.95.0", + "version": "1.102.1", "description": "Native bridge library used to integrate the block editor into a native App.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 725bf7615c5353..a9fa07190f2fcd 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,8 +10,78 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [**] Replace third-party dependency react-native-hsv-color-picker with first-party code [#53329] +- [*] Search Control - Prevent calling TextInput's methods when undefined [#53745] +- [*] Improve horizontal rule styles to avoid invisible lines [#53883] +- [*] Fix horizontal rule style extensions [#53917] +- [*] Add block outline to all Social Link blocks when selected [#54011] +- [*] Columns block - Fix transforming into a Group block crash [#54035] + +## 1.102.1 +- [**] Fix Voice Over and assistive keyboards [#53895] + +## 1.102.0 +- [*] Display custom color value in mobile Cover Block color picker [#51414] +- [**] Display outline around selected Social Link block [#53377] +- [**] Fixes font customization not getting updated on iOS [#53391] + +## 1.101.2 +- [**] Fix Voice Over and assistive keyboards [#53895] + +## 1.101.1 +- [**] Fix the dynamic height when opening/closing navigation screens within the bottom sheet. [https://github.com/WordPress/gutenberg/pull/53608] + +## 1.101.0 +- [*] Remove visual gap in mobile toolbar when a Gallery block is selected [#52966] +- [*] Remove Gallery caption button on mobile [#53010] +- [**] Upgrade React Native to 0.71.11 [#51303] +- [*] Upgrade Gradle to 8.2.1 & AGP to 8.1.0 [#52872] +- [*] Fix Gallery block selection when adding media [#53127] + +## 1.100.2 +- [**] Fix iOS Focus loop for RichText components [#53217] + +## 1.100.1 +- [**] Add WP hook for registering non-core blocks [#52791] + +## 1.100.0 +- [**] Add media inserter buttons to editor toolbar [#51827] +- [**] Update native BlockOutline component styles to remove blue border from blocks [#51222] +- [**] Move the undo/redo buttons to the navigation bar [#51766] +- [**] Update Editor block inserter button styles and default text input placeholder/selection styles [#52269] +- [**] Update Editor toolbar icons and colors [#52336] +- [*] Update Block Settings button border [#52715] + +## 1.99.1 +- [**] Fix crash related to removing a block under certain conditions [#52595] + +## 1.99.0 +- [*] Rename "Reusable blocks" to "Synced patterns", aligning with the web editor. [#51704] +- [**] Fix a crash related to Reanimated when closing the editor [#52320] + +## 1.98.1 +- [*] fix: Display heading level dropdown icons and labels [#52004] + +## 1.98.0 +- [*] Image block - Fix issue where in some cases the image doesn't display the right aspect ratio [#51463] +- [*] Fix cursor positioning when dictating text on iOS [#51227] + +## 1.97.1 +- [**] Fix crash when using the delete key to remove a single button [#51435] +- [*] Ensure text input field is not editable when Bottom sheet cell is disabled [#51567] + +## 1.97.0 +- [**] [iOS] Fix dictation regression, in which typing/dictating at the same time caused content loss. [#49452] +- [*] [internal] Upgrade compile and target sdk version to Android API 33 [#50731] +- [*] Display lock icon in disabled state of `Cell` component [#50907] + +## 1.96.1 +- [**] Fix Android-only issue related to block toolbar not being displayed on some blocks in UBE [#51131] + +## 1.96.0 - [**] Tapping on all nested blocks gets focus directly instead of having to tap multiple times depending on the nesting levels. [#50672] - [*] Add disabled style to `Cell` component [#50665] +- [**] Fix undo/redo history when inserting a link configured to open in a new tab [#50460] - [*] [List block] Fix an issue when merging a list item into a Paragraph would remove its nested list items. [#50701] ## 1.95.0 @@ -27,7 +97,7 @@ For each user feature we should also add a importance categorization label to i - [**] Fix regression with the Color hook and ColorPanel. [#49917] ## 1.93.0 -- [***] [iOS] Fixed iOS scroll jumping issue by refactoring KeyboardAwareFlatList improving writing flow and caret focus handling. [#48791] +- [***] [iOS] Fixed iOS scroll jumping issue by refactoring KeyboardAwareFlatList improving writing flow and caret focus handling. [#48791] ## 1.92.1 - [*] Avoid empty Gallery block error [#49557] @@ -522,8 +592,8 @@ For each user feature we should also add a importance categorization label to i ## 1.42.0 - [***] Adding support for selecting different unit of value in Cover and Columns blocks [#26161] -- [**] Button block - Add link picker to the block settings [#26206] -- [**] Support to render background/text colors in Group, Paragraph and Quote blocks [#25994] +- [**] Button block - Add link picker to the block settings [#26206] +- [**] Support to render background/text colors in Group, Paragraph and Quote blocks [#25994] - [*] Fix theme colors syncing with the editor [#26821] - [**] Fix issue where a blocks would disappear when deleting all of the text inside without requiring the extra backspace to remove the block. [#27583] @@ -818,4 +888,4 @@ For each user feature we should also add a importance categorization label to i ## 1.6.0 - Fixed issue with link settings where “Open in New Tab” was always OFF on open. -- Added UI to display a warning when a block has invalid content. +- Added UI to display a warning when a block has invalid content. \ No newline at end of file diff --git a/packages/react-native-editor/__device-tests__/CONTRIBUTING.md b/packages/react-native-editor/__device-tests__/CONTRIBUTING.md index 55b85dd0c2cc6a..eba49df6949838 100644 --- a/packages/react-native-editor/__device-tests__/CONTRIBUTING.md +++ b/packages/react-native-editor/__device-tests__/CONTRIBUTING.md @@ -30,7 +30,7 @@ For Android, you can fire up the app and then within Android Studio select `Tool For iOS, you can also fire up and use the accessibility inspector, which is an app that should come available on your OSX machine. From there you can choose the process running your simulator and inspect various areas of the app. -Alternative for both of these platforms and for an interface to simulate the commands I'd recommend [Appium Inspector](https://github.com/appium/appium-inspector/releases). A great tool for inspecting the view hierarchy and interacting with elements on screen as your test would. +Alternative for both of these platforms and for an interface to simulate the commands I'd recommend [Appium Inspector](https://github.com/appium/appium-inspector/releases), a great tool for inspecting the view hierarchy and interacting with elements on screen as your test would. In order to connect the Appium Inspector, you'll need to start the Appium server manually by running `npm run native appium:start` and then configure Appium Inspector with the appropriate capabilities. You can find the capabilities in `__device-tests__/helpers/caps.js`. Using one or a combination of these tools will make it much easier to identify what locator strategy you're going to use or which elements need accessibility identifiers to ease the search process without affecting VoiceOver features. @@ -38,7 +38,7 @@ Using one or a combination of these tools will make it much easier to identify w - You'll write any functions needed to interact with the page in the `EditorPage` page object and then call those interactions within the test. The code you'll need to write to actually do the finding will use a combination of -- Appium's spec https://appium.io/docs/en/about-appium/intro/ which you can find examples of a variety of functions under the commands tab +- Appium's spec https://github.com/appium/appium/blob/1.x/docs/en/about-appium/intro.md which you can find examples of a variety of functions under the commands tab - WebDriver I/O Appium protocols https://webdriver.io/docs/api/appium.html which provides examples and descriptions of what those look like. It takes some getting used to but looking at the existing code should be helpful in identifying common commands that it'd help to be familiar with. diff --git a/packages/react-native-editor/__device-tests__/README.md b/packages/react-native-editor/__device-tests__/README.md index fa80762b52809c..81a2d4d794cb47 100644 --- a/packages/react-native-editor/__device-tests__/README.md +++ b/packages/react-native-editor/__device-tests__/README.md @@ -26,7 +26,7 @@ SauceLabs is a cloud hosting platform that provides access to a variety of simul ## Running the tests locally -TLDR; to run the tests locally ensure metro isn't running and then run `npm run native test:e2e:ios:local` and `npm run native test:e2e:android:local` for the desired platform. +TL;DR: to run the tests locally ensure metro isn't running and then run `npm run native test:e2e:ios:local` and `npm run native test:e2e:android:local` for the desired platform. Those commands include the process to build a testable version of the app with these steps: @@ -50,7 +50,13 @@ You can also write `debugger;` in the JS code in any line to add a breakpoint. ### Starting the Appium Server -One of the Caveats to using Appium is the need for the Appium server to be running to interact with the Simulator or Device through Webdriver, as a result the appium server will need to be started before running the tests. To make the entire process easier in the `beforeAll` block of the tests an Appium instance is fired up on a default port of 4723. If you already have something running on that port and would rather not stop that you can change the port within the code that starts that up. At the moment that port number is referenced from the config located at `__device-tests__/helpers/serverConfigs.js`. The process is killed in the `afterAll` block but at the time of writing this there's a small chance some errors might cause it not to get there so it might be best to kill the process yourself if you think something is up. The server output when running the tests are written to `appium-out.log`, this can provide useful information when debugging the issues with the tests. +One of the Caveats to using Appium is the need for the Appium server to be running to interact with the Simulator or Device through Webdriver, as a result the appium server will need to be started before running the tests. + +To make the entire process easier in the `beforeAll` block of the tests an Appium instance is fired up on a default port of 4723. If you already have something running on that port and would rather not stop that you can change the port within the code that starts that up. At the moment that port number is referenced from the config located at `__device-tests__/helpers/serverConfigs.js`. + +The process is killed in the `afterAll` block but at the time of writing this there's a small chance some errors might cause it not to get there so it might be best to kill the process yourself if you think something is up. The server output when running the tests are written to `appium-out.log`, this can provide useful information when debugging the issues with the tests. + +If the `beforeAll` and `afterAll` functionality is not working correctly, you can start the Appium server manually by running `npm run native appium:start`. ### WebDriver capabilities @@ -59,7 +65,7 @@ Appium uses a config object that contains `capabilities` to define how it will c - `platformVersion` which is the platform version of a connected adb device. e.g `9.0` for Android or `12.2` for iOS. The version used here is upper bounded by the max allowed on CI but feel free to change this value locally as needed. - `app` which is the absolute path to the `.app` or `.apk` file or the path relative to the **Appium root**. It's important to note that when using the relative paths it's not to the project folder but to the appium server, since by default we start up appium in the project root when running the paths appear relative to the root but if you were using another instance of the Appium server the relative path would need to come from there. -A full spec on the capabilities can be found [here](http://appium.io/docs/en/writing-running-appium/caps/). If you'd like to change configurations like +A full spec on the capabilities can be found [here](https://github.com/appium/appium/blob/1.x/docs/en/writing-running-appium/caps.md). If you'd like to change configurations like what port appium runs on or what device or emulator the tests should be executed on that file would be where you'd like to make that update. ## The run process @@ -75,4 +81,4 @@ After the build is complete, an appium server is fired up on port 4723 and the d --- -To read more about writing your own tests please read the [contributing guide](https://github.com/WordPress/gutenberg/blob/HEAD/packages/react-native-editor/__device-tests__/CONTRIBUTING.md) +To read more about writing your own tests please read the [contributing guide](https://github.com/WordPress/gutenberg/blob/HEAD/packages/react-native-editor/__device-tests__/CONTRIBUTING.md). diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js index b413f3b6a42cea..b3a9f2350c56d2 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js @@ -7,6 +7,7 @@ import testData, { slashInserter, shortText } from './helpers/test-data'; describe( 'Gutenberg Editor tests for Block insertion', () => { it( 'should be able to insert multi-paragraph text, and text to another paragraph block in between', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); let paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -38,19 +39,12 @@ describe( 'Gutenberg Editor tests for Block insertion', () => { expect( html.toLowerCase() ).toBe( testData.blockInsertionHtml.toLowerCase() ); - - for ( let i = 4; i > 0; i-- ) { - paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph - ); - await paragraphBlockElement.click(); - await editorPage.removeBlock(); - } } ); it( 'should be able to insert block at the beginning of post from the title', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); - let paragraphBlockElement = await editorPage.getTextBlockAtPosition( + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph ); if ( isAndroid() ) { @@ -79,20 +73,12 @@ describe( 'Gutenberg Editor tests for Block insertion', () => { expect( html.toLowerCase() ).toBe( testData.blockInsertionHtmlFromTitle.toLowerCase() ); - - // Remove blocks - for ( let i = 4; i > 0; i-- ) { - paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph - ); - await paragraphBlockElement.click(); - await editorPage.removeBlock(); - } } ); } ); describe( 'Gutenberg Editor Slash Inserter tests', () => { it( 'should show the menu after typing /', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -104,10 +90,10 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { ); expect( await editorPage.assertSlashInserterPresent() ).toBe( true ); - await editorPage.removeBlockAtPosition( blockNames.paragraph ); } ); it( 'should hide the menu after deleting the / character', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -138,11 +124,10 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { // Check if the slash inserter UI no longer exists. expect( await editorPage.assertSlashInserterPresent() ).toBe( false ); - - await editorPage.removeBlockAtPosition( blockNames.paragraph ); } ); it( 'should add an Image block after tying /image and tapping on the Image block button', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -170,12 +155,10 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { // Slash inserter UI should not be present after adding a block. expect( await editorPage.assertSlashInserterPresent() ).toBe( false ); - - // Remove image block. - await editorPage.removeBlockAtPosition( blockNames.image ); } ); it( 'should insert an embed image block with "/img" + enter', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -189,7 +172,5 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { expect( await editorPage.hasBlockAtPosition( 1, blockNames.embed ) ).toBe( true ); - - await editorPage.removeBlockAtPosition( blockNames.embed ); } ); } ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js index 3a64ca37508980..e0fbd7630a5d24 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js @@ -15,6 +15,7 @@ import testData from './helpers/test-data'; describe( 'Gutenberg Editor Rotation tests', () => { it( 'should be able to add blocks , rotate device and continue adding blocks', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); let paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -70,6 +71,7 @@ describe( 'Gutenberg Editor Paste tests', () => { } ); it.skip( 'copies plain text from one paragraph block and pastes in another', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -107,14 +109,13 @@ describe( 'Gutenberg Editor Paste tests', () => { const text = await editorPage.getTextForParagraphBlockAtPosition( 2 ); expect( text ).toBe( testData.pastePlainText ); - - await editorPage.removeBlockAtPosition( blockNames.paragraph, 2 ); - await editorPage.removeBlockAtPosition( blockNames.paragraph, 1 ); } ); it.skip( 'copies styled text from one paragraph block and pastes in another', async () => { // Create paragraph block with styled text by editing html. - await editorPage.setHtmlContent( testData.pasteHtmlText ); + await editorPage.initializeEditor( { + initialData: testData.pasteHtmlText, + } ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js index a13e6047f1712f..f7e395d986d57e 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js @@ -22,11 +22,12 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { it( 'should be able to drag & drop a block', async () => { // Initialize the editor with a Spacer and Paragraph block - await editorPage.setHtmlContent( - [ testData.spacerBlock, testData.paragraphBlockShortText ].join( - '\n\n' - ) - ); + await editorPage.initializeEditor( { + initialData: [ + testData.spacerBlock, + testData.paragraphBlockShortText, + ].join( '\n\n' ), + } ); // Get elements for both blocks const spacerBlock = await editorPage.getBlockAtPosition( @@ -49,16 +50,12 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { const firstBlockText = await editorPage.getTextForParagraphBlockAtPosition( 1 ); expect( firstBlockText ).toMatch( testData.shortText ); - - // Remove the blocks - await spacerBlock.click(); - await editorPage.removeBlock(); - await editorPage.removeBlock(); } ); onlyOnAndroid( 'should be able to long-press on a text-based block to paste a text in a focused textinput', async () => { + await editorPage.initializeEditor(); // Add a Paragraph block await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = @@ -83,15 +80,13 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { // Expect to have the pasted text in the Paragraph block expect( paragraphText ).toMatch( testData.shortText ); - - // Remove the block - await editorPage.removeBlock(); } ); onlyOnAndroid( 'should be able to long-press on a text-based block using the PlainText component to paste a text in a focused textinput', async () => { + await editorPage.initializeEditor(); // Add a Shortcode block await editorPage.addNewBlock( blockNames.shortcode ); const shortcodeBlockElement = @@ -125,12 +120,12 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { it( 'should be able to drag & drop a text-based block when another textinput is focused', async () => { // Initialize the editor with two Paragraph blocks - await editorPage.setHtmlContent( - [ + await editorPage.initializeEditor( { + initialData: [ testData.paragraphBlockShortText, testData.paragraphBlockEmpty, - ].join( '\n\n' ) - ); + ].join( '\n\n' ), + } ); // Tap on the second block const secondParagraphBlock = await editorPage.getBlockAtPosition( diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js index f806b43d2f8e42..50a2a3ee8fd640 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js @@ -6,6 +6,7 @@ import testData from './helpers/test-data'; describe( 'Gutenberg Editor tests', () => { it( 'should be able to create a post with heading and paragraph blocks', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.heading ); let headingBlockElement = await editorPage.getTextBlockAtPosition( blockNames.heading diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-initial-html-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-initial-html-@canary.test.js index 6569bf68bf62f5..10ff74da9308a0 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-initial-html-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-initial-html-@canary.test.js @@ -5,7 +5,7 @@ import initialHtml from '../src/initial-html'; describe( 'Gutenberg Editor Blocks test', () => { it( 'should be able to create a post with all blocks and scroll to the last one', async () => { - await editorPage.setHtmlContent( initialHtml ); + await editorPage.initializeEditor( { initialData: initialHtml } ); // Scroll to the last element const addBlockPlaceholder = diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js index 6fd68a7a4aff34..ddc7bd8131c443 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js @@ -9,6 +9,7 @@ const onlyOniOS = ! isAndroid() ? describe : describe.skip; describe( 'Gutenberg Editor Audio Block tests', () => { it( 'should be able to add an audio block and a file to it', async () => { + await editorPage.initializeEditor(); // add an audio block await editorPage.addNewBlock( blockNames.audio ); @@ -17,17 +18,19 @@ describe( 'Gutenberg Editor Audio Block tests', () => { await editorPage.closePicker(); // verify there's an audio block - let block = await editorPage.getFirstBlockVisible(); - await expect( block ).toBeTruthy(); + const block = await editorPage.getFirstBlockVisible(); + expect( block ).toBeTruthy(); // tap on the audio block - block.click(); + await block.click(); // wait for the media picker's Media Library option to come up await waitForMediaLibrary( editorPage.driver ); // tap on Media Library option await editorPage.chooseMediaLibrary(); + // wait until the media is added + await editorPage.driver.sleep( 500 ); // get the html version of the content const html = await editorPage.getHtmlContent(); @@ -36,15 +39,12 @@ describe( 'Gutenberg Editor Audio Block tests', () => { expect( html.toLowerCase() ).toBe( testData.audioBlockPlaceholder.toLowerCase() ); - - block = await editorPage.getBlockAtPosition( blockNames.audio ); - await block.click(); - await editorPage.removeBlock(); } ); } ); describe( 'Gutenberg Editor File Block tests', () => { it( 'should be able to add a file block and a file to it', async () => { + await editorPage.initializeEditor(); // add a file block await editorPage.addNewBlock( blockNames.file ); @@ -53,17 +53,19 @@ describe( 'Gutenberg Editor File Block tests', () => { await editorPage.closePicker(); // verify there's a file block - let block = await editorPage.getFirstBlockVisible(); - await expect( block ).toBeTruthy(); + const block = await editorPage.getFirstBlockVisible(); + expect( block ).toBeTruthy(); // tap on the file block - block.click(); + await block.click(); // wait for the media picker's Media Library option to come up await waitForMediaLibrary( editorPage.driver ); // tap on Media Library option await editorPage.chooseMediaLibrary(); + // wait until the media is added + await editorPage.driver.sleep( 500 ); // get the html version of the content const html = await editorPage.getHtmlContent(); @@ -72,20 +74,17 @@ describe( 'Gutenberg Editor File Block tests', () => { expect( html.toLowerCase() ).toBe( testData.fileBlockPlaceholder.toLowerCase() ); - - block = await editorPage.getBlockAtPosition( blockNames.file ); - await block.click(); - await editorPage.removeBlock(); } ); } ); // iOS only test - It can only add images from the media library on iOS. onlyOniOS( 'Gutenberg Editor Image Block tests', () => { it( 'should be able to add an image block', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.image ); await editorPage.closePicker(); - let imageBlock = await editorPage.getBlockAtPosition( + const imageBlock = await editorPage.getBlockAtPosition( blockNames.image ); @@ -106,10 +105,6 @@ onlyOniOS( 'Gutenberg Editor Image Block tests', () => { expect( html.toLowerCase() ).toBe( testData.imageShortHtml.toLowerCase() ); - - imageBlock = await editorPage.getBlockAtPosition( blockNames.image ); - await imageBlock.click(); - await editorPage.removeBlock(); } ); } ); @@ -117,7 +112,9 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { it( 'should displayed properly and have properly converted height (ios only)', async () => { // Temporarily this test is skipped on Android, due to the inconsistency of the results, // which are related to getting values in raw pixels instead of density pixels on Android. - await editorPage.setHtmlContent( testData.coverHeightWithRemUnit ); + await editorPage.initializeEditor( { + initialData: testData.coverHeightWithRemUnit, + } ); const coverBlock = await editorPage.getBlockAtPosition( blockNames.cover @@ -125,23 +122,21 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { const { height } = await coverBlock.getSize(); // Height is set to 20rem, where 1rem is 16. - // There is also block's vertical padding equal 32. - // Finally, the total height should be 20 * 16 + 32 = 352. - expect( height ).toBe( 352 ); + // There is also block's vertical padding equal 16. + // Finally, the total height should be 20 * 16 + 16 = 336. + expect( height ).toBe( 336 ); await coverBlock.click(); expect( coverBlock ).toBeTruthy(); - - // Navigate upwards to select parent block - await editorPage.moveBlockSelectionUp(); - await editorPage.removeBlockAtPosition( blockNames.cover ); } ); // Testing this for iOS on a device is valuable to ensure that it properly // handles opening multiple modals, as only one can be open at a time. // NOTE: It can only add images from the media library on iOS. it( 'allows modifying media from within block settings', async () => { - await editorPage.setHtmlContent( testData.coverHeightWithRemUnit ); + await editorPage.initializeEditor( { + initialData: testData.coverHeightWithRemUnit, + } ); const coverBlock = await editorPage.getBlockAtPosition( blockNames.cover @@ -165,6 +160,5 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { await editorPage.chooseMediaLibrary(); expect( coverBlock ).toBeTruthy(); - await editorPage.removeBlockAtPosition( blockNames.cover ); } ); } ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js index aa398d1f24e265..8f21ef04858fb6 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js @@ -6,12 +6,12 @@ import { backspace, clickMiddleOfElement, clickBeginningOfElement, - isAndroid, } from './helpers/utils'; import testData from './helpers/test-data'; describe( 'Gutenberg Editor tests for Paragraph Block', () => { it( 'should be able to split one paragraph block into two', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); const paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -34,12 +34,10 @@ describe( 'Gutenberg Editor tests for Paragraph Block', () => { expect( testData.shortText ).toMatch( new RegExp( `${ text0 + text1 }|${ text0 } ${ text1 }` ) ); - - await editorPage.removeBlock(); - await editorPage.removeBlock(); } ); it( 'should be able to merge 2 paragraph blocks into 1', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); let paragraphBlockElement = await editorPage.getTextBlockAtPosition( blockNames.paragraph @@ -78,28 +76,22 @@ describe( 'Gutenberg Editor tests for Paragraph Block', () => { ); await paragraphBlockElement.click(); expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 1 ); - await editorPage.removeBlock(); } ); it( 'should be able to create a post with multiple paragraph blocks', async () => { + await editorPage.initializeEditor(); await editorPage.addNewBlock( blockNames.paragraph ); await editorPage.sendTextToParagraphBlock( 1, testData.longText ); - - for ( let i = 3; i > 0; i-- ) { - const paragraphBlockElement = - await editorPage.getTextBlockAtPosition( blockNames.paragraph ); - await paragraphBlockElement.click(); - await editorPage.removeBlock(); - } + expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 3 ); } ); it( 'should be able to merge blocks with unknown html elements', async () => { - await editorPage.setHtmlContent( - [ + await editorPage.initializeEditor( { + initialData: [ testData.unknownElementParagraphBlock, testData.lettersInParagraphBlock, - ].join( '\n\n' ) - ); + ].join( '\n\n' ), + } ); // Merge paragraphs. const paragraphBlockElement = await editorPage.getTextBlockAtPosition( @@ -123,18 +115,16 @@ describe( 'Gutenberg Editor tests for Paragraph Block', () => { const mergedBlockText = await editorPage.getTextForParagraphBlockAtPosition( 1 ); expect( text0 + text1 ).toMatch( mergedBlockText ); - - await editorPage.removeBlock(); } ); // Based on https://github.com/wordpress-mobile/gutenberg-mobile/pull/1507 it( 'should handle multiline paragraphs from web', async () => { - await editorPage.setHtmlContent( - [ + await editorPage.initializeEditor( { + initialData: [ testData.multiLinesParagraphBlock, testData.paragraphBlockEmpty, - ].join( '\n\n' ) - ); + ].join( '\n\n' ), + } ); // Merge paragraphs. const paragraphBlockElement = await editorPage.getTextBlockAtPosition( @@ -149,10 +139,5 @@ describe( 'Gutenberg Editor tests for Paragraph Block', () => { // Verify the editor has not crashed. const text = await editorPage.getTextForParagraphBlockAtPosition( 1 ); expect( text.length ).not.toEqual( 0 ); - - if ( isAndroid() ) { - await paragraphBlockElement.click(); - } - await editorPage.removeBlock(); } ); } ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-media-blocks.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-media-blocks.test.js index 947006332bb340..4d877049622ab8 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-media-blocks.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-media-blocks.test.js @@ -5,7 +5,7 @@ import { mediaBlocks } from '../src/initial-html'; describe( 'Gutenberg Editor Rendering Media Blocks test', () => { it( 'should be able to render blocks correctly', async () => { - await editorPage.setHtmlContent( mediaBlocks ); + await editorPage.initializeEditor( { initialData: mediaBlocks } ); // Give some time to media placeholders to render. await editorPage.driver.sleep( 3000 ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-other-blocks.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-other-blocks.test.js index 2354874caafcf0..bfa4053e8c57eb 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-other-blocks.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-other-blocks.test.js @@ -5,7 +5,7 @@ import { otherBlocks } from '../src/initial-html'; describe( 'Gutenberg Editor Rendering Other Blocks test', () => { it( 'should be able to render blocks correctly', async () => { - await editorPage.setHtmlContent( otherBlocks ); + await editorPage.initializeEditor( { initialData: otherBlocks } ); // Scroll to the last element. const addBlockPlaceholder = diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-text-blocks.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-text-blocks.test.js index 538638d2e89945..9e5569e7083b7e 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-text-blocks.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-rendering-text-blocks.test.js @@ -5,7 +5,7 @@ import { textBlocks } from '../src/initial-html'; describe( 'Gutenberg Editor Rendering Text Blocks test', () => { it( 'should be able to render blocks correctly', async () => { - await editorPage.setHtmlContent( textBlocks ); + await editorPage.initializeEditor( { initialData: textBlocks } ); // Scroll to the last element const addBlockPlaceholder = diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-search.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-search.test.js index 4b425df306c478..95082777711b33 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-search.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-search.test.js @@ -14,16 +14,25 @@ const testIds = { const searchBlockHtml = `<!-- wp:search {"label":"","buttonText":""} /-->`; describe( 'Gutenberg Editor Search Block tests.', () => { + it( 'Able to add the Search Block.', async () => { + await editorPage.initializeEditor(); + await editorPage.addNewBlock( blockNames.search ); + const searchBlock = await editorPage.getBlockAtPosition( + blockNames.search + ); + expect( searchBlock ).toBeTruthy(); + } ); + describe( 'Editing Search Block elements.', () => { - beforeAll( async () => { + beforeEach( async () => { // Add a search block with all child elements having no text. // This is important to get around test flakiness where sometimes // the existing default text isn't replaced properly when entering // new text during testing. - await editorPage.setHtmlContent( searchBlockHtml ); - } ); + await editorPage.initializeEditor( { + initialData: searchBlockHtml, + } ); - beforeEach( async () => { // Tap search block to ensure selected. const searchBlock = await editorPage.getBlockAtPosition( blockNames.search @@ -31,10 +40,6 @@ describe( 'Gutenberg Editor Search Block tests.', () => { await searchBlock.click(); } ); - afterAll( async () => { - await removeSearchBlock(); - } ); - it( 'Able to customize label text', async () => { await editorPage.sendTextToSearchBlockChild( testIds.label, @@ -73,17 +78,10 @@ describe( 'Gutenberg Editor Search Block tests.', () => { } ); describe( 'Changing search block settings.', () => { - afterAll( async () => { - await removeSearchBlock(); - } ); - - it( 'Able to add the Search Block.', async () => { - await editorPage.addNewBlock( blockNames.search ); - const searchBlock = await editorPage.getBlockAtPosition( - blockNames.search - ); - - expect( searchBlock ).toBeTruthy(); + beforeEach( async () => { + await editorPage.initializeEditor( { + initialData: searchBlockHtml, + } ); } ); it( 'Able to hide search block label', async () => { @@ -148,16 +146,6 @@ describe( 'Gutenberg Editor Search Block tests.', () => { } ); } ); -const removeSearchBlock = async () => { - const searchBlock = await editorPage.getBlockAtPosition( - blockNames.search - ); - await searchBlock.click(); - - // Remove search block. - await editorPage.removeBlock(); -}; - const verifySearchElementText = async ( testId, expected ) => { let actual; diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-unsupported-blocks.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-unsupported-blocks.test.js deleted file mode 100644 index a5520ac92a468a..00000000000000 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-unsupported-blocks.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Internal dependencies - */ -import testData from './helpers/test-data'; -import { isAndroid } from './helpers/utils'; - -// Disabled for now on Android see https://github.com/wordpress-mobile/gutenberg-mobile/issues/5321 -const onlyOniOS = ! isAndroid() ? describe : describe.skip; - -onlyOniOS( 'Gutenberg Editor Unsupported Block Editor Tests', () => { - it( 'should be able to open the unsupported block web view editor', async () => { - await editorPage.setHtmlContent( testData.unsupportedBlockHtml ); - - const firstVisibleBlock = await editorPage.getFirstBlockVisible(); - await firstVisibleBlock.click(); - - const helpButton = await editorPage.getUnsupportedBlockHelpButton(); - await helpButton.click(); - - const editButton = - await editorPage.getUnsupportedBlockBottomSheetEditButton(); - await editButton.click(); - - const webView = await editorPage.getUnsupportedBlockWebView(); - await expect( webView ).toBeTruthy(); - } ); -} ); diff --git a/packages/react-native-editor/__device-tests__/helpers/appium-local-start.js b/packages/react-native-editor/__device-tests__/helpers/appium-local-start.js new file mode 100644 index 00000000000000..a8e62b6b1f67f0 --- /dev/null +++ b/packages/react-native-editor/__device-tests__/helpers/appium-local-start.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +const { start } = require( './appium-local.js' ); + +start( { flags: '--allow-cors' } ).then( + () => { + // eslint-disable-next-line no-console + console.log( 'INFO: Appium server started successfully' ); + }, + ( error ) => { + // eslint-disable-next-line no-console + console.error( error ); + process.exit( 1 ); + } +); diff --git a/packages/react-native-editor/__device-tests__/helpers/appium-local.js b/packages/react-native-editor/__device-tests__/helpers/appium-local.js index cf41032b9cdd31..b4b4f415d10ce2 100644 --- a/packages/react-native-editor/__device-tests__/helpers/appium-local.js +++ b/packages/react-native-editor/__device-tests__/helpers/appium-local.js @@ -4,16 +4,18 @@ const childProcess = require( 'child_process' ); // Spawns an appium process. -const start = ( localAppiumPort ) => +const start = ( { port = 4723, flags } ) => new Promise( ( resolve, reject ) => { - const appium = childProcess.spawn( 'appium', [ + const args = [ '--port', - localAppiumPort.toString(), + port.toString(), '--log', './appium-out.log', '--log-no-colors', '--relaxed-security', // Needed for mobile:shell commend for text entry on Android - ] ); + flags, + ].filter( Boolean ); + const appium = childProcess.spawn( 'appium', args ); let appiumOutputBuffer = ''; let resolved = false; diff --git a/packages/react-native-editor/__device-tests__/helpers/caps.js b/packages/react-native-editor/__device-tests__/helpers/caps.js index b9e6f61bd445aa..05346dcbbb4801 100644 --- a/packages/react-native-editor/__device-tests__/helpers/caps.js +++ b/packages/react-native-editor/__device-tests__/helpers/caps.js @@ -9,6 +9,7 @@ const ios = { processArguments: { args: [ 'uitesting' ], }, + autoLaunch: false, }; exports.iosLocal = ( { iPadDevice = false } ) => ( { @@ -39,4 +40,5 @@ exports.android = { appiumVersion: '1.22.1', app: undefined, disableWindowAnimation: true, + autoLaunch: false, }; diff --git a/packages/react-native-editor/__device-tests__/helpers/serverConfigs.js b/packages/react-native-editor/__device-tests__/helpers/serverConfigs.js index 6f7ec3aab8f60d..a78c0bb9f6f12f 100644 --- a/packages/react-native-editor/__device-tests__/helpers/serverConfigs.js +++ b/packages/react-native-editor/__device-tests__/helpers/serverConfigs.js @@ -1,5 +1,5 @@ exports.local = { - host: 'localhost', + host: '127.0.0.1', port: 4723, // Port for local Appium runs. }; diff --git a/packages/react-native-editor/__device-tests__/helpers/test-data.js b/packages/react-native-editor/__device-tests__/helpers/test-data.js index 66619e803bd5ed..c356d9a91d16a1 100644 --- a/packages/react-native-editor/__device-tests__/helpers/test-data.js +++ b/packages/react-native-editor/__device-tests__/helpers/test-data.js @@ -202,3 +202,33 @@ exports.galleryBlock = `<!-- wp:gallery {"columns":8,"linkTo":"none","className" <!-- /wp:image --> </figure> <!-- /wp:gallery -->`; + +exports.groupNestedStructure = `<!-- wp:group {"layout":{"type":"constrained"}} --> +<div class="wp-block-group"><!-- wp:paragraph {"style":{"color":{"background":"#f9d0d0"}}} --> +<p class="has-background" style="background-color:#f9d0d0">Level 1</p> +<!-- /wp:paragraph --> + +<!-- wp:spacer {"height":"50px"} --> +<div style="height:50px" aria-hidden="true" class="wp-block-spacer"></div> +<!-- /wp:spacer --> + +<!-- wp:group {"layout":{"type":"constrained"}} --> +<div class="wp-block-group"><!-- wp:paragraph {"style":{"color":{"background":"#d5f0ab"}}} --> +<p class="has-background" style="background-color:#d5f0ab">Level 2</p> +<!-- /wp:paragraph --> + +<!-- wp:spacer {"height":"50px"} --> +<div style="height:50px" aria-hidden="true" class="wp-block-spacer"></div> +<!-- /wp:spacer --> + +<!-- wp:group {"layout":{"type":"constrained"}} --> +<div class="wp-block-group"><!-- wp:paragraph {"style":{"color":{"background":"#c3e5ff"}}} --> +<p class="has-background" style="background-color:#c3e5ff">Level 3</p> +<!-- /wp:paragraph --> + +<!-- wp:spacer {"height":"50px"} --> +<div style="height:50px" aria-hidden="true" class="wp-block-spacer"></div> +<!-- /wp:spacer --></div> +<!-- /wp:group --></div> +<!-- /wp:group --></div> +<!-- /wp:group -->`; diff --git a/packages/react-native-editor/__device-tests__/helpers/utils.js b/packages/react-native-editor/__device-tests__/helpers/utils.js index 4591cd4176d251..64fb2ed510a45e 100644 --- a/packages/react-native-editor/__device-tests__/helpers/utils.js +++ b/packages/react-native-editor/__device-tests__/helpers/utils.js @@ -38,16 +38,12 @@ let appiumProcess; const backspace = '\u0008'; -// Used to map unicode and special values to keycodes on Android -// Docs for keycode values: https://developer.android.com/reference/android/view/KeyEvent.html -const strToKeycode = { - '\n': 66, - [ backspace ]: 67, -}; - // $block-edge-to-content value const blockEdgeToContent = 16; +const IOS_BUNDLE_ID = 'org.wordpress.gutenberg.development'; +const ANDROID_COMPONENT_NAME = 'com.gutenberg/.MainActivity'; + const timer = ( ms ) => new Promise( ( res ) => setTimeout( res, ms ) ); const isAndroid = () => { @@ -76,7 +72,9 @@ const setupDriver = async () => { const safeBranchName = branch.replace( /\//g, '-' ); if ( isLocalEnvironment() ) { try { - appiumProcess = await AppiumLocal.start( localAppiumPort ); + appiumProcess = await AppiumLocal.start( { + port: localAppiumPort, + } ); } catch ( err ) { // Ignore error here, Appium is probably already running (Appium Inspector has its own server for instance) // eslint-disable-next-line no-console @@ -195,6 +193,9 @@ const stopDriver = async ( driver ) => { * On iOS: "clear" is not defaulted to true because calling element.clear when a text is present takes a very long time (approx. 23 seconds) */ const typeString = async ( driver, element, str, clear ) => { + if ( isKeycode( str ) ) { + return await pressKeycode( driver, getKeycode( str ) ); + } if ( isAndroid() ) { await typeStringAndroid( driver, element, str, clear ); } else { @@ -236,9 +237,7 @@ const typeStringAndroid = async ( str, clear = true // See comment above for why it is defaulted to true. ) => { - if ( str in strToKeycode ) { - return await driver.pressKeycode( strToKeycode[ str ] ); - } else if ( clear ) { + if ( clear ) { /* * On Android `element.type` deletes the contents of the EditText before typing and, unfortunately, * with our blocks it also deletes the block entirely. We used to avoid this by using adb to enter @@ -267,9 +266,13 @@ const typeStringAndroid = async ( const paragraphs = str.split( '\n' ); for ( let i = 0; i < paragraphs.length; i++ ) { const paragraph = paragraphs[ i ].replace( /[ ]/g, '%s' ); - if ( paragraph in strToKeycode ) { - await driver.pressKeycode( strToKeycode[ paragraph ] ); - } else { + if ( isKeycode( paragraph ) ) { + return await pressKeycode( driver, getKeycode( paragraph ) ); + } + // Empty values passed in the `args` list of `execute` function are removed. + // In order to avoid an exception in `text` command due to passing fewer arguments, we don't + // execute the command with empty strings. + else if ( paragraph !== '' ) { // Execute with adb shell input <text> since normal type auto clears field on Android await driver.execute( 'mobile: shell', { command: 'input', @@ -277,12 +280,59 @@ const typeStringAndroid = async ( } ); } if ( i !== paragraphs.length - 1 ) { - await driver.pressKeycode( strToKeycode[ '\n' ] ); + await pressKeycode( driver, getKeycode( '\n' ) ); } } } }; +/** + * Returns the mapped keycode for a string to use in `pressKeycode` function. + * + * @param {string} str String associated to a keycode + */ +const getKeycode = ( str ) => { + if ( isAndroid() ) { + // On Android, we map keycodes using Android values. + // Reference: https://developer.android.com/reference/android/view/KeyEvent.html + return { + '\n': 66, + [ backspace ]: 67, + }[ str ]; + } + // On iOS, we map keycodes using the special keys defined in WebDriver. + // Reference: https://github.com/admc/wd/blob/master/lib/special-keys.js + return { + '\n': wd.SPECIAL_KEYS.Enter, + [ backspace ]: wd.SPECIAL_KEYS[ 'Back space' ], + }[ str ]; +}; + +/** + * Determines if the string is mapped to a keycode. + * + * @param {string} str String potentially associated to a keycode + */ +const isKeycode = ( str ) => { + return !! getKeycode( str ); +}; + +/** + * Presses the specified keycode. + * + * @param {*} driver WebDriver instance + * @param {*} keycode Keycode to press + */ +const pressKeycode = async ( driver, keycode ) => { + if ( isAndroid() ) { + // `pressKeycode` command is only implemented on Android + return await driver.pressKeycode( keycode ); + } + // `keys` command only works on iOS. On Android, executing this + // results in typing a special character instead. + return await driver.keys( [ keycode ] ); +}; + // Calculates middle x,y and clicks that position const clickMiddleOfElement = async ( driver, element ) => { const location = await element.getLocation(); @@ -476,18 +526,21 @@ const dragAndDropAfterElement = async ( driver, element, nextElement ) => { const toggleHtmlMode = async ( driver, toggleOn ) => { if ( isAndroid() ) { - // Hit the "Menu" key. - await driver.pressKeycode( 82 ); + const moreOptionsButton = await driver.elementByAccessibilityId( + 'More options' + ); + await moreOptionsButton.click(); const showHtmlButtonXpath = '/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.ListView/android.widget.TextView[9]'; await clickIfClickable( driver, showHtmlButtonXpath ); } else if ( toggleOn ) { - await clickIfClickable( - driver, - '//XCUIElementTypeButton[@name="..."]' + const moreOptionsButton = await driver.elementByAccessibilityId( + 'editor-menu-button' ); + await moreOptionsButton.click(); + await clickIfClickable( driver, '//XCUIElementTypeButton[@name="Switch to HTML"]' @@ -495,10 +548,10 @@ const toggleHtmlMode = async ( driver, toggleOn ) => { } else { // This is to wait for the clipboard paste notification to disappear, currently it overlaps with the menu button await driver.sleep( 3000 ); - await clickIfClickable( - driver, - '//XCUIElementTypeButton[@name="..."]' + const moreOptionsButton = await driver.elementByAccessibilityId( + 'editor-menu-button' ); + await moreOptionsButton.click(); await clickIfClickable( driver, '//XCUIElementTypeButton[@name="Switch To Visual"]' @@ -702,6 +755,30 @@ const clearClipboard = async ( driver, contentType = 'plaintext' ) => { await driver.setClipboard( '', contentType ); }; +const launchApp = async ( driver, initialProps = {} ) => { + if ( isAndroid() ) { + await driver.execute( 'mobile: startActivity', { + component: ANDROID_COMPONENT_NAME, + stop: true, + extras: [ + [ + 's', + 'initialProps', + `'${ JSON.stringify( initialProps ) }'`, + ], + ], + } ); + } else { + await driver.execute( 'mobile: terminateApp', { + bundleId: IOS_BUNDLE_ID, + } ); + await driver.execute( 'mobile: launchApp', { + bundleId: IOS_BUNDLE_ID, + arguments: [ 'uitesting', JSON.stringify( initialProps ) ], + } ); + } +}; + module.exports = { backspace, clearClipboard, @@ -715,10 +792,11 @@ module.exports = { isEditorVisible, isElementVisible, isLocalEnvironment, + launchApp, longPressMiddleOfElement, + selectTextFromElement, setClipboard, setupDriver, - selectTextFromElement, stopDriver, swipeDown, swipeFromTo, diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js index 3b06187482a6f4..8e2bc02d657b9a 100644 --- a/packages/react-native-editor/__device-tests__/pages/editor-page.js +++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js @@ -22,29 +22,14 @@ const { typeString, waitForVisible, clickIfClickable, + launchApp, } = require( '../helpers/utils' ); -const ADD_BLOCK_ID = isAndroid() - ? 'Add block, Double tap to add a block' - : 'Add block'; +const ADD_BLOCK_ID = isAndroid() ? 'Add block' : 'add-block-button'; -const initializeEditorPage = async () => { +const setupEditor = async () => { const driver = await setupDriver(); - await isEditorVisible( driver ); - const initialValues = await setupInitialValues( driver ); - return new EditorPage( driver, initialValues ); -}; - -// Stores initial values from the editor for different helpers. -const setupInitialValues = async ( driver ) => { - const initialValues = {}; - const addButton = await driver.elementsByAccessibilityId( ADD_BLOCK_ID ); - - if ( addButton.length !== 0 ) { - initialValues.addButtonLocation = await addButton[ 0 ].getLocation(); - } - - return initialValues; + return new EditorPage( driver ); }; class EditorPage { @@ -55,11 +40,12 @@ class EditorPage { verseBlockName = 'Verse'; orderedListButtonName = 'Ordered'; - constructor( driver, initialValues ) { + constructor( driver ) { this.driver = driver; this.accessibilityIdKey = 'name'; this.accessibilityIdXPathAttrib = 'name'; - this.initialValues = initialValues; + this.initialValues = {}; + this.blockNames = blockNames; if ( isAndroid() ) { this.accessibilityIdXPathAttrib = 'content-desc'; @@ -67,15 +53,31 @@ class EditorPage { } } + async initializeEditor( { initialData } = {} ) { + await launchApp( this.driver, { initialData } ); + + // Stores initial values from the editor for different helpers. + const addButton = await this.driver.elementsByAccessibilityId( + ADD_BLOCK_ID + ); + + if ( addButton.length !== 0 ) { + this.initialValues.addButtonLocation = + await addButton[ 0 ].getLocation(); + } + + await isEditorVisible( this.driver ); + } + async getBlockList() { return await this.driver.hasElementByAccessibilityId( 'block-list' ); } - async getAddBlockButton( options = { timeout: 3000 } ) { - return await this.waitForElementToBeDisplayedById( - ADD_BLOCK_ID, - options.timeout + async getAddBlockButton() { + const elements = await this.driver.elementsByAccessibilityId( + ADD_BLOCK_ID ); + return elements[ 0 ]; } // =============================== @@ -188,7 +190,7 @@ class EditorPage { async getTitleElement( options = { autoscroll: false } ) { const titleElement = isAndroid() - ? 'Post title. Welcome to Gutenberg!, Updates the title.' + ? 'Post title. Welcome to Gutenberg!' : 'post-title'; if ( options.autoscroll ) { @@ -292,11 +294,9 @@ class EditorPage { } const hideKeyboardButton = isAndroid() - ? await this.waitForElementToBeDisplayedById( - 'Hide keyboard, Tap to hide the keyboard' - ) + ? await this.waitForElementToBeDisplayedById( 'Hide keyboard' ) : await this.waitForElementToBeDisplayedByXPath( - '//XCUIElementTypeButton[@name="Hide keyboard"]' + '(//XCUIElementTypeOther[@name="Hide keyboard"])[1]' ); await hideKeyboardButton.click(); @@ -341,6 +341,46 @@ class EditorPage { } } + async swipeToolbarToElement( elementSelector, options ) { + const { byId, swipeRight } = options || {}; + const offset = isAndroid() ? 300 : 50; + const maxLocatorAttempts = 5; + let locatorAttempts = 0; + let element; + + const toolbar = await this.getToolbar(); + const toolbarLocation = await toolbar.getLocation(); + const toolbarSize = await toolbar.getSize(); + + while ( locatorAttempts < maxLocatorAttempts ) { + element = byId + ? await this.driver.elementsByAccessibilityId( elementSelector ) + : await this.driver.elementsByXPath( elementSelector ); + if ( await element[ 0 ]?.isDisplayed() ) { + break; + } + + swipeFromTo( + this.driver, + { + x: ! swipeRight + ? toolbarSize.width - offset + : toolbarSize.width / 2, + y: toolbarLocation.y + toolbarSize.height / 2, + }, + { + x: ! swipeRight + ? toolbarSize.width / 2 + : toolbarSize.width - offset, + y: toolbarLocation.y + toolbarSize.height / 2, + }, + 1000 + ); + locatorAttempts++; + } + return element; + } + async openBlockSettings() { const settingsButtonElement = isAndroid() ? '//android.widget.Button[@content-desc="Open Settings"]/android.view.ViewGroup' @@ -356,10 +396,10 @@ class EditorPage { const blockActionsButtonElement = isAndroid() ? '//android.widget.Button[contains(@content-desc, "Open Block Actions Menu")]' : '//XCUIElementTypeButton[@name="Open Block Actions Menu"]'; - const blockActionsMenu = await this.waitForElementToBeDisplayedByXPath( + const blockActionsMenu = await this.swipeToolbarToElement( blockActionsButtonElement ); - await blockActionsMenu.click(); + await blockActionsMenu[ 0 ].click(); const removeElement = 'Remove block'; const removeBlockButton = await this.waitForElementToBeDisplayedById( @@ -378,27 +418,22 @@ class EditorPage { // ========================= async getToolbar() { - return await this.driver.elementsByAccessibilityId( 'Document tools' ); + return this.waitForElementToBeDisplayedById( 'Document tools', 4000 ); } async addNewBlock( blockName, { skipInserterOpen = false } = {} ) { if ( ! skipInserterOpen ) { - const addButton = await this.getAddBlockButton(); - await addButton.click(); + const addButton = await this.swipeToolbarToElement( ADD_BLOCK_ID, { + byId: true, + swipeRight: true, + } ); + await addButton[ 0 ].click(); } // Click on block of choice. const blockButton = await this.findBlockButton( blockName ); - if ( isAndroid() ) { - await blockButton.click(); - } else { - await this.driver.execute( 'mobile: tap', { - element: blockButton, - x: 10, - y: 10, - } ); - } + await blockButton.click(); } static getInserterPageHeight( screenHeight ) { @@ -487,6 +522,8 @@ class EditorPage { toY: EditorPage.getInserterPageHeight( height ), duration: 0.5, } ); + // Wait for dragging gesture + await this.driver.sleep( 2000 ); } return blockButton; @@ -515,6 +552,29 @@ class EditorPage { } while ( navigateUpElements.length > 0 ); } + // Adds a block by tapping on the appender button of blocks with inner blocks (e.g. Group block) + async addBlockUsingAppender( block, blockName ) { + const appenderButton = isAndroid() + ? await this.waitForElementToBeDisplayedByXPath( + `//android.widget.Button[@resource-id="appender-button"]` + ) + : await this.waitForElementToBeDisplayedById( 'appender-button' ); + await appenderButton.click(); + + // Click on block of choice. + const blockButton = await this.findBlockButton( blockName ); + + if ( isAndroid() ) { + await blockButton.click(); + } else { + await this.driver.execute( 'mobile: tap', { + element: blockButton, + x: 10, + y: 10, + } ); + } + } + // ========================= // Inline toolbar functions // ========================= @@ -599,10 +659,8 @@ class EditorPage { const identifier = isAndroid() ? `//android.widget.Button[@content-desc="${ formatting }"]/android.view.ViewGroup` : `//XCUIElementTypeButton[@name="${ formatting }"]`; - const toggleElement = await this.waitForElementToBeDisplayedByXPath( - identifier - ); - return await toggleElement.click(); + const toggleElement = await this.swipeToolbarToElement( identifier ); + return await toggleElement[ 0 ].click(); } async openLinkToSettings() { @@ -631,7 +689,7 @@ class EditorPage { await this.typeTextToTextBlock( block, paragraphs[ i ], clear ); if ( i !== paragraphs.length - 1 ) { - await this.typeTextToTextBlock( block, '\n', false ); + await this.typeTextToTextBlock( block, '\n' ); } } } @@ -927,7 +985,7 @@ class EditorPage { async addButtonWithInlineAppender( position = 1 ) { const appenderButton = isAndroid() ? await this.waitForElementToBeDisplayedByXPath( - `//android.widget.Button[@content-desc="Buttons Block. Row 1"]/android.view.ViewGroup/android.view.ViewGroup[1]/android.widget.Button[${ position }]` + `//android.widget.Button[@content-desc="Buttons Block. Row 1"]/android.view.ViewGroup/android.view.ViewGroup[1]/android.view.ViewGroup/android.view.ViewGroup/android.view.ViewGroup[${ position }]/android.view.ViewGroup/android.widget.Button` ) : await this.waitForElementToBeDisplayedById( 'appender-button' ); await appenderButton.click(); @@ -966,6 +1024,7 @@ const blockNames = { paragraph: 'Paragraph', search: 'Search', separator: 'Separator', + socialIcons: 'Social Icons', spacer: 'Spacer', verse: 'Verse', shortcode: 'Shortcode', @@ -973,6 +1032,7 @@ const blockNames = { buttons: 'Buttons', button: 'Button', preformatted: 'Preformatted', + unsupported: 'Unsupported', }; -module.exports = { initializeEditorPage, blockNames }; +module.exports = { setupEditor, blockNames }; diff --git a/packages/react-native-editor/android/.java-version b/packages/react-native-editor/android/.java-version new file mode 100644 index 00000000000000..03b6389f32ad57 --- /dev/null +++ b/packages/react-native-editor/android/.java-version @@ -0,0 +1 @@ +17.0 diff --git a/packages/react-native-editor/android/app/BUCK b/packages/react-native-editor/android/app/BUCK deleted file mode 100644 index bd517c4b3f0328..00000000000000 --- a/packages/react-native-editor/android/app/BUCK +++ /dev/null @@ -1,55 +0,0 @@ -# To learn about Buck see [Docs](https://buckbuild.com/). -# To run your application with Buck: -# - install Buck -# - `npm start` - to start the packager -# - `cd android` -# - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"` -# - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck -# - `buck install -r android/app` - compile, install and run application -# - -load(":build_defs.bzl", "create_aar_targets", "create_jar_targets") - -lib_deps = [] - -create_aar_targets(glob(["libs/*.aar"])) - -create_jar_targets(glob(["libs/*.jar"])) - -android_library( - name = "all-libs", - exported_deps = lib_deps, -) - -android_library( - name = "app-code", - srcs = glob([ - "src/main/java/**/*.java", - ]), - deps = [ - ":all-libs", - ":build_config", - ":res", - ], -) - -android_build_config( - name = "build_config", - package = "com.gutenberg", -) - -android_resource( - name = "res", - package = "com.gutenberg", - res = "src/main/res", -) - -android_binary( - name = "app", - keystore = "//android/keystores:debug", - manifest = "src/main/AndroidManifest.xml", - package_type = "debug", - deps = [ - ":app-code", - ], -) diff --git a/packages/react-native-editor/android/app/build.gradle b/packages/react-native-editor/android/app/build.gradle index d143a36952ee1c..5bbb6e560a4916 100644 --- a/packages/react-native-editor/android/app/build.gradle +++ b/packages/react-native-editor/android/app/build.gradle @@ -1,116 +1,68 @@ apply plugin: "com.android.application" +apply plugin: "com.facebook.react" apply from: "../../../react-native-bridge/android/extractPackageVersion.gradle" import com.android.build.OutputFile -/** - * The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets - * and bundleReleaseJsAndAssets). - * These basically call `react-native bundle` with the correct arguments during the Android build - * cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the - * bundle directly from the development server. Below you can see all the possible configurations - * and their defaults. If you decide to add a configuration block, make sure to add it before the - * `apply from: "../../node_modules/react-native/react.gradle"` line. - * - * project.ext.react = [ - * // the name of the generated asset file containing your JS bundle - * bundleAssetName: "index.android.bundle", - * - * // the entry file for bundle generation. If none specified and - * // "index.android.js" exists, it will be used. Otherwise "index.js" is - * // default. Can be overridden with ENTRY_FILE environment variable. - * entryFile: "index.android.js", - * - * // https://reactnative.dev/docs/performance#enable-the-ram-format - * bundleCommand: "ram-bundle", - * - * // whether to bundle JS and assets in debug mode - * bundleInDebug: false, - * - * // whether to bundle JS and assets in release mode - * bundleInRelease: true, - * - * // whether to bundle JS and assets in another build variant (if configured). - * // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants - * // The configuration property can be in the following formats - * // 'bundleIn${productFlavor}${buildType}' - * // 'bundleIn${buildType}' - * // bundleInFreeDebug: true, - * // bundleInPaidRelease: true, - * // bundleInBeta: true, - * - * // whether to disable dev mode in custom build variants (by default only disabled in release) - * // for example: to disable dev mode in the staging build type (if configured) - * devDisabledInStaging: true, - * // The configuration property can be in the following formats - * // 'devDisabledIn${productFlavor}${buildType}' - * // 'devDisabledIn${buildType}' - * - * // the root of your project, i.e. where "package.json" lives - * root: "../../", - * - * // where to put the JS bundle asset in debug mode - * jsBundleDirDebug: "$buildDir/intermediates/assets/debug", - * - * // where to put the JS bundle asset in release mode - * jsBundleDirRelease: "$buildDir/intermediates/assets/release", - * - * // where to put drawable resources / React Native assets, e.g. the ones you use via - * // require('./image.png')), in debug mode - * resourcesDirDebug: "$buildDir/intermediates/res/merged/debug", - * - * // where to put drawable resources / React Native assets, e.g. the ones you use via - * // require('./image.png')), in release mode - * resourcesDirRelease: "$buildDir/intermediates/res/merged/release", - * - * // by default the gradle tasks are skipped if none of the JS files or assets change; this means - * // that we don't look at files in android/ or ios/ to determine whether the tasks are up to - * // date; if you have any other folders that you want to ignore for performance reasons (gradle - * // indexes the entire tree), add them here. Alternatively, if you have JS files in android/ - * // for example, you might want to remove it from here. - * inputExcludes: ["android/**", "ios/**"], - * - * // override which node gets called and with what additional arguments - * nodeExecutableAndArgs: ["node"], - * - * // supply additional arguments to the packager - * extraPackagerArgs: [] - * ] - */ - -project.ext.react = [ - enableHermes: true, // clean and rebuild if changing - cliPath: "../../../../node_modules/.bin/react-native", -] - -apply from: "../../../../node_modules/react-native/react.gradle" +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '..' + root = file("../../../../") + // The folder where the react-native NPM package is. Default is ../node_modules/react-native + // reactNativeDir = file("../../../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen + // codegenDir = file("../node_modules/react-native-codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js + // cliFile = file("../node_modules/react-native/cli.js") + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. By default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] +} /** - * Set this to true to create two separate APKs instead of one: - * - An APK that only works on ARM devices - * - An APK that only works on x86 devices - * The advantage is the size of the APK is reduced by about 4MB. - * Upload all the APKs to the Play Store and people will download - * the correct one based on the CPU architecture of their device. + * Set this to true to create four separate APKs instead of one, + * one for each native architecture. This is useful if you don't + * use App Bundles (https://developer.android.com/guide/app-bundle/) + * and want to have separate APKs to upload to the Play Store. */ def enableSeparateBuildPerCPUArchitecture = false /** - * Run Proguard to shrink the Java bytecode in release builds. + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. */ def enableProguardInReleaseBuilds = false /** - * Whether to enable the Hermes VM. - * - * This should be set on project.ext.react and that value will be read here. If it is not set - * on project.ext.react, JavaScript will not be compiled to Hermes Bytecode - * and the benefits of using Hermes will therefore be sharply reduced. - */ -def enableHermes = project.ext.react.get("enableHermes", false); - -/** - * Architectures to build native code for. + * Private function to get the list of Native Architectures you want to build. + * This reads the value from reactNativeArchitectures in your gradle.properties + * file and works together with the --active-arch-only flag of react-native run-android. */ def reactNativeArchitectures() { def value = project.getProperties().get("reactNativeArchitectures") @@ -118,6 +70,10 @@ def reactNativeArchitectures() { } android { + // IMPORTANT: Any updates to the namespace should be reflected in + // the `package` attribute of the main `AndroidManifest.xml` file. + // File reference: `react-native-editor/android/app/src/main/AndroidManifest.xml` + namespace "com.gutenberg" ndkVersion rootProject.ext.ndkVersion compileSdkVersion rootProject.ext.compileSdkVersion @@ -127,81 +83,18 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + buildFeatures { + buildConfig true + } + defaultConfig { applicationId "com.gutenberg" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 versionName "1.0" - buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() - - if (isNewArchitectureEnabled()) { - // We configure the NDK build only if you decide to opt-in for the New Architecture. - externalNativeBuild { - ndkBuild { - arguments "APP_PLATFORM=android-21", - "APP_STL=c++_shared", - "NDK_TOOLCHAIN_VERSION=clang", - "GENERATED_SRC_DIR=$buildDir/generated/source", - "PROJECT_BUILD_DIR=$buildDir", - "REACT_ANDROID_DIR=../../../../node_modules/react-native/ReactAndroid", - "REACT_ANDROID_BUILD_DIR=../../../../node_modules/react-native/ReactAndroid/build", - "NODE_MODULES_DIR=../../../../node_modules" - cFlags "-Wall", "-Werror", "-fexceptions", "-frtti", "-DWITH_INSPECTOR=1" - cppFlags "-std=c++17" - // Make sure this target name is the same you specify inside the - // src/main/jni/Android.mk file for the `LOCAL_MODULE` variable. - targets "gutenberg_appmodules" - } - } - if (!enableSeparateBuildPerCPUArchitecture) { - ndk { - abiFilters (*reactNativeArchitectures()) - } - } - } } - if (isNewArchitectureEnabled()) { - // We configure the NDK build only if you decide to opt-in for the New Architecture. - externalNativeBuild { - ndkBuild { - path "$projectDir/src/main/jni/Android.mk" - } - } - def reactAndroidProjectDir = project(':ReactAndroid').projectDir - def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) { - dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck") - from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib") - into("$buildDir/react-ndk/exported") - } - def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) { - dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck") - from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib") - into("$buildDir/react-ndk/exported") - } - afterEvaluate { - // If you wish to add a custom TurboModule or component locally, - // you should uncomment this line. - // preBuild.dependsOn("generateCodegenArtifactsFromSchema") - preDebugBuild.dependsOn(packageReactNdkDebugLibs) - preReleaseBuild.dependsOn(packageReactNdkReleaseLibs) - - // Due to a bug inside AGP, we have to explicitly set a dependency - // between configureNdkBuild* tasks and the preBuild tasks. - // This can be removed once this is solved: https://issuetracker.google.com/issues/207403732 - configureNdkBuildRelease.dependsOn(preReleaseBuild) - configureNdkBuildDebug.dependsOn(preDebugBuild) - reactNativeArchitectures().each { architecture -> - tasks.findByName("configureNdkBuildDebug[${architecture}]")?.configure { - dependsOn("preDebugBuild") - } - tasks.findByName("configureNdkBuildRelease[${architecture}]")?.configure { - dependsOn("preReleaseBuild") - } - } - } - } splits { abi { reset() @@ -241,25 +134,19 @@ dependencies { implementation "org.wordpress-mobile.gutenberg-mobile:react-native-bridge" implementation 'androidx.appcompat:appcompat:1.2.0' - //noinspection GradleDynamicVersion - implementation "com.facebook.react:react-native:${extractPackageVersion(packageJson, 'react-native', 'dependencies')}" + implementation "com.google.android.material:material:1.9.0" + // The version of react-native is set by the React Native Gradle Plugin + implementation "com.facebook.react:react-android" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" implementation "com.github.wordpress-mobile:react-native-video:${extractPackageVersion(packageJson, 'react-native-video', 'dependencies')}" - implementation "com.github.wordpress-mobile:react-native-linear-gradient:${extractPackageVersion(packageJson, 'react-native-linear-gradient', 'dependencies')}" implementation "com.github.wordpress-mobile:react-native-slider:${extractPackageVersion(packageJson, '@react-native-community/slider', 'dependencies')}" - implementation "com.github.wordpress-mobile:react-native-reanimated:${extractPackageVersion(packageJson, 'react-native-reanimated', 'dependencies')}" implementation "com.github.wordpress-mobile:react-native-prompt-android:${extractPackageVersion(packageJson, 'react-native-prompt-android', 'dependencies')}" - implementation("com.github.wordpress-mobile:react-native-gesture-handler:${extractPackageVersion(packageJson, 'react-native-gesture-handler', 'dependencies')}", { - // Remove Reanimated transitive dependency as it's already defined here - exclude group: 'com.github.wordpress-mobile', module: 'react-native-reanimated' - }) - // Published by `wordpress-mobile/react-native-libraries-publisher` // See the documentation for this value in `build.gradle.kts` of `wordpress-mobile/react-native-libraries-publisher` - def reactNativeLibrariesPublisherVersion = "v1" + def reactNativeLibrariesPublisherVersion = "v3" def reactNativeLibrariesGroupId = "org.wordpress-mobile.react-native-libraries.$reactNativeLibrariesPublisherVersion" implementation "$reactNativeLibrariesGroupId:react-native-get-random-values:${extractPackageVersion(packageJson, 'react-native-get-random-values', 'dependencies')}" implementation "$reactNativeLibrariesGroupId:react-native-safe-area-context:${extractPackageVersion(packageJson, 'react-native-safe-area-context', 'dependencies')}" @@ -269,39 +156,18 @@ dependencies { implementation "$reactNativeLibrariesGroupId:react-native-masked-view:${extractPackageVersion(packageJson, '@react-native-masked-view/masked-view', 'dependencies')}" implementation "$reactNativeLibrariesGroupId:react-native-clipboard:${extractPackageVersion(packageJson, '@react-native-clipboard/clipboard', 'dependencies')}" implementation "$reactNativeLibrariesGroupId:react-native-fast-image:${extractPackageVersion(packageJson, 'react-native-fast-image', 'dependencies')}" + implementation "$reactNativeLibrariesGroupId:react-native-reanimated:${extractPackageVersion(packageJson, 'react-native-reanimated', 'dependencies')}" + implementation "$reactNativeLibrariesGroupId:react-native-gesture-handler:${extractPackageVersion(packageJson, 'react-native-gesture-handler', 'dependencies')}" + implementation "$reactNativeLibrariesGroupId:react-native-linear-gradient:${extractPackageVersion(packageJson, 'react-native-linear-gradient', 'dependencies')}" - implementation("com.facebook.react:hermes-engine:${extractPackageVersion(packageJson, 'react-native', 'dependencies')}") { - exclude group:'com.facebook.fbjni' - } - - debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { - exclude group:'com.facebook.fbjni' - } - - debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { - exclude group:'com.facebook.flipper' - } + // The version of react-native is set by the React Native Gradle Plugin + implementation "com.facebook.react:hermes-android" - debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") { - exclude group:'com.facebook.flipper' - } + debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") + debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") + debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") } -if (isNewArchitectureEnabled()) { - // If new architecture is enabled, we let you build RN from source - // Otherwise we fallback to a prebuilt .aar bundled in the NPM package. - // This will be applied to all the imported transtitive dependency. - configurations.all { - resolutionStrategy.dependencySubstitution { - substitute(module("com.facebook.react:react-native")) - .using(project(":ReactAndroid")) - .because("On New Architecture we're building React Native from source") - substitute(module("com.facebook.react:hermes-engine")) - .using(project(":ReactAndroid:hermes-engine")) - .because("On New Architecture we're building Hermes from source") - } - } -} // Run this once to be able to run the application with BUCK // puts all compile dependencies into folder libs for BUCK to use @@ -310,10 +176,3 @@ task copyDownloadableDepsToLibs(type: Copy) { into 'libs' } -def isNewArchitectureEnabled() { - // To opt-in for the New Architecture, you can either: - // - Set `newArchEnabled` to true inside the `gradle.properties` file - // - Invoke gradle with `-newArchEnabled=true` - // - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true` - return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" -} diff --git a/packages/react-native-editor/android/app/build_defs.bzl b/packages/react-native-editor/android/app/build_defs.bzl deleted file mode 100644 index fff270f8d1d484..00000000000000 --- a/packages/react-native-editor/android/app/build_defs.bzl +++ /dev/null @@ -1,19 +0,0 @@ -"""Helper definitions to glob .aar and .jar targets""" - -def create_aar_targets(aarfiles): - for aarfile in aarfiles: - name = "aars__" + aarfile[aarfile.rindex("/") + 1:aarfile.rindex(".aar")] - lib_deps.append(":" + name) - android_prebuilt_aar( - name = name, - aar = aarfile, - ) - -def create_jar_targets(jarfiles): - for jarfile in jarfiles: - name = "jars__" + jarfile[jarfile.rindex("/") + 1:jarfile.rindex(".jar")] - lib_deps.append(":" + name) - prebuilt_jar( - name = name, - binary_jar = jarfile, - ) diff --git a/packages/react-native-editor/android/app/src/debug/AndroidManifest.xml b/packages/react-native-editor/android/app/src/debug/AndroidManifest.xml index 1970c1dc4856e4..150f7cd0cc14db 100644 --- a/packages/react-native-editor/android/app/src/debug/AndroidManifest.xml +++ b/packages/react-native-editor/android/app/src/debug/AndroidManifest.xml @@ -1,10 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools"> - <application - android:networkSecurityConfig="@xml/react_native_config" - tools:targetApi="28" - tools:ignore="GoogleAppIndexingWarning"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <application android:networkSecurityConfig="@xml/react_native_config"> <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false" /> </application> -</manifest> \ No newline at end of file +</manifest> diff --git a/packages/react-native-editor/android/app/src/main/AndroidManifest.xml b/packages/react-native-editor/android/app/src/main/AndroidManifest.xml index 3f139808aa706f..8a25e1ad47d1ff 100644 --- a/packages/react-native-editor/android/app/src/main/AndroidManifest.xml +++ b/packages/react-native-editor/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.gutenberg" - android:versionCode="1" - android:versionName="1.0"> +<!-- + IMPORTANT: Any updates to the package name should be reflected in + the `namespace` property of the `build.gradle` file. + File reference: `react-native-editor/android/app/build.gradle` +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.gutenberg"> <uses-permission android:name="android.permission.INTERNET" /> diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java index 2c31c3bdc82810..a3f7b599db6a14 100644 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java +++ b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java @@ -1,17 +1,157 @@ package com.gutenberg; import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; -import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; import com.facebook.react.ReactActivity; -import com.facebook.react.ReactActivityDelegate; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactRootView; +import org.json.JSONException; +import org.json.JSONObject; import org.wordpress.mobile.WPAndroidGlue.GutenbergProps; import java.util.Locale; public class MainActivity extends ReactActivity { + private static MainActivity currentInstance; + + private ReactRootView mReactRootView; + private Menu mMenu; + + private static final String EXTRAS_INITIAL_PROPS = "initialProps"; + + private void openReactNativeDebugMenu() { + ReactInstanceManager devSettingsModule = getReactInstanceManager(); + if (devSettingsModule != null) { + devSettingsModule.showDevOptionsDialog(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + mMenu = menu; + getMenuInflater().inflate(R.menu.toolbar_menu, menu); + + // Set opacity for menu items + MenuItem undoItem = menu.findItem(R.id.menuUndo); + undoItem.getIcon().setAlpha(76); + undoItem.setEnabled(false); + + MenuItem redoItem = menu.findItem(R.id.menuRedo); + redoItem.getIcon().setAlpha(76); + redoItem.setEnabled(false); + return true; + } + + public void updateUndoItem(boolean isDisabled) { + if (mMenu != null) { + runOnUiThread(new Runnable() { + @Override + public void run() { + MenuItem undoItem = mMenu.findItem(R.id.menuUndo); + + undoItem.setEnabled(!isDisabled); + undoItem.getIcon().setAlpha(!isDisabled ? 255 : 76); + } + }); + } + } + + public void updateRedoItem(boolean isDisabled) { + if (mMenu != null) { + runOnUiThread(new Runnable() { + @Override + public void run() { + MenuItem redoItem = mMenu.findItem(R.id.menuRedo); + + redoItem.setEnabled(!isDisabled); + redoItem.getIcon().setAlpha(!isDisabled ? 255 : 76); + } + }); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + MainApplication mainApplication = (MainApplication) getApplication(); + + int itemId = item.getItemId(); + if (itemId == R.id.menuUndo) { + mainApplication.toggleUndo(); + return true; + } + if (itemId == R.id.menuRedo) { + mainApplication.toggleRedo(); + return true; + } + if (itemId == R.id.menuButton) { + openReactNativeDebugMenu(); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static MainActivity getInstance() { + return currentInstance; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + currentInstance = this; + + // Create a LinearLayout that will hold both the toolbar and React Native content + LinearLayout linearLayout = new LinearLayout(this); + linearLayout.setOrientation(LinearLayout.VERTICAL); + linearLayout.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + // Create a Toolbar instance + Toolbar toolbar = new Toolbar(this); + + // Set toolbar properties (you can customize this as you want) + toolbar.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + // Set the toolbar as the Activity's action bar + setSupportActionBar(toolbar); + + // Add the toolbar to the linear layout + linearLayout.addView(toolbar); + + // Create a View to be used as the border + View borderView = new View(this); + borderView.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1)); + borderView.setBackgroundColor(ContextCompat.getColor(this, R.color.toolbarBorder)); + + // Add the border view to the linear layout + linearLayout.addView(borderView); + + // Create a ReactRootView and assign it to mReactRootView + mReactRootView = new ReactRootView(this); + LinearLayout.LayoutParams reactViewParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1); + mReactRootView.setLayoutParams(reactViewParams); + + // Add ReactView to the linear layout + linearLayout.addView(mReactRootView); + + // Set the linear layout as the content view + setContentView(linearLayout); + + // Load the React application + mReactRootView.startReactApplication( + ((MainApplication) getApplication()).getReactNativeHost().getReactInstanceManager(), + getMainComponentName(), + getAppOptions() + ); + } /** * Returns the name of the main component registered from JavaScript. @@ -22,35 +162,48 @@ protected String getMainComponentName() { return "gutenberg"; } - @Override - protected ReactActivityDelegate createReactActivityDelegate() { - return new ReactActivityDelegate(this, getMainComponentName()) { - @Nullable - @Override - protected Bundle getLaunchOptions() { - Bundle bundle = new Bundle(); - - // Add locale - String languageString = Locale.getDefault().toString(); - String localeSlug = languageString.replace("_", "-").toLowerCase(Locale.ENGLISH); - bundle.putString(GutenbergProps.PROP_LOCALE, localeSlug); - - // Add capabilities - Bundle capabilities = new Bundle(); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_MENTIONS, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_XPOSTS, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_UNSUPPORTED_BLOCK_EDITOR, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_REUSABLE_BLOCK, false); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_IS_AUDIO_BLOCK_MEDIA_UPLOAD_ENABLED, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_TILED_GALLERY_BLOCK, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_VIDEOPRESS_BLOCK, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_FACEBOOK_EMBED_BLOCK, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_INSTAGRAM_EMBED_BLOCK, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_LOOM_EMBED_BLOCK, true); - capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_SMARTFRAME_EMBED_BLOCK, true); - bundle.putBundle(GutenbergProps.PROP_CAPABILITIES, capabilities); - return bundle; + private Bundle getAppOptions() { + Bundle bundle = new Bundle(); + + // Parse initial props from launch arguments + String initialData = null; + Bundle extrasBundle = getIntent().getExtras(); + if(extrasBundle != null) { + String initialProps = extrasBundle.getString(EXTRAS_INITIAL_PROPS, "{}"); + try { + JSONObject jsonObject = new JSONObject(initialProps); + if (jsonObject.has(GutenbergProps.PROP_INITIAL_DATA)) { + initialData = jsonObject.getString(GutenbergProps.PROP_INITIAL_DATA); + } + } catch (final JSONException e) { + Log.e("MainActivity", "Json parsing error: " + e.getMessage()); } - }; + } + + // Add locale + String languageString = Locale.getDefault().toString(); + String localeSlug = languageString.replace("_", "-").toLowerCase(Locale.ENGLISH); + bundle.putString(GutenbergProps.PROP_LOCALE, localeSlug); + + // Add capabilities + Bundle capabilities = new Bundle(); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_MENTIONS, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_XPOSTS, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_UNSUPPORTED_BLOCK_EDITOR, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_REUSABLE_BLOCK, false); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_IS_AUDIO_BLOCK_MEDIA_UPLOAD_ENABLED, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_TILED_GALLERY_BLOCK, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_VIDEOPRESS_BLOCK, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_FACEBOOK_EMBED_BLOCK, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_INSTAGRAM_EMBED_BLOCK, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_LOOM_EMBED_BLOCK, true); + capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_SMARTFRAME_EMBED_BLOCK, true); + bundle.putBundle(GutenbergProps.PROP_CAPABILITIES, capabilities); + + if(initialData != null) { + bundle.putString(GutenbergProps.PROP_INITIAL_DATA, initialData); + } + + return bundle; } } diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java index b781357bee369a..ba01cffb2943cd 100644 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java +++ b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java @@ -1,9 +1,6 @@ package com.gutenberg; -import static org.wordpress.mobile.WPAndroidGlue.Media.createRNMediaUsingMimeType; - import android.app.Application; -import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; @@ -14,7 +11,6 @@ import com.facebook.react.ReactApplication; import com.BV.LinearGradient.LinearGradientPackage; -import com.facebook.react.ReactInstanceManager; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableNativeMap; @@ -37,6 +33,8 @@ import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; +import com.facebook.react.defaults.DefaultReactNativeHost; import com.facebook.react.shell.MainReactPackage; import com.facebook.soloader.SoLoader; import com.reactnativecommunity.webview.RNCWebViewPackage; @@ -296,9 +294,25 @@ public void requestGotoCustomerSupportOptions() { public void sendEventToHost(final String eventName, final ReadableMap properties) { Log.d("SendEventToHost", String.format("Gutenberg requested sending '%s' event to host with properties: %s", eventName, properties)); } + + @Override + public void toggleUndoButton(boolean isDisabled) { + MainActivity mainActivity = MainActivity.getInstance(); + if (mainActivity != null) { + mainActivity.updateUndoItem(isDisabled); + } + } + + @Override + public void toggleRedoButton(boolean isDisabled) { + MainActivity mainActivity = MainActivity.getInstance(); + if (mainActivity != null) { + mainActivity.updateRedoItem(isDisabled); + } + } }, isDarkMode()); - return new ReactNativeHost(this) { + return new DefaultReactNativeHost(this) { @Override public boolean getUseDeveloperSupport() { return BuildConfig.DEBUG; @@ -331,6 +345,15 @@ protected List<ReactPackage> getPackages() { protected String getJSMainModuleName() { return "index"; } + + @Override + protected boolean isNewArchEnabled() { + return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + } + @Override + protected Boolean isHermesEnabled() { + return BuildConfig.IS_HERMES_ENABLED; + } }; } @@ -341,6 +364,14 @@ private boolean isDarkMode() { return currentNightMode == Configuration.UI_MODE_NIGHT_YES; } + public void toggleUndo() { + mRnReactNativeGutenbergBridgePackage.getRNReactNativeGutenbergBridgeModule().onUndoPressed(); + } + + public void toggleRedo() { + mRnReactNativeGutenbergBridgePackage.getRNReactNativeGutenbergBridgeModule().onRedoPressed(); + } + private void openGutenbergWebView(String content, String blockId, String blockName) { @@ -366,38 +397,11 @@ public ReactNativeHost getReactNativeHost() { public void onCreate() { super.onCreate(); SoLoader.init(this, /* native exopackage */ false); - initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); - } - - /** - * Loads Flipper in React Native templates. Call this in the onCreate method with something like - * initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); - * - * @param context - * @param reactInstanceManager - */ - private static void initializeFlipper( - Context context, ReactInstanceManager reactInstanceManager) { - if (BuildConfig.DEBUG) { - try { - /* - We use reflection here to pick up the class that initializes Flipper, - since Flipper library is not available in release mode - */ - Class<?> aClass = Class.forName("com.gutenberg.ReactNativeFlipper"); - aClass - .getMethod("initializeFlipper", Context.class, ReactInstanceManager.class) - .invoke(null, context, reactInstanceManager); - } catch (ClassNotFoundException e) { - e.printStackTrace(); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + DefaultNewArchitectureEntryPoint.load(); } + ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); } private void createCustomDevOptions(ReactNativeHost reactNativeHost) { diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/ReactNativeFlipper.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/ReactNativeFlipper.java index ac2f27fdba19f5..c3992d41c615c1 100644 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/ReactNativeFlipper.java +++ b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/ReactNativeFlipper.java @@ -17,20 +17,22 @@ import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin; import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor; import com.facebook.flipper.plugins.network.NetworkFlipperPlugin; -import com.facebook.flipper.plugins.react.ReactFlipperPlugin; import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin; import com.facebook.react.ReactInstanceManager; import com.facebook.react.bridge.ReactContext; import com.facebook.react.modules.network.NetworkingModule; import okhttp3.OkHttpClient; +/** + * Class responsible of loading Flipper inside your React Native application. This is the debug + * flavor of it. Here you can add your own plugins and customize the Flipper setup. + */ public class ReactNativeFlipper { public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) { if (FlipperUtils.shouldEnableFlipper(context)) { final FlipperClient client = AndroidFlipperClient.getInstance(context); client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())); - client.addPlugin(new ReactFlipperPlugin()); client.addPlugin(new DatabasesFlipperPlugin(context)); client.addPlugin(new SharedPreferencesFlipperPlugin(context)); client.addPlugin(CrashReporterPlugin.getInstance()); diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/MainApplicationReactNativeHost.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/MainApplicationReactNativeHost.java deleted file mode 100644 index 5a3deee24f507c..00000000000000 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/MainApplicationReactNativeHost.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.gutenberg.newarchitecture; - -import android.app.Application; -import androidx.annotation.NonNull; -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.ReactNativeHost; -import com.facebook.react.ReactPackage; -import com.facebook.react.ReactPackageTurboModuleManagerDelegate; -import com.facebook.react.bridge.JSIModulePackage; -import com.facebook.react.bridge.JSIModuleProvider; -import com.facebook.react.bridge.JSIModuleSpec; -import com.facebook.react.bridge.JSIModuleType; -import com.facebook.react.bridge.JavaScriptContextHolder; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.UIManager; -import com.facebook.react.fabric.ComponentFactory; -import com.facebook.react.fabric.CoreComponentsRegistry; -import com.facebook.react.fabric.FabricJSIModuleProvider; -import com.facebook.react.fabric.ReactNativeConfig; -import com.facebook.react.uimanager.ViewManagerRegistry; -import com.gutenberg.BuildConfig; -import com.gutenberg.newarchitecture.components.MainComponentsRegistry; -import com.gutenberg.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * A {@link ReactNativeHost} that helps you load everything needed for the New Architecture, both - * TurboModule delegates and the Fabric Renderer. - * - * <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the - * `newArchEnabled` property). Is ignored otherwise. - */ -public class MainApplicationReactNativeHost extends ReactNativeHost { - public MainApplicationReactNativeHost(Application application) { - super(application); - } - - @Override - public boolean getUseDeveloperSupport() { - return BuildConfig.DEBUG; - } - - @Override - protected List<ReactPackage> getPackages() { - List<ReactPackage> packages = Arrays.asList(); - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); - // TurboModules must also be loaded here providing a valid TurboReactPackage implementation: - // packages.add(new TurboReactPackage() { ... }); - // If you have custom Fabric Components, their ViewManagers should also be loaded here - // inside a ReactPackage. - return packages; - } - - @Override - protected String getJSMainModuleName() { - return "index"; - } - - @NonNull - @Override - protected ReactPackageTurboModuleManagerDelegate.Builder - getReactPackageTurboModuleManagerDelegateBuilder() { - // Here we provide the ReactPackageTurboModuleManagerDelegate Builder. This is necessary - // for the new architecture and to use TurboModules correctly. - return new MainApplicationTurboModuleManagerDelegate.Builder(); - } - - @Override - protected JSIModulePackage getJSIModulePackage() { - return new JSIModulePackage() { - @Override - public List<JSIModuleSpec> getJSIModules( - final ReactApplicationContext reactApplicationContext, - final JavaScriptContextHolder jsContext) { - final List<JSIModuleSpec> specs = new ArrayList<>(); - - // Here we provide a new JSIModuleSpec that will be responsible of providing the - // custom Fabric Components. - specs.add( - new JSIModuleSpec() { - @Override - public JSIModuleType getJSIModuleType() { - return JSIModuleType.UIManager; - } - - @Override - public JSIModuleProvider<UIManager> getJSIModuleProvider() { - final ComponentFactory componentFactory = new ComponentFactory(); - CoreComponentsRegistry.register(componentFactory); - - // Here we register a Components Registry. - // The one that is generated with the template contains no components - // and just provides you the one from React Native core. - MainComponentsRegistry.register(componentFactory); - - final ReactInstanceManager reactInstanceManager = getReactInstanceManager(); - - ViewManagerRegistry viewManagerRegistry = - new ViewManagerRegistry( - reactInstanceManager.getOrCreateViewManagers(reactApplicationContext)); - - return new FabricJSIModuleProvider( - reactApplicationContext, - componentFactory, - ReactNativeConfig.DEFAULT_CONFIG, - viewManagerRegistry); - } - }); - return specs; - } - }; - } -} diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/components/MainComponentsRegistry.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/components/MainComponentsRegistry.java deleted file mode 100644 index 4a613ac3a2bc3f..00000000000000 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/components/MainComponentsRegistry.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.gutenberg.newarchitecture.components; - -import com.facebook.jni.HybridData; -import com.facebook.proguard.annotations.DoNotStrip; -import com.facebook.react.fabric.ComponentFactory; -import com.facebook.soloader.SoLoader; - -/** - * Class responsible to load the custom Fabric Components. This class has native methods and needs a - * corresponding C++ implementation/header file to work correctly (already placed inside the jni/ - * folder for you). - * - * <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the - * `newArchEnabled` property). Is ignored otherwise. - */ -@DoNotStrip -public class MainComponentsRegistry { - static { - SoLoader.loadLibrary("fabricjni"); - } - - @DoNotStrip private final HybridData mHybridData; - - @DoNotStrip - private native HybridData initHybrid(ComponentFactory componentFactory); - - @DoNotStrip - private MainComponentsRegistry(ComponentFactory componentFactory) { - mHybridData = initHybrid(componentFactory); - } - - @DoNotStrip - public static MainComponentsRegistry register(ComponentFactory componentFactory) { - return new MainComponentsRegistry(componentFactory); - } -} \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate.java deleted file mode 100644 index b80720e9bdd883..00000000000000 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.gutenberg.newarchitecture.modules; - -import com.facebook.jni.HybridData; -import com.facebook.react.ReactPackage; -import com.facebook.react.ReactPackageTurboModuleManagerDelegate; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.soloader.SoLoader; -import java.util.List; - -/** - * Class responsible to load the TurboModules. This class has native methods and needs a - * corresponding C++ implementation/header file to work correctly (already placed inside the jni/ - * folder for you). - * - * <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the - * `newArchEnabled` property). Is ignored otherwise. - */ -public class MainApplicationTurboModuleManagerDelegate - extends ReactPackageTurboModuleManagerDelegate { - - private static volatile boolean sIsSoLibraryLoaded; - - protected MainApplicationTurboModuleManagerDelegate( - ReactApplicationContext reactApplicationContext, List<ReactPackage> packages) { - super(reactApplicationContext, packages); - } - - protected native HybridData initHybrid(); - - native boolean canCreateTurboModule(String moduleName); - - public static class Builder extends ReactPackageTurboModuleManagerDelegate.Builder { - protected MainApplicationTurboModuleManagerDelegate build( - ReactApplicationContext context, List<ReactPackage> packages) { - return new MainApplicationTurboModuleManagerDelegate(context, packages); - } - } - - @Override - protected synchronized void maybeLoadOtherSoLibraries() { - if (!sIsSoLibraryLoaded) { - // If you change the name of your application .so file in the Android.mk file, - // make sure you update the name here as well. - SoLoader.loadLibrary("gutenberg_appmodules"); - sIsSoLibraryLoaded = true; - } - } -} \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/Android.mk b/packages/react-native-editor/android/app/src/main/jni/Android.mk deleted file mode 100644 index 633707fd916058..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/Android.mk +++ /dev/null @@ -1,48 +0,0 @@ -THIS_DIR := $(call my-dir) - -include $(REACT_ANDROID_DIR)/Android-prebuilt.mk - -# If you wish to add a custom TurboModule or Fabric component in your app you -# will have to include the following autogenerated makefile. -# include $(GENERATED_SRC_DIR)/codegen/jni/Android.mk -include $(CLEAR_VARS) - -LOCAL_PATH := $(THIS_DIR) - -# You can customize the name of your application .so file here. -LOCAL_MODULE := gutenberg_appmodules - -LOCAL_C_INCLUDES := $(LOCAL_PATH) -LOCAL_SRC_FILES := $(wildcard $(LOCAL_PATH)/*.cpp) -LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH) - -# If you wish to add a custom TurboModule or Fabric component in your app you -# will have to uncomment those lines to include the generated source -# files from the codegen (placed in $(GENERATED_SRC_DIR)/codegen/jni) -# -# LOCAL_C_INCLUDES += $(GENERATED_SRC_DIR)/codegen/jni -# LOCAL_SRC_FILES += $(wildcard $(GENERATED_SRC_DIR)/codegen/jni/*.cpp) -# LOCAL_EXPORT_C_INCLUDES += $(GENERATED_SRC_DIR)/codegen/jni - -# Here you should add any native library you wish to depend on. -LOCAL_SHARED_LIBRARIES := \ - libfabricjni \ - libfbjni \ - libfolly_runtime \ - libglog \ - libjsi \ - libreact_codegen_rncore \ - libreact_debug \ - libreact_nativemodule_core \ - libreact_render_componentregistry \ - libreact_render_core \ - libreact_render_debug \ - libreact_render_graphics \ - librrc_view \ - libruntimeexecutor \ - libturbomodulejsijni \ - libyoga - -LOCAL_CFLAGS := -DLOG_TAG=\"ReactNative\" -fexceptions -frtti -std=c++17 -Wall - -include $(BUILD_SHARED_LIBRARY) \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/MainApplicationModuleProvider.cpp b/packages/react-native-editor/android/app/src/main/jni/MainApplicationModuleProvider.cpp deleted file mode 100644 index 39de093d1136d6..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/MainApplicationModuleProvider.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include "MainApplicationModuleProvider.h" - -#include <rncore.h> - -namespace facebook { -namespace react { - -std::shared_ptr<TurboModule> MainApplicationModuleProvider( - const std::string moduleName, - const JavaTurboModule::InitParams &params) { - // Here you can provide your own module provider for TurboModules coming from - // either your application or from external libraries. The approach to follow - // is similar to the following (for a library called `samplelibrary`: - // - // auto module = samplelibrary_ModuleProvider(moduleName, params); - // if (module != nullptr) { - // return module; - // } - // return rncore_ModuleProvider(moduleName, params); - return rncore_ModuleProvider(moduleName, params); -} - -} // namespace react -} // namespace facebook \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/MainApplicationModuleProvider.h b/packages/react-native-editor/android/app/src/main/jni/MainApplicationModuleProvider.h deleted file mode 100644 index 2f2fb24a3a76c9..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/MainApplicationModuleProvider.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include <memory> -#include <string> - -#include <ReactCommon/JavaTurboModule.h> - -namespace facebook { -namespace react { - -std::shared_ptr<TurboModule> MainApplicationModuleProvider( - const std::string moduleName, - const JavaTurboModule::InitParams &params); - -} // namespace react -} // namespace facebook \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.cpp b/packages/react-native-editor/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.cpp deleted file mode 100644 index f2e4714dc93b67..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.cpp +++ /dev/null @@ -1,45 +0,0 @@ -#include "MainApplicationTurboModuleManagerDelegate.h" -#include "MainApplicationModuleProvider.h" - -namespace facebook { -namespace react { - -jni::local_ref<MainApplicationTurboModuleManagerDelegate::jhybriddata> -MainApplicationTurboModuleManagerDelegate::initHybrid( - jni::alias_ref<jhybridobject>) { - return makeCxxInstance(); -} - -void MainApplicationTurboModuleManagerDelegate::registerNatives() { - registerHybrid({ - makeNativeMethod( - "initHybrid", MainApplicationTurboModuleManagerDelegate::initHybrid), - makeNativeMethod( - "canCreateTurboModule", - MainApplicationTurboModuleManagerDelegate::canCreateTurboModule), - }); -} - -std::shared_ptr<TurboModule> -MainApplicationTurboModuleManagerDelegate::getTurboModule( - const std::string name, - const std::shared_ptr<CallInvoker> jsInvoker) { - // Not implemented yet: provide pure-C++ NativeModules here. - return nullptr; -} - -std::shared_ptr<TurboModule> -MainApplicationTurboModuleManagerDelegate::getTurboModule( - const std::string name, - const JavaTurboModule::InitParams &params) { - return MainApplicationModuleProvider(name, params); -} - -bool MainApplicationTurboModuleManagerDelegate::canCreateTurboModule( - std::string name) { - return getTurboModule(name, nullptr) != nullptr || - getTurboModule(name, {.moduleName = name}) != nullptr; -} - -} // namespace react -} // namespace facebook \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.h b/packages/react-native-editor/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.h deleted file mode 100644 index 2cd17d7a944c0c..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.h +++ /dev/null @@ -1,38 +0,0 @@ -#include <memory> -#include <string> - -#include <ReactCommon/TurboModuleManagerDelegate.h> -#include <fbjni/fbjni.h> - -namespace facebook { -namespace react { - -class MainApplicationTurboModuleManagerDelegate - : public jni::HybridClass< - MainApplicationTurboModuleManagerDelegate, - TurboModuleManagerDelegate> { - public: - // Adapt it to the package you used for your Java class. - static constexpr auto kJavaDescriptor = - "Lcom/gutenberg/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate;"; - - static jni::local_ref<jhybriddata> initHybrid(jni::alias_ref<jhybridobject>); - - static void registerNatives(); - - std::shared_ptr<TurboModule> getTurboModule( - const std::string name, - const std::shared_ptr<CallInvoker> jsInvoker) override; - std::shared_ptr<TurboModule> getTurboModule( - const std::string name, - const JavaTurboModule::InitParams &params) override; - - /** - * Test-only method. Allows user to verify whether a TurboModule can be - * created by instances of this class. - */ - bool canCreateTurboModule(std::string name); -}; - -} // namespace react -} // namespace facebook \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/MainComponentsRegistry.cpp b/packages/react-native-editor/android/app/src/main/jni/MainComponentsRegistry.cpp deleted file mode 100644 index c5188f4dc7b12c..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/MainComponentsRegistry.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include "MainComponentsRegistry.h" - -#include <CoreComponentsRegistry.h> -#include <fbjni/fbjni.h> -#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> -#include <react/renderer/components/rncore/ComponentDescriptors.h> - -namespace facebook { -namespace react { - -MainComponentsRegistry::MainComponentsRegistry(ComponentFactory *delegate) {} - -std::shared_ptr<ComponentDescriptorProviderRegistry const> -MainComponentsRegistry::sharedProviderRegistry() { - auto providerRegistry = CoreComponentsRegistry::sharedProviderRegistry(); - - // Custom Fabric Components go here. You can register custom - // components coming from your App or from 3rd party libraries here. - // - // providerRegistry->add(concreteComponentDescriptorProvider< - // AocViewerComponentDescriptor>()); - return providerRegistry; -} - -jni::local_ref<MainComponentsRegistry::jhybriddata> -MainComponentsRegistry::initHybrid( - jni::alias_ref<jclass>, - ComponentFactory *delegate) { - auto instance = makeCxxInstance(delegate); - - auto buildRegistryFunction = - [](EventDispatcher::Weak const &eventDispatcher, - ContextContainer::Shared const &contextContainer) - -> ComponentDescriptorRegistry::Shared { - auto registry = MainComponentsRegistry::sharedProviderRegistry() - ->createComponentDescriptorRegistry( - {eventDispatcher, contextContainer}); - - auto mutableRegistry = - std::const_pointer_cast<ComponentDescriptorRegistry>(registry); - - mutableRegistry->setFallbackComponentDescriptor( - std::make_shared<UnimplementedNativeViewComponentDescriptor>( - ComponentDescriptorParameters{ - eventDispatcher, contextContainer, nullptr})); - - return registry; - }; - - delegate->buildRegistryFunction = buildRegistryFunction; - return instance; -} - -void MainComponentsRegistry::registerNatives() { - registerHybrid({ - makeNativeMethod("initHybrid", MainComponentsRegistry::initHybrid), - }); -} - -} // namespace react -} // namespace facebook \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/MainComponentsRegistry.h b/packages/react-native-editor/android/app/src/main/jni/MainComponentsRegistry.h deleted file mode 100644 index 1046cfa3f2f738..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/MainComponentsRegistry.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include <ComponentFactory.h> -#include <fbjni/fbjni.h> -#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> -#include <react/renderer/componentregistry/ComponentDescriptorRegistry.h> - -namespace facebook { -namespace react { - -class MainComponentsRegistry - : public facebook::jni::HybridClass<MainComponentsRegistry> { - public: - // Adapt it to the package you used for your Java class. - constexpr static auto kJavaDescriptor = - "Lcom/gutenberg/newarchitecture/components/MainComponentsRegistry;"; - - static void registerNatives(); - - MainComponentsRegistry(ComponentFactory *delegate); - - private: - static std::shared_ptr<ComponentDescriptorProviderRegistry const> - sharedProviderRegistry(); - - static jni::local_ref<jhybriddata> initHybrid( - jni::alias_ref<jclass>, - ComponentFactory *delegate); -}; - -} // namespace react -} // namespace facebook \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/jni/OnLoad.cpp b/packages/react-native-editor/android/app/src/main/jni/OnLoad.cpp deleted file mode 100644 index ae1ef007d15244..00000000000000 --- a/packages/react-native-editor/android/app/src/main/jni/OnLoad.cpp +++ /dev/null @@ -1,11 +0,0 @@ -#include <fbjni/fbjni.h> -#include "MainApplicationTurboModuleManagerDelegate.h" -#include "MainComponentsRegistry.h" - -JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { - return facebook::jni::initialize(vm, [] { - facebook::react::MainApplicationTurboModuleManagerDelegate:: - registerNatives(); - facebook::react::MainComponentsRegistry::registerNatives(); - }); -} \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/main/res/drawable/more_vertical.xml b/packages/react-native-editor/android/app/src/main/res/drawable/more_vertical.xml new file mode 100644 index 00000000000000..0a839eda9c3aff --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/drawable/more_vertical.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M13,19H11V17H13V19ZM13,13H11V11H13V13ZM13,7H11V5H13V7Z" + android:fillColor="?attr/colorSecondary"/> +</vector> diff --git a/packages/react-native-editor/android/app/src/main/res/drawable/redo.xml b/packages/react-native-editor/android/app/src/main/res/drawable/redo.xml new file mode 100644 index 00000000000000..062f14eb685268 --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/drawable/redo.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M15.6,6.5L14.5,7.5L17.4,10.8H8C7.1,10.8 6.3,11.1 5.7,11.7C4.3,13.2 4.3,15.9 4.3,17.3V17.5H5.8V17.2C5.8,16.1 5.8,13.7 6.8,12.7C7.1,12.4 7.5,12.2 8.1,12.2H17.3L14.5,15L15.6,16.1L20.2,11.5L15.6,6.5Z" + android:fillColor="?attr/colorSecondary"/> +</vector> diff --git a/packages/react-native-editor/android/app/src/main/res/drawable/undo.xml b/packages/react-native-editor/android/app/src/main/res/drawable/undo.xml new file mode 100644 index 00000000000000..773dbf271cc886 --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/drawable/undo.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M18.3,11.7C17.7,11.1 16.9,10.8 16,10.8H6.7L9.6,7.5L8.5,6.5L4,11.5L8.5,16L9.5,15L6.8,12.3H16C16.5,12.3 16.9,12.5 17.3,12.8C18.3,13.8 18.3,16.2 18.3,17.3V17.6H19.8V17.4C19.8,15.9 19.8,13.1 18.3,11.7Z" + android:fillColor="?attr/colorSecondary"/> +</vector> diff --git a/packages/react-native-editor/android/app/src/main/res/menu/toolbar_menu.xml b/packages/react-native-editor/android/app/src/main/res/menu/toolbar_menu.xml new file mode 100644 index 00000000000000..6cc6c5213392d9 --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/menu/toolbar_menu.xml @@ -0,0 +1,21 @@ +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <item + android:id="@+id/menuUndo" + android:icon="@drawable/undo" + android:title="@string/undo" + app:showAsAction="ifRoom" /> + + <item + android:id="@+id/menuRedo" + android:icon="@drawable/redo" + android:title="@string/redo" + app:showAsAction="ifRoom" /> + + <item + android:id="@+id/menuButton" + android:icon="@drawable/more_vertical" + android:title="@string/more_options" + app:showAsAction="ifRoom" /> +</menu> diff --git a/packages/react-native-editor/android/app/src/main/res/values-night/colors_dark.xml b/packages/react-native-editor/android/app/src/main/res/values-night/colors_dark.xml new file mode 100644 index 00000000000000..7ae0b9e788f00c --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/values-night/colors_dark.xml @@ -0,0 +1,6 @@ +<resources> + <color name="colorPrimary">#000000</color> + <color name="primary_dark">#000000</color> + <color name="colorPrimaryVariant">#FFFFFF</color> + <color name="toolbarBorder">#60FFFFFF</color> +</resources> diff --git a/packages/react-native-editor/android/app/src/main/res/values-night/styles.xml b/packages/react-native-editor/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000000000..b8af6a19007138 --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,20 @@ +<resources> + + <!-- Base application theme. --> + <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar"> + <!-- Customize your theme here. --> + <item name="colorPrimary">@color/colorPrimary</item> + <item name="colorPrimaryDark">@color/primary_dark</item> + <item name="colorOnPrimary">@android:color/white</item> + <item name="colorSecondary">@android:color/white</item> + <item name="colorOnSecondary">@android:color/white</item> + <item name="colorOnBackground">@android:color/black</item> + <item name="colorOnSurface">@color/colorPrimary</item> + + <item name="toolbarStyle">@style/Gutenberg.ToolBar</item> + + <item name="android:editTextBackground">@drawable/rn_edit_text_material</item> + <item name="android:windowLightStatusBar">false</item> + </style> + +</resources> diff --git a/packages/react-native-editor/android/app/src/main/res/values/colors.xml b/packages/react-native-editor/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000000000..0f702bc1ea645b --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ +<resources> + <color name="colorPrimary">#FFFFFF</color> + <color name="primary_dark">#FFFFFF</color> + <color name="colorPrimaryVariant">#000000</color> + <color name="toolbarBorder">#3C3C435C</color> +</resources> diff --git a/packages/react-native-editor/android/app/src/main/res/values/strings.xml b/packages/react-native-editor/android/app/src/main/res/values/strings.xml index 29e2e4d13944c2..df039af86cc8ce 100644 --- a/packages/react-native-editor/android/app/src/main/res/values/strings.xml +++ b/packages/react-native-editor/android/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ <resources> <string name="app_name">Gutenberg</string> + <string name="undo">Undo</string> + <string name="redo">Redo</string> + <string name="more_options">More options</string> </resources> diff --git a/packages/react-native-editor/android/app/src/main/res/values/styles.xml b/packages/react-native-editor/android/app/src/main/res/values/styles.xml index 7ba83a2ad5a2c9..1423d4e65c673d 100644 --- a/packages/react-native-editor/android/app/src/main/res/values/styles.xml +++ b/packages/react-native-editor/android/app/src/main/res/values/styles.xml @@ -1,9 +1,19 @@ <resources> <!-- Base application theme. --> - <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> + <style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar"> <!-- Customize your theme here. --> + <item name="colorPrimary">@color/colorPrimary</item> + <item name="colorPrimaryDark">@color/primary_dark</item> + <item name="colorOnPrimary">@android:color/white</item> + <item name="colorSecondary">@android:color/black</item> + <item name="colorOnSecondary">@android:color/white</item> + <item name="colorOnBackground">@android:color/black</item> + <item name="colorOnSurface">@color/colorPrimary</item> + + <item name="toolbarStyle">@style/Gutenberg.ToolBar</item> + <item name="android:editTextBackground">@drawable/rn_edit_text_material</item> + <item name="android:windowLightStatusBar">true</item> </style> - </resources> diff --git a/packages/react-native-editor/android/app/src/main/res/values/styles_toolbar.xml b/packages/react-native-editor/android/app/src/main/res/values/styles_toolbar.xml new file mode 100644 index 00000000000000..dddd75b88b150d --- /dev/null +++ b/packages/react-native-editor/android/app/src/main/res/values/styles_toolbar.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:tools="http://schemas.android.com/tools"> + <style name="Gutenberg.ToolBar" parent="Widget.MaterialComponents.Toolbar.Surface"> + <item name="titleTextColor">?attr/colorOnSurface</item> + <item name="android:elevation">0dp</item> + <item name="colorControlNormal">?attr/colorOnSurface</item> + <item name="android:background">?attr/colorOnSurface</item> + </style> + +</resources> \ No newline at end of file diff --git a/packages/react-native-editor/android/app/src/release/java/com/gutenberg/ReactNativeFlipper.java b/packages/react-native-editor/android/app/src/release/java/com/gutenberg/ReactNativeFlipper.java new file mode 100644 index 00000000000000..4f93cbce69c158 --- /dev/null +++ b/packages/react-native-editor/android/app/src/release/java/com/gutenberg/ReactNativeFlipper.java @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * <p>This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.rndiffapp; +import android.content.Context; +import com.facebook.react.ReactInstanceManager; +/** + * Class responsible of loading Flipper inside your React Native application. This is the release + * flavor of it so it's empty as we don't want to load Flipper. + */ +public class ReactNativeFlipper { + public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) { + // Do nothing as we don't want to initialize Flipper on Release. + } +} diff --git a/packages/react-native-editor/android/build.gradle b/packages/react-native-editor/android/build.gradle index 4db14e2cd92918..4746f9f3077bee 100644 --- a/packages/react-native-editor/android/build.gradle +++ b/packages/react-native-editor/android/build.gradle @@ -1,23 +1,15 @@ -import org.apache.tools.ant.taskdefs.condition.Os - buildscript { ext { - gradlePluginVersion = '7.2.1' - kotlinVersion = '1.5.32' - buildToolsVersion = "31.0.0" + gradlePluginVersion = '8.1.0' + kotlinVersion = '1.6.10' + buildToolsVersion = "33.0.0" minSdkVersion = 24 - compileSdkVersion = 31 - targetSdkVersion = 31 + compileSdkVersion = 33 + targetSdkVersion = 33 supportLibVersion = '28.0.0' - gradleDownloadTask = '5.0.1' - if (System.properties['os.arch'] == "aarch64") { - // For M1 Users we need to use the NDK 24 which added support for aarch64 - ndkVersion = "24.0.8215888" - } else { - // Otherwise we default to the side-by-side NDK version from AGP. - ndkVersion = "21.4.7075529" - } + // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. + ndkVersion = "23.1.7779620" } repositories { google() @@ -26,21 +18,13 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:$gradlePluginVersion" classpath("com.facebook.react:react-native-gradle-plugin") - classpath("de.undercouch:gradle-download-task:$gradleDownloadTask") classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } allprojects { repositories { - mavenCentral { - // We don't want to fetch react-native from Maven Central as there are - // older versions over there. - content { - excludeGroup "com.facebook.react" - } - } - mavenLocal() + mavenCentral() maven { url "https://a8c-libs.s3.amazonaws.com/android" content { @@ -50,8 +34,8 @@ allprojects { includeGroupByRegex "org.wordpress-mobile.react-native-libraries.*" } } - maven { url "https://a8c-libs.s3.amazonaws.com/android/react-native-mirror" } maven { url 'https://www.jitpack.io' } google() + mavenLocal() } } diff --git a/packages/react-native-editor/android/gradle.properties b/packages/react-native-editor/android/gradle.properties index 76f3ab8b275472..0705a21e44f055 100644 --- a/packages/react-native-editor/android/gradle.properties +++ b/packages/react-native-editor/android/gradle.properties @@ -24,7 +24,6 @@ org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=false -android.useDeprecatedNdk=true FLIPPER_VERSION=0.177.0 # Use this property to specify which architecture you want to build. diff --git a/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.jar b/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.jar index 41d9927a4d4fb3..033e24c4cdf41a 100644 Binary files a/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.jar and b/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.properties b/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.properties index 92f06b50fd65b4..c747538fb38b53 100644 --- a/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/react-native-editor/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/packages/react-native-editor/android/gradlew b/packages/react-native-editor/android/gradlew index 1b6c787337ffb7..fcb6fca147c0cd 100755 --- a/packages/react-native-editor/android/gradlew +++ b/packages/react-native-editor/android/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +130,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +197,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in @@ -205,6 +213,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/packages/react-native-editor/android/gradlew.bat b/packages/react-native-editor/android/gradlew.bat index ac1b06f93825db..6689b85beecde6 100644 --- a/packages/react-native-editor/android/gradlew.bat +++ b/packages/react-native-editor/android/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/packages/react-native-editor/android/settings.gradle b/packages/react-native-editor/android/settings.gradle index fea32ba7734e79..1afaaf6df0be3a 100644 --- a/packages/react-native-editor/android/settings.gradle +++ b/packages/react-native-editor/android/settings.gradle @@ -3,10 +3,4 @@ rootProject.name = 'gutenberg' includeBuild("../../react-native-bridge/android") include ':app' -includeBuild('../../../node_modules/react-native-gradle-plugin') -if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") { - include(":ReactAndroid") - project(":ReactAndroid").projectDir = file('../../../node_modules/react-native/ReactAndroid') - include(":ReactAndroid:hermes-engine") - project(":ReactAndroid:hermes-engine").projectDir = file('../../../node_modules/react-native/ReactAndroid/hermes-engine') -} \ No newline at end of file +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/packages/react-native-editor/bin/i18n-translations-download.js b/packages/react-native-editor/bin/i18n-translations-download.js index 714c7c0d99f8ca..24911a3ed64b3a 100644 --- a/packages/react-native-editor/bin/i18n-translations-download.js +++ b/packages/react-native-editor/bin/i18n-translations-download.js @@ -60,23 +60,54 @@ const supportedLocales = [ 'zh-tw', // Chinese (Taiwan) ]; +const MAX_RETRIES = 5; +const RETRY_DELAY = 2000; + const getLanguageUrl = ( locale, projectSlug ) => - `https://translate.wordpress.org/projects/${ projectSlug }/dev/${ locale }/default/export-translations\?format\=json`; + `https://translate.wordpress.org/projects/${ projectSlug }/dev/${ locale }/default/export-translations/\?format\=json`; const getTranslationFilePath = ( locale ) => `./data/${ locale }.json`; const fetchTranslation = ( locale, projectSlug ) => { + let retryCount = MAX_RETRIES; const localeUrl = getLanguageUrl( locale, projectSlug ); - return fetch( localeUrl ) - .then( ( response ) => response.json() ) - .then( ( body ) => { - return { response: body, locale }; - } ) - .catch( () => { - console.error( - `Could not find translation file ${ localeUrl } for project slug ${ projectSlug }` - ); - } ); + const request = () => + fetch( localeUrl ) + .then( ( response ) => { + if ( ! response.ok ) { + const { status, statusText } = response; + + // Retry when encountering "429 - Too Many Requests" error + if ( status === 429 && retryCount > 0 ) { + console.log( + `Translation file ${ localeUrl } for project slug ${ projectSlug } failed with error 429 - Too Many Requests, retrying (${ retryCount })...` + ); + retryCount--; + return new Promise( ( resolve ) => + setTimeout( + () => request().then( resolve ), + RETRY_DELAY + ) + ); + } + + console.error( + `Could not find translation file ${ localeUrl } for project slug ${ projectSlug }`, + { status, statusText } + ); + return { locale, status, statusText }; + } + return response.json(); + } ) + .then( ( body ) => { + return { response: body, locale }; + } ) + .catch( () => { + console.error( + `Could not find translation file ${ localeUrl } for project slug ${ projectSlug }` + ); + } ); + return request(); }; const fetchTranslations = ( { @@ -104,7 +135,16 @@ const fetchTranslations = ( { let extraTranslations = []; return Promise.all( fetchPromises ).then( ( results ) => { - const fetchedTranslations = results.filter( Boolean ); + const fetchedTranslations = results.filter( + ( result ) => result.response + ); + + // Abort process if any translation can't be fetched + if ( fetchedTranslations.length !== supportedLocales.length ) { + process.exit( 1 ); + return; + } + const translationFilePromises = fetchedTranslations.map( ( languageResult ) => { return new Promise( ( resolve, reject ) => { diff --git a/packages/react-native-editor/ios/.xcode.env b/packages/react-native-editor/ios/.xcode.env new file mode 100644 index 00000000000000..772b339b4c8e49 --- /dev/null +++ b/packages/react-native-editor/ios/.xcode.env @@ -0,0 +1 @@ +export NODE_BINARY=$(command -v node) diff --git a/packages/react-native-editor/ios/Colors.xcassets/Contents.json b/packages/react-native-editor/ios/Colors.xcassets/Contents.json new file mode 100644 index 00000000000000..d458f1c5928f7d --- /dev/null +++ b/packages/react-native-editor/ios/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/Colors.xcassets/HeaderLine.colorset/Contents.json b/packages/react-native-editor/ios/Colors.xcassets/HeaderLine.colorset/Contents.json new file mode 100644 index 00000000000000..5524b9950ee4c6 --- /dev/null +++ b/packages/react-native-editor/ios/Colors.xcassets/HeaderLine.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "0.360", + "blue": "0.025", + "green": "0.025", + "red": "0.025" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "0.360", + "blue": "0.025", + "green": "0.025", + "red": "0.025" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/Colors.xcassets/Primary.colorset/Contents.json b/packages/react-native-editor/ios/Colors.xcassets/Primary.colorset/Contents.json new file mode 100644 index 00000000000000..7437866da3cfe0 --- /dev/null +++ b/packages/react-native-editor/ios/Colors.xcassets/Primary.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.000", + "green": "0.000", + "red": "0.000" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "platform": "ios", + "reference": "labelColor" + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/Gemfile b/packages/react-native-editor/ios/Gemfile index 5b9407e20cb78c..f62500f50e966f 100644 --- a/packages/react-native-editor/ios/Gemfile +++ b/packages/react-native-editor/ios/Gemfile @@ -1,3 +1,3 @@ source 'https://rubygems.org' -gem 'cocoapods', '~> 1.11', '>= 1.11.2' \ No newline at end of file +gem 'cocoapods', '>= 1.11.3' \ No newline at end of file diff --git a/packages/react-native-editor/ios/Gemfile.lock b/packages/react-native-editor/ios/Gemfile.lock index d234b6cf2dd248..6b22b6ab00c668 100644 --- a/packages/react-native-editor/ios/Gemfile.lock +++ b/packages/react-native-editor/ios/Gemfile.lock @@ -93,7 +93,7 @@ PLATFORMS ruby DEPENDENCIES - cocoapods (~> 1.11, >= 1.11.2) + cocoapods (>= 1.11.3) BUNDLED WITH 2.3.18 diff --git a/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj b/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj index 79a27e1e618468..13bcf886bb1c84 100644 --- a/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj +++ b/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 1E4F2E752459E6F200EB73E7 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E85944D2449D85A006CC6A0 /* WebViewController.swift */; }; 1EFFAB71253EF6580062051E /* DocumentsMediaSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFFAB70253EF6580062051E /* DocumentsMediaSource.swift */; }; 2F634FA02731D5ED00310CC3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2F634F9F2731D5ED00310CC3 /* LaunchScreen.storyboard */; }; + 561700D72A4C91E700E7CF18 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 561700D62A4C91E700E7CF18 /* Colors.xcassets */; }; 6EBC6CA237E4D4B00D5AC79F /* Pods_GutenbergDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB79EC55FB340834C8D3BAB6 /* Pods_GutenbergDemo.framework */; }; 7EC7328F21907E3F00FED2E6 /* GutenbergViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EC7328E21907E3F00FED2E6 /* GutenbergViewController.swift */; }; E8649334C74E9AB9AF90B020 /* Pods_GutenbergDemo_GutenbergDemoTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6C4D4EB8326260FEFE8844CC /* Pods_GutenbergDemo_GutenbergDemoTests.framework */; }; @@ -49,6 +50,7 @@ 1EFFAB70253EF6580062051E /* DocumentsMediaSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsMediaSource.swift; sourceTree = "<group>"; }; 2F634F9F2731D5ED00310CC3 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; }; 4EC14D7D4E04A462CB8AC230 /* Pods-GutenbergDemo-GutenbergDemoTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GutenbergDemo-GutenbergDemoTests.release.xcconfig"; path = "Target Support Files/Pods-GutenbergDemo-GutenbergDemoTests/Pods-GutenbergDemo-GutenbergDemoTests.release.xcconfig"; sourceTree = "<group>"; }; + 561700D62A4C91E700E7CF18 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = "<group>"; }; 66F6B74F51BD6921D3AF25F6 /* Pods-GutenbergDemoTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GutenbergDemoTests.debug.xcconfig"; path = "Target Support Files/Pods-GutenbergDemoTests/Pods-GutenbergDemoTests.debug.xcconfig"; sourceTree = "<group>"; }; 6C4D4EB8326260FEFE8844CC /* Pods_GutenbergDemo_GutenbergDemoTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GutenbergDemo_GutenbergDemoTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 71AC74DFA49CB3BF62D440DB /* Pods-GutenbergDemoTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GutenbergDemoTests.release.xcconfig"; path = "Target Support Files/Pods-GutenbergDemoTests/Pods-GutenbergDemoTests.release.xcconfig"; sourceTree = "<group>"; }; @@ -124,6 +126,7 @@ FF6836C722035EAB00A0C562 /* MediaUploadCoordinator.swift */, F151983A2100DC3D000F6E97 /* MediaProvider.swift */, 13B07FB51A68108700A75B9A /* Images.xcassets */, + 561700D62A4C91E700E7CF18 /* Colors.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, FF83DAA82226905A00A34C93 /* CustomImageLoader.swift */, 2F634F9F2731D5ED00310CC3 /* LaunchScreen.storyboard */, @@ -302,6 +305,7 @@ F1EE6F7A21E7F0A500241744 /* NotoSerif-Italic.ttf in Resources */, 2F634FA02731D5ED00310CC3 /* LaunchScreen.storyboard in Resources */, F1EE6F7821E7F0A500241744 /* NotoSerif-BoldItalic.ttf in Resources */, + 561700D72A4C91E700E7CF18 /* Colors.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -346,6 +350,7 @@ "${BUILT_PRODUCTS_DIR}/React-Core/React.framework", "${BUILT_PRODUCTS_DIR}/React-CoreModules/CoreModules.framework", "${BUILT_PRODUCTS_DIR}/React-RCTAnimation/RCTAnimation.framework", + "${BUILT_PRODUCTS_DIR}/React-RCTAppDelegate/React_RCTAppDelegate.framework", "${BUILT_PRODUCTS_DIR}/React-RCTBlob/RCTBlob.framework", "${BUILT_PRODUCTS_DIR}/React-RCTImage/RCTImage.framework", "${BUILT_PRODUCTS_DIR}/React-RCTLinking/RCTLinking.framework", @@ -353,8 +358,8 @@ "${BUILT_PRODUCTS_DIR}/React-RCTSettings/RCTSettings.framework", "${BUILT_PRODUCTS_DIR}/React-RCTText/RCTText.framework", "${BUILT_PRODUCTS_DIR}/React-RCTVibration/RCTVibration.framework", - "${BUILT_PRODUCTS_DIR}/React-bridging/react_bridging.framework", "${BUILT_PRODUCTS_DIR}/React-cxxreact/cxxreact.framework", + "${BUILT_PRODUCTS_DIR}/React-jsc/React_jsc.framework", "${BUILT_PRODUCTS_DIR}/React-jsi/jsi.framework", "${BUILT_PRODUCTS_DIR}/React-jsiexecutor/jsireact.framework", "${BUILT_PRODUCTS_DIR}/React-jsinspector/jsinspector.framework", @@ -394,6 +399,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CoreModules.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTAnimation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React_RCTAppDelegate.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTBlob.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTLinking.framework", @@ -401,8 +407,8 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTSettings.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTText.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTVibration.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_bridging.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/cxxreact.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React_jsc.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/jsi.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/jsireact.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/jsinspector.framework", @@ -474,6 +480,7 @@ "${BUILT_PRODUCTS_DIR}/React-Core/React.framework", "${BUILT_PRODUCTS_DIR}/React-CoreModules/CoreModules.framework", "${BUILT_PRODUCTS_DIR}/React-RCTAnimation/RCTAnimation.framework", + "${BUILT_PRODUCTS_DIR}/React-RCTAppDelegate/React_RCTAppDelegate.framework", "${BUILT_PRODUCTS_DIR}/React-RCTBlob/RCTBlob.framework", "${BUILT_PRODUCTS_DIR}/React-RCTImage/RCTImage.framework", "${BUILT_PRODUCTS_DIR}/React-RCTLinking/RCTLinking.framework", @@ -481,8 +488,8 @@ "${BUILT_PRODUCTS_DIR}/React-RCTSettings/RCTSettings.framework", "${BUILT_PRODUCTS_DIR}/React-RCTText/RCTText.framework", "${BUILT_PRODUCTS_DIR}/React-RCTVibration/RCTVibration.framework", - "${BUILT_PRODUCTS_DIR}/React-bridging/react_bridging.framework", "${BUILT_PRODUCTS_DIR}/React-cxxreact/cxxreact.framework", + "${BUILT_PRODUCTS_DIR}/React-jsc/React_jsc.framework", "${BUILT_PRODUCTS_DIR}/React-jsi/jsi.framework", "${BUILT_PRODUCTS_DIR}/React-jsiexecutor/jsireact.framework", "${BUILT_PRODUCTS_DIR}/React-jsinspector/jsinspector.framework", @@ -522,6 +529,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CoreModules.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTAnimation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React_RCTAppDelegate.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTBlob.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTLinking.framework", @@ -529,8 +537,8 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTSettings.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTText.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RCTVibration.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_bridging.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/cxxreact.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React_jsc.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/jsi.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/jsireact.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/jsinspector.framework", @@ -635,6 +643,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", "\"$(SRCROOT)/$(TARGET_NAME)\"", @@ -669,6 +678,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", "\"$(SRCROOT)/$(TARGET_NAME)\"", @@ -710,7 +720,8 @@ INFOPLIST_FILE = GutenbergDemo/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)"; + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(PROJECT_DIR)"; + MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -744,7 +755,8 @@ INFOPLIST_FILE = GutenbergDemo/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)"; + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(PROJECT_DIR)"; + MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -764,7 +776,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LANGUAGE_STANDARD = "c++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -798,7 +810,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; }; @@ -808,7 +820,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LANGUAGE_STANDARD = "c++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -835,7 +847,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; diff --git a/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift b/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift index 8b86369126fa52..5983fed0065d95 100644 --- a/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift +++ b/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift @@ -39,6 +39,61 @@ class GutenbergViewController: UIViewController { @objc func saveButtonPressed(sender: UIBarButtonItem) { gutenberg.requestHTML() } + + lazy var undoButton: UIButton = { + let isRTL = UIView.userInterfaceLayoutDirection(for: .unspecified) == .rightToLeft + let undoImage = UIImage(named: "undo") + let button = UIButton(type: .system) + button.setImage(isRTL ? undoImage?.withHorizontallyFlippedOrientation() : undoImage, for: .normal) + button.accessibilityIdentifier = "editor-undo-button" + button.accessibilityLabel = "Undo" + button.accessibilityHint = "Double tap to undo last change" + button.addTarget(self, action: #selector(undoButtonPressed(sender:)), for: .touchUpInside) + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 5, bottom: 0, right: 5) + button.sizeToFit() + button.alpha = 0.3 + button.isUserInteractionEnabled = false + return button + }() + + lazy var redoButton: UIButton = { + let isRTL = UIView.userInterfaceLayoutDirection(for: .unspecified) == .rightToLeft + let redoImage = UIImage(named: "redo") + let button = UIButton(type: .system) + button.setImage(isRTL ? redoImage?.withHorizontallyFlippedOrientation() : redoImage, for: .normal) + button.accessibilityIdentifier = "editor-redo-button" + button.accessibilityLabel = "Redo" + button.accessibilityHint = "Double tap to redo last change" + button.addTarget(self, action: #selector(redoButtonPressed(sender:)), for: .touchUpInside) + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 5, bottom: 0, right: 5) + button.sizeToFit() + button.alpha = 0.3 + button.isUserInteractionEnabled = false + return button + }() + + lazy var moreButton: UIButton = { + let moreImage = UIImage(named: "more") + let button = UIButton(type: .system) + button.setImage(moreImage, for: .normal) + button.titleLabel?.minimumScaleFactor = 0.5 + button.accessibilityIdentifier = "editor-menu-button" + button.accessibilityLabel = "More options" + button.accessibilityHint = "Double tap to see options" + button.addTarget(self, action: #selector(moreButtonPressed(sender:)), for: .touchUpInside) + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 5, bottom: 0, right: 5) + button.sizeToFit() + return button + }() + + + @objc func undoButtonPressed(sender: UIBarButtonItem) { + self.onUndoPressed() + } + + @objc func redoButtonPressed(sender: UIBarButtonItem) { + self.onRedoPressed() + } func registerLongPressGestureRecognizer() { longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) @@ -272,6 +327,24 @@ extension GutenbergViewController: GutenbergBridgeDelegate { func gutenbergDidRequestSendEventToHost(_ eventName: String, properties: [AnyHashable: Any]) -> Void { print("Gutenberg requested sending '\(eventName)' event to host with propreties: \(properties).") } + + func gutenbergDidRequestToggleUndoButton(_ isDisabled: Bool) -> Void { + DispatchQueue.main.async { + UIView.animate(withDuration: 0.2) { + self.undoButton.isUserInteractionEnabled = isDisabled ? false : true + self.undoButton.alpha = isDisabled ? 0.3 : 1.0 + } + } + } + + func gutenbergDidRequestToggleRedoButton(_ isDisabled: Bool) -> Void { + DispatchQueue.main.async { + UIView.animate(withDuration: 0.2) { + self.redoButton.isUserInteractionEnabled = isDisabled ? false : true + self.redoButton.alpha = isDisabled ? 0.3 : 1.0 + } + } + } } extension GutenbergViewController: GutenbergWebDelegate { @@ -304,7 +377,10 @@ extension GutenbergViewController: GutenbergBridgeDataSource { } func gutenbergInitialContent() -> String? { - return nil + guard isUITesting(), let initialProps = getInitialPropsFromArgs() else { + return nil + } + return initialProps["initialData"] } func gutenbergInitialTitle() -> String? { @@ -349,6 +425,29 @@ extension GutenbergViewController: GutenbergBridgeDataSource { func gutenbergMediaSources() -> [Gutenberg.MediaSource] { return [.filesApp, .otherApps] } + + private func isUITesting() -> Bool { + guard ProcessInfo.processInfo.arguments.count >= 2 else { + return false + } + return ProcessInfo.processInfo.arguments[1] == "uitesting" + } + + private func getInitialPropsFromArgs() -> [String:String]? { + guard ProcessInfo.processInfo.arguments.count >= 3 else { + return nil + } + let initialProps = ProcessInfo.processInfo.arguments[2] + + if let data = initialProps.data(using: .utf8) { + do { + return try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] + } catch { + print(error.localizedDescription) + } + } + return nil + } } extension Gutenberg.MediaSource { @@ -362,7 +461,22 @@ extension GutenbergViewController { func configureNavigationBar() { addSaveButton() - addMoreButton() + addRightButtons() + + navigationController?.navigationBar.tintColor = UIColor(named: "Primary") + + // Add a bottom border to the navigation bar + let borderBottom = UIView() + borderBottom.backgroundColor = UIColor(named: "HeaderLine") + borderBottom.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + borderBottom.frame = CGRect( + x: 0, + y: navigationController?.navigationBar.frame.height ?? 0 - (1.0 / UIScreen.main.scale), + width: navigationController?.navigationBar.frame.width ?? 0, + height: 1.0 / UIScreen.main.scale + ) + + navigationController?.navigationBar.addSubview(borderBottom) } func addSaveButton() { @@ -371,11 +485,12 @@ extension GutenbergViewController { action: #selector(saveButtonPressed(sender:))) } - func addMoreButton() { - navigationItem.rightBarButtonItem = UIBarButtonItem(title: "...", - style: .plain, - target: self, - action: #selector(moreButtonPressed(sender:))) + func addRightButtons() { + let undoButton = UIBarButtonItem(customView: self.undoButton) + let redoButton = UIBarButtonItem(customView: self.redoButton) + let moreButton = UIBarButtonItem(customView: self.moreButton) + + navigationItem.rightBarButtonItems = [moreButton, redoButton, undoButton] } } @@ -459,6 +574,10 @@ extension GutenbergViewController { } func toggleHTMLMode(_ action: UIAlertAction) { + if !htmlMode { + self.gutenbergDidRequestToggleUndoButton(true) + self.gutenbergDidRequestToggleRedoButton(true) + } htmlMode = !htmlMode gutenberg.toggleHTMLMode() } @@ -466,4 +585,12 @@ extension GutenbergViewController { func showEditorHelp() { gutenberg.showEditorHelp() } + + func onUndoPressed() { + gutenberg.onUndoPressed() + } + + func onRedoPressed() { + gutenberg.onRedoPressed() + } } diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/120 1.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/120 1.png new file mode 100644 index 00000000000000..c982afc10cfa18 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/120 1.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/120.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000000000..c982afc10cfa18 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/120.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/167.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 00000000000000..aba493ba7e459b Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/167.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/180.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000000000..bd9f312169115f Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/180.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/80.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000000000..a20992fdb73eec Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/80.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/Contents.json b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/Contents.json index 9138a0eee5c1d1..6a88a653cce095 100644 --- a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/AppIcon.appiconset/Contents.json @@ -2,52 +2,82 @@ "images": [ { "idiom": "iphone", - "size": "20x20", - "scale": "2x" + "scale": "2x", + "size": "20x20" }, { "idiom": "iphone", - "size": "20x20", - "scale": "3x" + "scale": "3x", + "size": "20x20" }, { "idiom": "iphone", - "size": "29x29", - "scale": "2x" + "scale": "2x", + "size": "29x29" }, { "idiom": "iphone", - "size": "29x29", - "scale": "3x" + "scale": "3x", + "size": "29x29" }, { + "filename": "80.png", "idiom": "iphone", - "size": "40x40", - "scale": "2x" + "scale": "2x", + "size": "40x40" }, { + "filename": "120.png", "idiom": "iphone", - "size": "40x40", - "scale": "3x" + "scale": "3x", + "size": "40x40" }, { + "filename": "120 1.png", "idiom": "iphone", - "size": "60x60", - "scale": "2x" + "scale": "2x", + "size": "60x60" }, { + "filename": "180.png", "idiom": "iphone", - "size": "60x60", - "scale": "3x" + "scale": "3x", + "size": "60x60" + }, + { + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "167.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" }, { "idiom": "ios-marketing", - "size": "1024x1024", - "scale": "1x" + "scale": "1x", + "size": "1024x1024" } ], "info": { - "version": 1, - "author": "xcode" + "author": "xcode", + "version": 1 } } diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/Contents.json b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/Contents.json index 9a38aea4a8b791..d458f1c5928f7d 100644 --- a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/Contents.json +++ b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info": { - "version": 1, - "author": "xcode" + "author": "xcode", + "version": 1 } } diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/Contents.json b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/Contents.json new file mode 100644 index 00000000000000..4ac8d3b08a30f9 --- /dev/null +++ b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "GutenbergLogo.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "GutenbergLogo@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "GutenbergLogo@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo.png new file mode 100644 index 00000000000000..98cdf056bada2a Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo@2x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo@2x.png new file mode 100644 index 00000000000000..e499f955231253 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo@2x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo@3x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo@3x.png new file mode 100644 index 00000000000000..a6f69b7a1f9ad2 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/GutenbergLogo.imageset/GutenbergLogo@3x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/Contents.json b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/Contents.json new file mode 100644 index 00000000000000..a7ab9ab17cc084 --- /dev/null +++ b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "editor-more.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "editor-more@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "editor-more@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more.png new file mode 100644 index 00000000000000..50ae464cf9a0bd Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more@2x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more@2x.png new file mode 100644 index 00000000000000..50f8ba4fa2991d Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more@2x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more@3x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more@3x.png new file mode 100644 index 00000000000000..933dee6311d135 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/more.imageset/editor-more@3x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/Contents.json b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/Contents.json new file mode 100644 index 00000000000000..87d8d6ddf04bd8 --- /dev/null +++ b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "redo.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "redo@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "redo@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo.png new file mode 100644 index 00000000000000..d7c40357de3319 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo@2x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo@2x.png new file mode 100644 index 00000000000000..7fcc8387ad5d98 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo@2x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo@3x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo@3x.png new file mode 100644 index 00000000000000..4bba41d8298731 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/redo.imageset/redo@3x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/Contents.json b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/Contents.json new file mode 100644 index 00000000000000..e6ba640f73e246 --- /dev/null +++ b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "undo.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "undo@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "undo@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo.png new file mode 100644 index 00000000000000..5fde404937bb98 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo@2x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo@2x.png new file mode 100644 index 00000000000000..8fb11c970db0bc Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo@2x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo@3x.png b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo@3x.png new file mode 100644 index 00000000000000..15703e1b6d6367 Binary files /dev/null and b/packages/react-native-editor/ios/GutenbergDemo/Images.xcassets/undo.imageset/undo@3x.png differ diff --git a/packages/react-native-editor/ios/GutenbergDemo/Info.plist b/packages/react-native-editor/ios/GutenbergDemo/Info.plist index 3929cb00cf60bf..02b32d3ab22292 100644 --- a/packages/react-native-editor/ios/GutenbergDemo/Info.plist +++ b/packages/react-native-editor/ios/GutenbergDemo/Info.plist @@ -17,11 +17,11 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.0</string> + <string>$(MARKETING_VERSION)</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>1</string> + <string>$(CURRENT_PROJECT_VERSION)</string> <key>LSRequiresIPhoneOS</key> <true/> <key>NSAppTransportSecurity</key> diff --git a/packages/react-native-editor/ios/LaunchScreen.storyboard b/packages/react-native-editor/ios/LaunchScreen.storyboard index 8306f30bc23285..d472b72a5ae627 100644 --- a/packages/react-native-editor/ios/LaunchScreen.storyboard +++ b/packages/react-native-editor/ios/LaunchScreen.storyboard @@ -1,8 +1,9 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13142" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> + <device id="retina6_12" orientation="portrait" appearance="light"/> <dependencies> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12042"/> - <capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1"/> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21679"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -12,37 +13,31 @@ <objects> <viewController id="01J-lp-oVM" sceneMemberID="viewController"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <rect key="frame" x="0.0" y="0.0" width="393" height="852"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> - <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Copyright © 2021 Facebook. All rights reserved." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="obG-Y5-kRd"> - <rect key="frame" x="0.0" y="626.5" width="375" height="20.5"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="GutenbergDemo" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb"> - <rect key="frame" x="0.0" y="202" width="375" height="43"/> - <fontDescription key="fontDescription" type="boldSystem" pointSize="36"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="GutenbergLogo" translatesAutoresizingMaskIntoConstraints="NO" id="ncC-dB-TVp"> + <rect key="frame" x="161.66666666666666" y="391" width="70" height="70"/> + <constraints> + <constraint firstAttribute="width" constant="70" id="VX0-jg-Cfg"/> + <constraint firstAttribute="height" constant="70" id="aFA-Ec-hdU"/> + </constraints> + </imageView> </subviews> + <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="obG-Y5-kRd" secondAttribute="centerX" id="5cz-MP-9tL"/> - <constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/> - <constraint firstItem="obG-Y5-kRd" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" constant="20" symbolic="YES" id="SfN-ll-jLj"/> - <constraint firstAttribute="bottom" secondItem="obG-Y5-kRd" secondAttribute="bottom" constant="20" id="Y44-ml-fuU"/> - <constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/> - <constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" constant="20" symbolic="YES" id="x7j-FC-K8j"/> + <constraint firstItem="ncC-dB-TVp" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="8EG-mg-H5D"/> + <constraint firstItem="ncC-dB-TVp" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="gmp-Oa-mLP"/> </constraints> - <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/> </view> </viewController> <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> </objects> - <point key="canvasLocation" x="53" y="375"/> + <point key="canvasLocation" x="52.671755725190835" y="374.64788732394368"/> </scene> </scenes> + <resources> + <image name="GutenbergLogo" width="90" height="90"/> + </resources> </document> diff --git a/packages/react-native-editor/ios/Podfile b/packages/react-native-editor/ios/Podfile index 0423e435398eb9..a6100a78272652 100644 --- a/packages/react-native-editor/ios/Podfile +++ b/packages/react-native-editor/ios/Podfile @@ -6,7 +6,25 @@ require_relative '../../../node_modules/@react-native-community/cli-platform-ios # Uncomment the next line to define a global platform for your project app_ios_deployment_target = Gem::Version.new('13.0') platform :ios, app_ios_deployment_target.version -install! 'cocoapods', :deterministic_uuids => false +install! 'cocoapods', min_ios_version_supported +prepare_react_native_project! + +# If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set. +# because `react-native-flipper` depends on (FlipperKit,...) that will be excluded +# +# To fix this you can also exclude `react-native-flipper` using a `react-native.config.js` +# ```js +# module.exports = { +# dependencies: { +# ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}), +# ``` +flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled + +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green + use_frameworks! :linkage => linkage.to_sym +end target 'GutenbergDemo' do # Comment the next line if you don't want to use dynamic frameworks @@ -16,9 +34,16 @@ target 'GutenbergDemo' do use_react_native!( :path => config[:reactNativePath], - # to enable hermes on iOS, change `false` to `true` and then install pods + # Hermes is now enabled by default. Disable by setting this flag to false. + # Upcoming versions of React Native may rely on get_default_flags(), but + # we make it explicit here to aid in the React Native upgrade process. :hermes_enabled => false, :fabric_enabled => false, + # Enables Flipper. + # + # Note that if you have use_frameworks! enabled, Flipper will not work and + # you should disable the next line. + # :flipper_configuration => flipper_config, # An absolute path to the application root :app_path => "#{Pod::Config.instance.installation_root}/.." ) @@ -29,8 +54,13 @@ target 'GutenbergDemo' do end post_install do |installer| - react_native_post_install(installer) - __apply_Xcode_12_5_M1_post_install_workaround(installer) + react_native_post_install( + installer, + config[:reactNativePath], + # Set `mac_catalyst_enabled` to `true` in order to apply patches + # necessary for Mac Catalyst builds + :mac_catalyst_enabled => false + ) # Let Pods targets inherit deployment target from the app # This solution is suggested here: https://github.com/CocoaPods/CocoaPods/issues/4859 diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index 8fcaecffe9aa3d..170a7561517537 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -1,22 +1,22 @@ PODS: - boost (1.76.0) - - BVLinearGradient (2.5.6-wp-3): + - BVLinearGradient (2.7.3): - React-Core - DoubleConversion (1.1.6) - - FBLazyVector (0.69.4) - - FBReactNativeSpec (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTRequired (= 0.69.4) - - RCTTypeSafety (= 0.69.4) - - React-Core (= 0.69.4) - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) + - FBLazyVector (0.71.11) + - FBReactNativeSpec (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - RCTRequired (= 0.71.11) + - RCTTypeSafety (= 0.71.11) + - React-Core (= 0.71.11) + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) - fmt (6.2.1) - glog (0.3.5) - - Gutenberg (1.95.0): - - React-Core (= 0.69.4) - - React-CoreModules (= 0.69.4) - - React-RCTImage (= 0.69.4) + - Gutenberg (1.102.1): + - React-Core (= 0.71.11) + - React-CoreModules (= 0.71.11) + - React-RCTImage (= 0.71.11) - RNTAztecView - libwebp (1.2.3): - libwebp/demux (= 1.2.3) @@ -27,214 +27,227 @@ PODS: - libwebp/mux (1.2.3): - libwebp/demux - libwebp/webp (1.2.3) - - RCT-Folly (2021.06.28.00-v2): + - RCT-Folly (2021.07.22.00): - boost - DoubleConversion - fmt (~> 6.2.1) - glog - - RCT-Folly/Default (= 2021.06.28.00-v2) - - RCT-Folly/Default (2021.06.28.00-v2): + - RCT-Folly/Default (= 2021.07.22.00) + - RCT-Folly/Default (2021.07.22.00): - boost - DoubleConversion - fmt (~> 6.2.1) - glog - - RCTRequired (0.69.4) - - RCTTypeSafety (0.69.4): - - FBLazyVector (= 0.69.4) - - RCTRequired (= 0.69.4) - - React-Core (= 0.69.4) - - React (0.69.4): - - React-Core (= 0.69.4) - - React-Core/DevSupport (= 0.69.4) - - React-Core/RCTWebSocket (= 0.69.4) - - React-RCTActionSheet (= 0.69.4) - - React-RCTAnimation (= 0.69.4) - - React-RCTBlob (= 0.69.4) - - React-RCTImage (= 0.69.4) - - React-RCTLinking (= 0.69.4) - - React-RCTNetwork (= 0.69.4) - - React-RCTSettings (= 0.69.4) - - React-RCTText (= 0.69.4) - - React-RCTVibration (= 0.69.4) - - React-bridging (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - React-jsi (= 0.69.4) - - React-callinvoker (0.69.4) - - React-Codegen (0.69.4): - - FBReactNativeSpec (= 0.69.4) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTRequired (= 0.69.4) - - RCTTypeSafety (= 0.69.4) - - React-Core (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-Core (0.69.4): + - RCTRequired (0.71.11) + - RCTTypeSafety (0.71.11): + - FBLazyVector (= 0.71.11) + - RCTRequired (= 0.71.11) + - React-Core (= 0.71.11) + - React (0.71.11): + - React-Core (= 0.71.11) + - React-Core/DevSupport (= 0.71.11) + - React-Core/RCTWebSocket (= 0.71.11) + - React-RCTActionSheet (= 0.71.11) + - React-RCTAnimation (= 0.71.11) + - React-RCTBlob (= 0.71.11) + - React-RCTImage (= 0.71.11) + - React-RCTLinking (= 0.71.11) + - React-RCTNetwork (= 0.71.11) + - React-RCTSettings (= 0.71.11) + - React-RCTText (= 0.71.11) + - React-RCTVibration (= 0.71.11) + - React-callinvoker (0.71.11) + - React-Codegen (0.71.11): + - FBReactNativeSpec + - RCT-Folly + - RCTRequired + - RCTTypeSafety + - React-Core + - React-jsc + - React-jsi + - React-jsiexecutor + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - React-Core (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default (= 0.69.4) - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - RCT-Folly (= 2021.07.22.00) + - React-Core/Default (= 0.71.11) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/CoreModulesHeaders (0.69.4): + - React-Core/CoreModulesHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/Default (0.69.4): + - React-Core/Default (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - RCT-Folly (= 2021.07.22.00) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/DevSupport (0.69.4): + - React-Core/DevSupport (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default (= 0.69.4) - - React-Core/RCTWebSocket (= 0.69.4) - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-jsinspector (= 0.69.4) - - React-perflogger (= 0.69.4) + - RCT-Folly (= 2021.07.22.00) + - React-Core/Default (= 0.71.11) + - React-Core/RCTWebSocket (= 0.71.11) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-jsinspector (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTActionSheetHeaders (0.69.4): + - React-Core/RCTActionSheetHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTAnimationHeaders (0.69.4): + - React-Core/RCTAnimationHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTBlobHeaders (0.69.4): + - React-Core/RCTBlobHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTImageHeaders (0.69.4): + - React-Core/RCTImageHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTLinkingHeaders (0.69.4): + - React-Core/RCTLinkingHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTNetworkHeaders (0.69.4): + - React-Core/RCTNetworkHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTSettingsHeaders (0.69.4): + - React-Core/RCTSettingsHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTTextHeaders (0.69.4): + - React-Core/RCTTextHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTVibrationHeaders (0.69.4): + - React-Core/RCTVibrationHeaders (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) + - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-Core/RCTWebSocket (0.69.4): + - React-Core/RCTWebSocket (0.71.11): - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default (= 0.69.4) - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsiexecutor (= 0.69.4) - - React-perflogger (= 0.69.4) + - RCT-Folly (= 2021.07.22.00) + - React-Core/Default (= 0.71.11) + - React-cxxreact (= 0.71.11) + - React-jsc + - React-jsi (= 0.71.11) + - React-jsiexecutor (= 0.71.11) + - React-perflogger (= 0.71.11) - Yoga - - React-CoreModules (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.69.4) - - React-Codegen (= 0.69.4) - - React-Core/CoreModulesHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - React-RCTImage (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-cxxreact (0.69.4): - - boost (= 1.76.0) - - DoubleConversion - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-callinvoker (= 0.69.4) - - React-jsi (= 0.69.4) - - React-jsinspector (= 0.69.4) - - React-logger (= 0.69.4) - - React-perflogger (= 0.69.4) - - React-runtimeexecutor (= 0.69.4) - - React-jsi (0.69.4): + - React-CoreModules (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - RCTTypeSafety (= 0.71.11) + - React-Codegen (= 0.71.11) + - React-Core/CoreModulesHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - React-RCTBlob + - React-RCTImage (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-cxxreact (0.71.11): - boost (= 1.76.0) - DoubleConversion - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-jsi/Default (= 0.69.4) - - React-jsi/Default (0.69.4): + - RCT-Folly (= 2021.07.22.00) + - React-callinvoker (= 0.71.11) + - React-jsi (= 0.71.11) + - React-jsinspector (= 0.71.11) + - React-logger (= 0.71.11) + - React-perflogger (= 0.71.11) + - React-runtimeexecutor (= 0.71.11) + - React-jsc (0.71.11): + - React-jsc/Fabric (= 0.71.11) + - React-jsi (= 0.71.11) + - React-jsc/Fabric (0.71.11): + - React-jsi (= 0.71.11) + - React-jsi (0.71.11): - boost (= 1.76.0) - DoubleConversion - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-jsiexecutor (0.69.4): + - RCT-Folly (= 2021.07.22.00) + - React-jsiexecutor (0.71.11): - DoubleConversion - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-perflogger (= 0.69.4) - - React-jsinspector (0.69.4) - - React-logger (0.69.4): + - RCT-Folly (= 2021.07.22.00) + - React-cxxreact (= 0.71.11) + - React-jsi (= 0.71.11) + - React-perflogger (= 0.71.11) + - React-jsinspector (0.71.11) + - React-logger (0.71.11): - glog - react-native-blur (4.2.0): - React-Core @@ -242,94 +255,113 @@ PODS: - React-Core - react-native-safe-area (0.5.1): - React-Core - - react-native-safe-area-context (3.2.0): + - react-native-safe-area-context (4.6.3): + - RCT-Folly + - RCTRequired + - RCTTypeSafety + - React-Core + - ReactCommon/turbomodule/core + - react-native-slider (3.0.2-wp-4): - React-Core - - react-native-slider (3.0.2-wp-3): + - react-native-video (5.2.0-wp-6): - React-Core - - react-native-video (5.2.0-wp-5): + - react-native-video/Video (= 5.2.0-wp-6) + - react-native-video/Video (5.2.0-wp-6): - React-Core - - react-native-video/Video (= 5.2.0-wp-5) - - react-native-video/Video (5.2.0-wp-5): + - react-native-webview (11.26.1): - React-Core - - react-native-webview (11.6.2): + - React-perflogger (0.71.11) + - React-RCTActionSheet (0.71.11): + - React-Core/RCTActionSheetHeaders (= 0.71.11) + - React-RCTAnimation (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - RCTTypeSafety (= 0.71.11) + - React-Codegen (= 0.71.11) + - React-Core/RCTAnimationHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-RCTAppDelegate (0.71.11): + - RCT-Folly + - RCTRequired + - RCTTypeSafety - React-Core - - React-perflogger (0.69.4) - - React-RCTActionSheet (0.69.4): - - React-Core/RCTActionSheetHeaders (= 0.69.4) - - React-RCTAnimation (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.69.4) - - React-Codegen (= 0.69.4) - - React-Core/RCTAnimationHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-RCTBlob (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - React-Codegen (= 0.69.4) - - React-Core/RCTBlobHeaders (= 0.69.4) - - React-Core/RCTWebSocket (= 0.69.4) - - React-jsi (= 0.69.4) - - React-RCTNetwork (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-RCTImage (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.69.4) - - React-Codegen (= 0.69.4) - - React-Core/RCTImageHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - React-RCTNetwork (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-RCTLinking (0.69.4): - - React-Codegen (= 0.69.4) - - React-Core/RCTLinkingHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-RCTNetwork (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.69.4) - - React-Codegen (= 0.69.4) - - React-Core/RCTNetworkHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-RCTSettings (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.69.4) - - React-Codegen (= 0.69.4) - - React-Core/RCTSettingsHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-RCTText (0.69.4): - - React-Core/RCTTextHeaders (= 0.69.4) - - React-RCTVibration (0.69.4): - - RCT-Folly (= 2021.06.28.00-v2) - - React-Codegen (= 0.69.4) - - React-Core/RCTVibrationHeaders (= 0.69.4) - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (= 0.69.4) - - React-runtimeexecutor (0.69.4): - - React-jsi (= 0.69.4) - - ReactCommon/turbomodule/core (0.69.4): + - ReactCommon/turbomodule/core + - React-RCTBlob (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - React-Codegen (= 0.71.11) + - React-Core/RCTBlobHeaders (= 0.71.11) + - React-Core/RCTWebSocket (= 0.71.11) + - React-jsi (= 0.71.11) + - React-RCTNetwork (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-RCTImage (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - RCTTypeSafety (= 0.71.11) + - React-Codegen (= 0.71.11) + - React-Core/RCTImageHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - React-RCTNetwork (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-RCTLinking (0.71.11): + - React-Codegen (= 0.71.11) + - React-Core/RCTLinkingHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-RCTNetwork (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - RCTTypeSafety (= 0.71.11) + - React-Codegen (= 0.71.11) + - React-Core/RCTNetworkHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-RCTSettings (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - RCTTypeSafety (= 0.71.11) + - React-Codegen (= 0.71.11) + - React-Core/RCTSettingsHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-RCTText (0.71.11): + - React-Core/RCTTextHeaders (= 0.71.11) + - React-RCTVibration (0.71.11): + - RCT-Folly (= 2021.07.22.00) + - React-Codegen (= 0.71.11) + - React-Core/RCTVibrationHeaders (= 0.71.11) + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/core (= 0.71.11) + - React-runtimeexecutor (0.71.11): + - React-jsi (= 0.71.11) + - ReactCommon/turbomodule/bridging (0.71.11): + - DoubleConversion + - glog + - RCT-Folly (= 2021.07.22.00) + - React-callinvoker (= 0.71.11) + - React-Core (= 0.71.11) + - React-cxxreact (= 0.71.11) + - React-jsi (= 0.71.11) + - React-logger (= 0.71.11) + - React-perflogger (= 0.71.11) + - ReactCommon/turbomodule/core (0.71.11): - DoubleConversion - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-bridging (= 0.69.4) - - React-callinvoker (= 0.69.4) - - React-Core (= 0.69.4) - - React-cxxreact (= 0.69.4) - - React-jsi (= 0.69.4) - - React-logger (= 0.69.4) - - React-perflogger (= 0.69.4) - - RNCClipboard (1.9.0): + - RCT-Folly (= 2021.07.22.00) + - React-callinvoker (= 0.71.11) + - React-Core (= 0.71.11) + - React-cxxreact (= 0.71.11) + - React-jsi (= 0.71.11) + - React-logger (= 0.71.11) + - React-perflogger (= 0.71.11) + - RNCClipboard (1.11.2): - React-Core - - RNCMaskedView (0.2.6): + - RNCMaskedView (0.2.9): - React-Core - RNFastImage (8.5.11): - React-Core - SDWebImage (~> 5.11.1) - SDWebImageWebPCoder (~> 0.8.4) - - RNGestureHandler (2.3.2-wp-2): + - RNGestureHandler (2.10.2): - React-Core - - RNReanimated (2.9.1-wp-3): + - RNReanimated (2.17.0): - DoubleConversion - FBLazyVector - FBReactNativeSpec @@ -343,6 +375,7 @@ PODS: - React-Core/RCTWebSocket - React-CoreModules - React-cxxreact + - React-jsc - React-jsi - React-jsiexecutor - React-jsinspector @@ -356,11 +389,12 @@ PODS: - React-RCTText - ReactCommon/turbomodule/core - Yoga - - RNScreens (2.9.0): + - RNScreens (3.22.0): - React-Core - - RNSVG (9.13.6): + - React-RCTImage + - RNSVG (13.9.0): - React-Core - - RNTAztecView (1.95.0): + - RNTAztecView (1.102.1): - React-Core - WordPress-Aztec-iOS (~> 1.19.8) - SDWebImage (5.11.1): @@ -384,13 +418,13 @@ DEPENDENCIES: - RCTRequired (from `../../../node_modules/react-native/Libraries/RCTRequired`) - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) - React (from `../../../node_modules/react-native/`) - - React-bridging (from `../../../node_modules/react-native/ReactCommon`) - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) - React-Codegen (from `build/generated/ios`) - React-Core (from `../../../node_modules/react-native/`) - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) + - React-jsc (from `../../../node_modules/react-native/ReactCommon/jsc`) - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector`) @@ -405,6 +439,7 @@ DEPENDENCIES: - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) @@ -455,8 +490,6 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/Libraries/TypeSafety" React: :path: "../../../node_modules/react-native/" - React-bridging: - :path: "../../../node_modules/react-native/ReactCommon" React-callinvoker: :path: "../../../node_modules/react-native/ReactCommon/callinvoker" React-Codegen: @@ -467,6 +500,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/React/CoreModules" React-cxxreact: :path: "../../../node_modules/react-native/ReactCommon/cxxreact" + React-jsc: + :path: "../../../node_modules/react-native/ReactCommon/jsc" React-jsi: :path: "../../../node_modules/react-native/ReactCommon/jsi" React-jsiexecutor: @@ -495,6 +530,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: :path: "../../../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../../../node_modules/react-native/Libraries/AppDelegate" React-RCTBlob: :path: "../../../node_modules/react-native/Libraries/Blob" React-RCTImage: @@ -533,61 +570,62 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: a7c83b31436843459a1961bfd74b96033dc77234 - BVLinearGradient: ace34fab72158c068ae989a0ebdbf86cb4ef0e49 + boost: 57d2868c099736d80fcd648bf211b4431e51a558 + BVLinearGradient: fbe308a1d19a8133f69e033abc85d8008644f5e3 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 - FBLazyVector: c71b8c429a8af2aff1013934a7152e9d9d0c937d - FBReactNativeSpec: 2ff441cbe6e58c1778d8a5cf3311831a6a8c0809 + FBLazyVector: c511d4cd0210f416cb5c289bd5ae6b36d909b048 + FBReactNativeSpec: f07662560742d82a5b73cee116c70b0b49bcc220 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 3d02b25ca00c2d456734d0bcff864cbc62f6ae1a - Gutenberg: 98fc15135123cdfcfa6ad1fc35c8012014dab488 + glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b + Gutenberg: 656841784dff643159705b6f22e11b4149696e8c libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c - RCT-Folly: b9d9fe1fc70114b751c076104e52f3b1b5e5a95a - RCTRequired: bd9d2ab0fda10171fcbcf9ba61a7df4dc15a28f4 - RCTTypeSafety: e44e139bf6ec8042db396201834fc2372f6a21cd - React: 482cd1ba23c471be1aed3800180be2427418d7be - React-bridging: c2ea4fed6fe4ed27c12fd71e88b5d5d3da107fde - React-callinvoker: d4d1f98163fb5e35545e910415ef6c04796bb188 - React-Codegen: ff35fb9c7f6ec2ed34fb6de2e1099d88dfb25f2f - React-Core: 4d3443a45b67c71d74d7243ddde9569d1e4f4fad - React-CoreModules: 70be25399366b5632ab18ecf6fe444a8165a7bea - React-cxxreact: 822d3794fc0bf206f4691592f90e086dd4f92228 - React-jsi: ffa51cbc9a78cc156cf61f79ed52ecb76dc6013b - React-jsiexecutor: a27badbbdbc0ff781813370736a2d1c7261181d4 - React-jsinspector: 8a3d3f5dcd23a91e8c80b1bf0e96902cd1dca999 - React-logger: 1088859f145b8f6dd0d3ed051a647ef0e3e80fad + RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCTRequired: f6187ec763637e6a57f5728dd9a3bdabc6d6b4e0 + RCTTypeSafety: a01aca2dd3b27fa422d5239252ad38e54e958750 + React: 741b4f5187e7a2137b69c88e65f940ba40600b4b + React-callinvoker: 72ba74b2d5d690c497631191ae6eeca0c043d9cf + React-Codegen: 6a3870e906e80066a9b707389846c692a02415d9 + React-Core: 9cf97a2d0830a024deffebe873407f6717bbcc19 + React-CoreModules: ffd19b082fc36b9b463fedf30955138b5426c053 + React-cxxreact: f88c74ac51e59c294fbf825974d377fcf9641eba + React-jsc: 75bfda40ea4032b5018875355ab5ee089ac748bf + React-jsi: 71ae5726d2b0fd6b0aaa0845a9294739cf4c95c6 + React-jsiexecutor: 089cd07c76ecf498960a64ba8ae0f2dddd382f44 + React-jsinspector: b6ed4cb3ffa27a041cd440300503dc512b761450 + React-logger: 186dd536128ae5924bc38ed70932c00aa740cd5b react-native-blur: 3e9c8e8e9f7d17fa1b94e1a0ae9fd816675f5382 react-native-get-random-values: b6fb85e7169b9822976793e467458c151c3e8b69 react-native-safe-area: c9cf765aa2dd96159476a99633e7d462ce5bb94f - react-native-safe-area-context: f0906bf8bc9835ac9a9d3f97e8bde2a997d8da79 - react-native-slider: a433f1c13c5da3c17a587351bff7371f65cc9a07 - react-native-video: cc1982bfac4d256fb56302642d968b6e72ffbeb7 - react-native-webview: 193d233c29eacce1f42ca2637dab7ba79c25a6de - React-perflogger: cb386fd44c97ec7f8199c04c12b22066b0f2e1e0 - React-RCTActionSheet: f803a85e46cf5b4066c2ac5e122447f918e9c6e5 - React-RCTAnimation: 19c80fa950ccce7f4db76a2a7f2cf79baae07fc7 - React-RCTBlob: f36ab97e2d515c36df14a1571e50056be80413d5 - React-RCTImage: 2c8f0a329a116248e82f8972ffe806e47c6d1cfa - React-RCTLinking: 670f0223075aff33be3b89714f1da4f5343fc4af - React-RCTNetwork: 09385b73f4ff1f46bd5d749540fb33f69a7e5908 - React-RCTSettings: 33b12d3ac7a1f2eba069ec7bd1b84345263b3bbe - React-RCTText: a1a3ea902403bd9ae4cf6f7960551dc1d25711b5 - React-RCTVibration: 9adb4a3cbb598d1bbd46a05256f445e4b8c70603 - React-runtimeexecutor: 61ee22a8cdf8b6bb2a7fb7b4ba2cc763e5285196 - ReactCommon: 8f67bd7e0a6afade0f20718f859dc8c2275f2e83 - RNCClipboard: 99fc8ad669a376b756fbc8098ae2fd05c0ed0668 - RNCMaskedView: c298b644a10c0c142055b3ae24d83879ecb13ccd + react-native-safe-area-context: 36cc67648134e89465663b8172336a19eeda493d + react-native-slider: dff0d8a46f368a8d1bacd8638570d75b9b0be400 + react-native-video: 6dee623307ed9d04d1be2de87494f9a0fa2041d1 + react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1 + React-perflogger: e706562ab7eb8eb590aa83a224d26fa13963d7f2 + React-RCTActionSheet: 57d4bd98122f557479a3359ad5dad8e109e20c5a + React-RCTAnimation: ccf3ef00101ea74bda73a045d79a658b36728a60 + React-RCTAppDelegate: de78bc79e1a469ffa275fbe3948356cea061c4bb + React-RCTBlob: 519c8ecb8ef83ce461a5670bdaf2fef882af3393 + React-RCTImage: f2e4904566ccccaa4b704170fcc5ae144ca347bf + React-RCTLinking: 52a3740e3651e30aa11dff5a6debed7395dd8169 + React-RCTNetwork: ea0976f2b3ffc7877cd7784e351dc460adf87b12 + React-RCTSettings: ed5ac992b23e25c65c3cc31f11b5c940ae5e3e60 + React-RCTText: c9dfc6722621d56332b4f3a19ac38105e7504145 + React-RCTVibration: f09f08de63e4122deb32506e20ca4cae6e4e14c1 + React-runtimeexecutor: 4817d63dbc9d658f8dc0ec56bd9b83ce531129f0 + ReactCommon: e2d70ebcd90a2eaab343fb0cc23bbdb5ac321f5c + RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc + RNCMaskedView: 949696f25ec596bfc697fc88e6f95cf0c79669b6 RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7 - RNGestureHandler: 3e0ea0c115175e66680032904339696bab928ca3 - RNReanimated: bea6acb5fdcbd8ca27641180579d09e3434f803c - RNScreens: 953633729a42e23ad0c93574d676b361e3335e8b - RNSVG: 36a7359c428dcb7c6bce1cc546fbfebe069809b0 - RNTAztecView: 95adb6b60e5d430ecf5eb710ff7813794d17ddc8 + RNGestureHandler: f75d81410b40aaa99e71ae8f8bb7a88620c95042 + RNReanimated: df2567658c01135f9ff4709d372675bcb9fd1d83 + RNScreens: 68fd1060f57dd1023880bf4c05d74784b5392789 + RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315 + RNTAztecView: f6bc15f12a0c97e22a7c5ed1c2c5e4e9e93e65b7 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d WordPress-Aztec-iOS: 7d11d598f14c82c727c08b56bd35fbeb7dafb504 - Yoga: ff994563b2fd98c982ca58e8cd9db2cdaf4dda74 + Yoga: f7decafdc5e8c125e6fa0da38a687e35238420fa -PODFILE CHECKSUM: 6ae5f47a0b577b6e4ad29c87992c37b2a814a0b7 +PODFILE CHECKSUM: 13786fe1bd037b8f06258137c3f1269a82608b59 COCOAPODS: 1.11.3 diff --git a/packages/react-native-editor/ios/gutenbergTests/Info.plist b/packages/react-native-editor/ios/gutenbergTests/Info.plist index 886825ccc9bf0d..86d31dc8a65810 100644 --- a/packages/react-native-editor/ios/gutenbergTests/Info.plist +++ b/packages/react-native-editor/ios/gutenbergTests/Info.plist @@ -15,10 +15,10 @@ <key>CFBundlePackageType</key> <string>BNDL</string> <key>CFBundleShortVersionString</key> - <string>1.0</string> + <string>$(MARKETING_VERSION)</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>1</string> + <string>$(CURRENT_PROJECT_VERSION)</string> </dict> </plist> diff --git a/packages/react-native-editor/jest_ui.config.js b/packages/react-native-editor/jest_ui.config.js index 43dc5519ee869f..f1dd087a5a9890 100644 --- a/packages/react-native-editor/jest_ui.config.js +++ b/packages/react-native-editor/jest_ui.config.js @@ -9,7 +9,6 @@ if ( process.env.TEST_RN_PLATFORM ) { } module.exports = { - verbose: true, rootDir: './', haste: { defaultPlatform: rnPlatform, @@ -23,4 +22,8 @@ module.exports = { testMatch: [ '**/__device-tests__/**/*.test.[jt]s?(x)' ], testRunner: 'jest-jasmine2', reporters: [ 'default', 'jest-junit' ], + watchPlugins: [ + 'jest-watch-typeahead/filename', + 'jest-watch-typeahead/testname', + ], }; diff --git a/packages/react-native-editor/jest_ui_test_environment.js b/packages/react-native-editor/jest_ui_test_environment.js index 4df51158f0b6a0..3813558ec3b111 100644 --- a/packages/react-native-editor/jest_ui_test_environment.js +++ b/packages/react-native-editor/jest_ui_test_environment.js @@ -1,10 +1,7 @@ /** * Internal dependencies */ -const { - initializeEditorPage, - blockNames, -} = require( './__device-tests__/pages/editor-page' ); +const { setupEditor } = require( './__device-tests__/pages/editor-page' ); const utils = require( './__device-tests__/helpers/utils' ); const testData = require( './__device-tests__/helpers/test-data' ); @@ -18,8 +15,7 @@ class CustomEnvironment extends JSDOMEnvironment { async setup() { try { await super.setup(); - this.global.editorPage = await initializeEditorPage(); - this.global.editorPage.blockNames = blockNames; + this.global.editorPage = await setupEditor(); this.global.e2eUtils = utils; this.global.e2eTestData = testData; } catch ( error ) { diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index c70d9e013734e4..b57486f9bdf2b6 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-editor", - "version": "1.95.0", + "version": "1.102.1", "description": "Mobile WordPress gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -29,15 +29,16 @@ "main": "src/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.16.0", - "@react-native-clipboard/clipboard": "1.9.0", + "@babel/runtime": "^7.20.0", + "@react-native-clipboard/clipboard": "1.11.2", "@react-native-community/blur": "4.2.0", - "@react-native-community/slider": "https://raw.githubusercontent.com/wordpress-mobile/react-native-slider/v3.0.2-wp-3/react-native-community-slider-3.0.2-wp-3.tgz", - "@react-native-masked-view/masked-view": "0.2.6", + "@react-native-community/slider": "https://raw.githubusercontent.com/wordpress-mobile/react-native-slider/v3.0.2-wp-4/react-native-community-slider-3.0.2-wp-4.tgz", + "@react-native-masked-view/masked-view": "0.2.9", + "@react-native/gradle-plugin": "0.72.11", "@react-navigation/core": "5.12.0", - "@react-navigation/native": "5.7.0", + "@react-navigation/native": "6.0.14", "@react-navigation/routers": "5.4.9", - "@react-navigation/stack": "5.6.2", + "@react-navigation/stack": "6.3.5", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/block-editor": "file:../block-editor", "@wordpress/block-library": "file:../block-library", @@ -50,30 +51,28 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/react-native-aztec": "file:../react-native-aztec", "@wordpress/react-native-bridge": "file:../react-native-bridge", - "core-js": "^3.19.1", + "core-js": "^3.31.0", "fast-average-color": "^9.1.1", "gettext-parser": "^1.3.1", "jed": "^1.1.1", "jsdom-jscore-rn": "0.1.8", "node-fetch": "^2.6.0", - "react-native": "0.69.4", + "react-native": "0.71.11", "react-native-fast-image": "8.5.11", - "react-native-gesture-handler": "https://raw.githubusercontent.com/wordpress-mobile/react-native-gesture-handler/2.3.2-wp-2/react-native-gesture-handler-2.3.2-wp-2.tgz", + "react-native-gesture-handler": "2.10.2", "react-native-get-random-values": "1.4.0", - "react-native-hr": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hr/1.1.3-wp-1/react-native-hr-1.1.3.tgz", - "react-native-hsv-color-picker": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hsv-color-picker/v1.0.1-wp-3/react-native-hsv-color-picker-1.0.1-wp-3.tgz", - "react-native-linear-gradient": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", - "react-native-modal": "^11.10.0", - "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-3/react-native-prompt-android-1.0.0-wp-3.tgz", - "react-native-reanimated": "https://raw.githubusercontent.com/wordpress-mobile/react-native-reanimated/2.9.1-wp-3/react-native-reanimated-2.9.1-wp-3.tgz", + "react-native-linear-gradient": "2.7.3", + "react-native-modal": "13.0.1", + "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-4/react-native-prompt-android-1.0.0-wp-4.tgz", + "react-native-reanimated": "2.17.0", "react-native-safe-area": "^0.5.0", - "react-native-safe-area-context": "3.2.0", + "react-native-safe-area-context": "4.6.3", "react-native-sass-transformer": "^1.1.1", - "react-native-screens": "2.9.0", - "react-native-svg": "9.13.6", + "react-native-screens": "3.22.0", + "react-native-svg": "13.9.0", "react-native-url-polyfill": "^1.1.2", - "react-native-video": "https://raw.githubusercontent.com/wordpress-mobile/react-native-video/5.2.0-wp-5/react-native-video-5.2.0-wp-5.tgz", - "react-native-webview": "11.6.2" + "react-native-video": "https://raw.githubusercontent.com/wordpress-mobile/react-native-video/5.2.0-wp-6/react-native-video-5.2.0-wp-6.tgz", + "react-native-webview": "11.26.1" }, "publishConfig": { "access": "public" @@ -95,7 +94,7 @@ "i18n:extract-used-strings": "node bin/extract-used-strings", "i18n:fetch-translations": "node bin/i18n-translations-download", "postinstall": "npm run i18n-cache", - "android": "react-native run-android --no-packager --no-jetifier", + "android": "react-native run-android --no-packager", "prewpandroid": "rm -Rf $TMPDIR/gbmobile-wpandroidfakernroot && mkdir $TMPDIR/gbmobile-wpandroidfakernroot && ln -s $(cd \"$(dirname \"../../../../../\")\"; pwd) $TMPDIR/gbmobile-wpandroidfakernroot/android", "wpandroid": "npm run android -- --root $TMPDIR/gbmobile-wpandroidfakernroot --variant wasabiDebug --appIdSuffix beta --appFolder WordPress --main-activity=ui.WPLaunchActivity", "preios": "cd ios && (bundle check > /dev/null || bundle install) && bundle exec pod install --repo-update", @@ -104,15 +103,11 @@ "preios:xcode10": "cd ../../node_modules/react-native && ./scripts/ios-install-third-party.sh && cd third-party/glog-0.3.5 && [ -f libglog.pc ] || ../../scripts/ios-configure-glog.sh", "ios": "react-native run-ios", "ios:fast": "react-native run-ios", - "test": "cross-env NODE_ENV=test jest --verbose --config ../../test/native/jest.config.js", - "test:debug": "cross-env NODE_ENV=test node --inspect-brk ../../node_modules/.bin/jest --runInBand --verbose --config ../../test/native/jest.config.js", - "test:update": "npm run test -- --updateSnapshot", - "test:perf": "cross-env NODE_ENV=test TEST_RUNNER_PATH=../../node_modules/.bin/jest TEST_RUNNER_ARGS='--runInBand --testMatch \"**/performance/*.native.[jt]s?(x)\" --verbose --config ../../test/native/jest.config.js' reassure", - "test:perf:baseline": "cross-env NODE_ENV=test TEST_RUNNER_PATH=../../node_modules/.bin/jest TEST_RUNNER_ARGS='--runInBand --testMatch \"**/performance/*.native.[jt]s?(x)\" --verbose --config ../../test/native/jest.config.js' reassure --baseline", - "device-tests": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --no-cache --maxWorkers=3 --testPathIgnorePatterns='canary|gutenberg-editor-rendering' --verbose --config ./jest_ui.config.js", - "device-tests-canary": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --no-cache --maxWorkers=2 --testPathPattern=@canary --verbose --config ./jest_ui.config.js", - "device-tests:local": "cross-env NODE_ENV=test jest --runInBand --detectOpenHandles --verbose --forceExit --config ./jest_ui.config.js", + "device-tests": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --no-cache --maxWorkers=3 --testPathIgnorePatterns='canary|gutenberg-editor-rendering' --config ./jest_ui.config.js", + "device-tests-canary": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --no-cache --maxWorkers=2 --testPathPattern=@canary --config ./jest_ui.config.js", + "device-tests:local": "cross-env NODE_ENV=test jest --runInBand --detectOpenHandles --forceExit --config ./jest_ui.config.js", "device-tests:debug": "cross-env NODE_ENV=test node $NODE_DEBUG_OPTION --inspect-brk node_modules/jest/bin/jest --runInBand --detectOpenHandles --verbose --config ./jest_ui.config.js", + "appium:start": "node ./__device-tests__/helpers/appium-local-start.js", "test:e2e:bundle:android": "mkdir -p android/app/src/main/assets && npm run rn-bundle -- --reset-cache --platform android --dev false --minify false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res", "test:e2e:build-app:android": "cd android && ./gradlew clean && ./gradlew assembleDebug", "test:e2e:android:local": "npm run test:e2e:bundle:android && npm run test:e2e:build-app:android && TEST_RN_PLATFORM=android npm run device-tests:local", @@ -121,13 +116,12 @@ "test:e2e:build-wda": "xcodebuild -project ../../node_modules/appium/node_modules/appium-webdriveragent/WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'platform=iOS Simulator,name=iPhone 13' -derivedDataPath ios/build/WDA", "test:e2e:ios:local": "npm run test:e2e:bundle:ios && npm run test:e2e:build-app:ios && npm run test:e2e:build-wda && TEST_RN_PLATFORM=ios npm run device-tests:local", "build:gutenberg": "cd gutenberg && npm ci && npm run build", - "clean": "npm run clean:build-artifacts; npm run clean:aztec; npm run clean:haste; npm run clean:jest; npm run clean:metro; npm run clean:watchman", - "clean:runtime": "npm run clean:haste; npm run clean:metro; npm run clean:jest; npm run clean:watchman; npm run clean:babel-cache", + "clean": "npm run clean:build-artifacts; npm run clean:aztec; npm run clean:haste; npm run clean:metro; npm run clean:watchman", + "clean:runtime": "npm run clean:haste; npm run clean:metro; npm run clean:watchman; npm run clean:babel-cache", "clean:build-artifacts": "rm -rf ./ios/build && rm -rf ./ios/Pods", "clean:aztec": "cd ../react-native-aztec && npm run clean", "clean:haste": "rm -rf $TMPDIR/haste-map-metro-*", "clean:install": "npm run clean; npm install", - "clean:jest": "jest --clearCache --config ../../test/native/jest.config.js; rm -rf $TMPDIR/jest_*", "clean:metro": "rm -rf $TMPDIR/metro-cache/", "clean:watchman": "command -v watchman >/dev/null 2>&1 && watchman watch-del-all; true", "clean:babel-cache": "rm -rf ../../node_modules/.cache/babel-loader/*", diff --git a/packages/react-native-editor/src/index.js b/packages/react-native-editor/src/index.js index adb33b8943bdeb..a4ca31e3e94888 100644 --- a/packages/react-native-editor/src/index.js +++ b/packages/react-native-editor/src/index.js @@ -60,11 +60,6 @@ const registerGutenberg = ( { ); } - componentDidMount() { - // Dispatch post-render hooks. - doAction( 'native.render', this.filteredProps ); - } - render() { return cloneElement( this.editorComponent, this.filteredProps ); } diff --git a/packages/react-native-editor/src/setup-locale.js b/packages/react-native-editor/src/setup-locale.js index bb50c5e76d3fb9..a583f2e1e53192 100644 --- a/packages/react-native-editor/src/setup-locale.js +++ b/packages/react-native-editor/src/setup-locale.js @@ -35,16 +35,6 @@ export default ( ...extraTranslations, }; - if ( domain === 'default' ) { - // eslint-disable-next-line no-console - console.log( 'locale', locale, allTranslations ); - } else { - // Extra translations are already logged along with the default domain, so - // for other domains we can limit the output to their translations. - // eslint-disable-next-line no-console - console.log( `${ domain } - locale`, locale, translations ); - } - // Only change the locale if it's supported by gutenberg if ( translations || extraTranslations ) { setLocaleData( allTranslations, domain ); diff --git a/packages/react-native-editor/src/setup.js b/packages/react-native-editor/src/setup.js index ff868d311e30b7..f5c80883b4b6d7 100644 --- a/packages/react-native-editor/src/setup.js +++ b/packages/react-native-editor/src/setup.js @@ -7,7 +7,7 @@ import { I18nManager, LogBox } from 'react-native'; * WordPress dependencies */ import { unregisterBlockType, getBlockType } from '@wordpress/blocks'; -import { addAction, addFilter } from '@wordpress/hooks'; +import { addAction, addFilter, doAction } from '@wordpress/hooks'; import * as wpData from '@wordpress/data'; import { registerCoreBlocks } from '@wordpress/block-library'; // eslint-disable-next-line no-restricted-imports @@ -22,21 +22,6 @@ import setupApiFetch from './api-fetch-setup'; const reactNativeSetup = () => { LogBox.ignoreLogs( [ 'Require cycle:', // TODO: Refactor to remove require cycles - 'lineHeight', // TODO: Remove lineHeight warning from Aztec - /** - * TODO: Migrate to @gorhom/bottom-sheet or replace usage of - * LayoutAnimation to Animated. KeyboardAvoidingView's usage of - * LayoutAnimation collides with both BottomSheet and NavigationContainer - * usage of LayoutAnimation simultaneously https://github.com/facebook/react-native/issues/12663, - * https://github.com/facebook/react-native/issues/10606 - */ - 'Overriding previous layout animation', - ] ); - - // "@react-navigation" package uses the old API of gesture handler, - // so the warning will be silenced until it gets updated. - LogBox.ignoreLogs( [ - "[react-native-gesture-handler] Seems like you're using an old API with gesture components, check out new Gestures system!", ] ); I18nManager.forceRTL( false ); // Change to `true` to debug RTL layout easily. @@ -50,10 +35,6 @@ const gutenbergSetup = () => { setupApiFetch(); - const isHermes = () => global.HermesInternal !== null; - // eslint-disable-next-line no-console - console.log( 'Hermes is: ' + isHermes() ); - setupInitHooks(); }; @@ -70,6 +51,8 @@ const setupInitHooks = () => { ) { unregisterBlockType( 'core/block' ); } + + doAction( 'native.post-register-core-blocks', props ); } ); // Map native props to Editor props diff --git a/packages/react-native-editor/src/test/index.test.js b/packages/react-native-editor/src/test/index.test.js index 8e27cecf8e75b9..e64e5ff934b202 100644 --- a/packages/react-native-editor/src/test/index.test.js +++ b/packages/react-native-editor/src/test/index.test.js @@ -8,7 +8,9 @@ import { initializeEditor, render } from 'test/helpers'; * WordPress dependencies */ import * as wpHooks from '@wordpress/hooks'; -import '@wordpress/jest-console'; +import { registerCoreBlocks } from '@wordpress/block-library'; +// eslint-disable-next-line no-restricted-imports +import * as wpEditPost from '@wordpress/edit-post'; /** * Internal dependencies @@ -18,6 +20,11 @@ import setupLocale from '../setup-locale'; jest.mock( 'react-native/Libraries/ReactNative/AppRegistry' ); jest.mock( '../setup-locale' ); +jest.mock( '@wordpress/block-library', () => ( { + __esModule: true, + registerCoreBlocks: jest.fn(), + NEW_BLOCK_TYPES: {}, +} ) ); const getEditorComponent = ( registerParams ) => { let EditorComponent; @@ -150,50 +157,55 @@ describe( 'Register Gutenberg', () => { expect( hookCallOrder ).toBeLessThan( onRenderEditorCallOrder ); } ); - it( 'dispatches "native.render" hook after the editor is rendered', () => { - const doAction = jest.spyOn( wpHooks, 'doAction' ); - + it( 'dispatches "native.post-register-core-blocks" hook after core blocks are registered', async () => { // An empty component is provided in order to listen for render calls of the editor component. const onRenderEditor = jest.fn(); const MockEditor = () => { onRenderEditor(); return null; }; - jest.mock( '../setup', () => ( { - __esModule: true, - default: jest.fn().mockReturnValue( <MockEditor /> ), - } ) ); + + // Unmock setup module to render the above mocked editor component. + jest.unmock( '../setup' ); + + // The mocked editor component is provided via `initializeEditor` function of + // `@wordpress/edit-post` package, instead of via the setup as above test cases. + const initializeEditorMock = jest + .spyOn( wpEditPost, 'initializeEditor' ) + .mockReturnValue( <MockEditor /> ); + + // Listen to WP hook + const callback = jest.fn(); + wpHooks.addAction( + 'native.post-register-core-blocks', + 'test', + callback + ); const EditorComponent = getEditorComponent(); - // Modules are isolated upon editor rendering in order to guarantee that the setup module is imported on every test. - jest.isolateModules( () => render( <EditorComponent /> ) ); + render( <EditorComponent /> ); - const hookCallIndex = 1; // "invocationCallOrder" can be used to compare call orders between different mocks. // Reference: https://github.com/facebook/jest/issues/4402#issuecomment-534516219 - const hookCallOrder = - doAction.mock.invocationCallOrder[ hookCallIndex ]; + const callbackCallOrder = callback.mock.invocationCallOrder[ 0 ]; + const registerCoreBlocksCallOrder = + registerCoreBlocks.mock.invocationCallOrder[ 0 ]; const onRenderEditorCallOrder = onRenderEditor.mock.invocationCallOrder[ 0 ]; - const hookName = doAction.mock.calls[ hookCallIndex ][ 0 ]; - expect( hookName ).toBe( 'native.render' ); - expect( hookCallOrder ).toBeGreaterThan( onRenderEditorCallOrder ); + expect( callbackCallOrder ).toBeGreaterThan( + registerCoreBlocksCallOrder + ); + expect( callbackCallOrder ).toBeLessThan( onRenderEditorCallOrder ); + + initializeEditorMock.mockRestore(); } ); it( 'initializes the editor', async () => { - // Unmock setup module to render the actual editor component. - jest.unmock( '../setup' ); - - const EditorComponent = getEditorComponent(); - const screen = await initializeEditor( - {}, - { component: EditorComponent } - ); + const screen = await initializeEditor(); // Inner blocks create BlockLists so let's take into account selecting the main one const blockList = screen.getAllByTestId( 'block-list-wrapper' )[ 0 ]; expect( blockList ).toBeVisible(); - expect( console ).toHaveLoggedWith( 'Hermes is: true' ); } ); } ); diff --git a/packages/readable-js-assets-webpack-plugin/CHANGELOG.md b/packages/readable-js-assets-webpack-plugin/CHANGELOG.md index cbe462f12524df..d762a1ee4aff27 100644 --- a/packages/readable-js-assets-webpack-plugin/CHANGELOG.md +++ b/packages/readable-js-assets-webpack-plugin/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.23.0 (2023-08-16) + +## 2.22.0 (2023-08-10) + +## 2.21.0 (2023-07-20) + +## 2.20.0 (2023-07-05) + +## 2.19.0 (2023-06-23) + +## 2.18.0 (2023-06-07) + ## 2.17.0 (2023-05-24) ## 2.16.0 (2023-05-10) diff --git a/packages/readable-js-assets-webpack-plugin/package.json b/packages/readable-js-assets-webpack-plugin/package.json index 00634900e11606..7099596310c4d8 100644 --- a/packages/readable-js-assets-webpack-plugin/package.json +++ b/packages/readable-js-assets-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/readable-js-assets-webpack-plugin", - "version": "2.17.0", + "version": "2.23.0", "description": "Generate a readable JS file for each JS asset.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/readable-js-assets-webpack-plugin/test/build.js b/packages/readable-js-assets-webpack-plugin/test/build.js index 00ef7beb7c6250..0b146db55d6c47 100644 --- a/packages/readable-js-assets-webpack-plugin/test/build.js +++ b/packages/readable-js-assets-webpack-plugin/test/build.js @@ -3,7 +3,7 @@ */ const fs = require( 'fs' ); const glob = require( 'glob' ).sync; -const mkdirp = require( 'mkdirp' ).sync; +const mkdirp = require( 'mkdirp' ).mkdirp.sync; const path = require( 'path' ); const rimraf = require( 'rimraf' ).sync; const webpack = require( 'webpack' ); diff --git a/packages/redux-routine/CHANGELOG.md b/packages/redux-routine/CHANGELOG.md index d202dfa64f1ac9..050b18e2d0fa4b 100644 --- a/packages/redux-routine/CHANGELOG.md +++ b/packages/redux-routine/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.40.0 (2023-08-16) + +## 4.39.0 (2023-08-10) + +## 4.38.0 (2023-07-20) + +## 4.37.0 (2023-07-05) + +## 4.36.0 (2023-06-23) + +## 4.35.0 (2023-06-07) + ## 4.34.0 (2023-05-24) ## 4.33.0 (2023-05-10) diff --git a/packages/redux-routine/package.json b/packages/redux-routine/package.json index b74010dfb1cf5b..66e8d2406cbad7 100644 --- a/packages/redux-routine/package.json +++ b/packages/redux-routine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/redux-routine", - "version": "4.34.0", + "version": "4.40.0", "description": "Redux middleware for generator coroutines.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/redux-routine/src/runtime.ts b/packages/redux-routine/src/runtime.ts index 8710bc0236f159..741a942fa01885 100644 --- a/packages/redux-routine/src/runtime.ts +++ b/packages/redux-routine/src/runtime.ts @@ -1,7 +1,8 @@ /** * External dependencies */ -import { create, Control } from 'rungen'; +import type { Control } from 'rungen'; +import { create } from 'rungen'; import isPromise from 'is-promise'; import type { Dispatch, AnyAction } from 'redux'; diff --git a/packages/report-flaky-tests/package.json b/packages/report-flaky-tests/package.json index 1daf24312eb696..8766762fcc1b70 100644 --- a/packages/report-flaky-tests/package.json +++ b/packages/report-flaky-tests/package.json @@ -28,7 +28,7 @@ "dependencies": { "@actions/core": "^1.8.0", "@actions/github": "^5.0.1", - "jest-message-util": "^29.5.0" + "jest-message-util": "^29.6.2" }, "publishConfig": { "access": "public" diff --git a/packages/report-flaky-tests/src/__tests__/markdown.test.ts b/packages/report-flaky-tests/src/__tests__/markdown.test.ts index a4a1727029a787..4a0fa73f354924 100644 --- a/packages/report-flaky-tests/src/__tests__/markdown.test.ts +++ b/packages/report-flaky-tests/src/__tests__/markdown.test.ts @@ -15,7 +15,7 @@ import { renderCommitComment, isReportComment, } from '../markdown'; -import { ReportedIssue } from '../types'; +import type { ReportedIssue } from '../types'; jest.useFakeTimers( 'modern' ).setSystemTime( new Date( '2020-05-10' ) ); diff --git a/packages/reusable-blocks/CHANGELOG.md b/packages/reusable-blocks/CHANGELOG.md index 5e9bb185dc650d..1960e2b9987c07 100644 --- a/packages/reusable-blocks/CHANGELOG.md +++ b/packages/reusable-blocks/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.17.0 (2023-08-16) + +## 4.16.0 (2023-08-10) + +## 4.15.0 (2023-07-20) + +## 4.14.0 (2023-07-05) + +## 4.13.0 (2023-06-23) + +## 4.12.0 (2023-06-07) + ## 4.11.0 (2023-05-24) ## 4.10.0 (2023-05-10) diff --git a/packages/reusable-blocks/package.json b/packages/reusable-blocks/package.json index 6d350e005c81f5..3ef73b83a49f20 100644 --- a/packages/reusable-blocks/package.json +++ b/packages/reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/reusable-blocks", - "version": "4.11.0", + "version": "4.17.0", "description": "Reusable blocks utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -28,6 +28,7 @@ "{src,build,build-module}/{index.js,store/index.js}" ], "dependencies": { + "@babel/runtime": "^7.16.0", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", "@wordpress/components": "file:../components", @@ -37,6 +38,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", + "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url" }, "peerDependencies": { diff --git a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/index.js b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/index.js index 3635c0d1c3e814..e0a7c5df4782ca 100644 --- a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/index.js +++ b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/index.js @@ -1,8 +1,7 @@ /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; +import { BlockSettingsMenuControls } from '@wordpress/block-editor'; /** * Internal dependencies @@ -10,23 +9,23 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import ReusableBlockConvertButton from './reusable-block-convert-button'; import ReusableBlocksManageButton from './reusable-blocks-manage-button'; -function ReusableBlocksMenuItems( { clientIds, rootClientId } ) { +export default function ReusableBlocksMenuItems( { rootClientId } ) { return ( - <> - <ReusableBlockConvertButton - clientIds={ clientIds } - rootClientId={ rootClientId } - /> - { clientIds.length === 1 && ( - <ReusableBlocksManageButton clientId={ clientIds[ 0 ] } /> + <BlockSettingsMenuControls> + { ( { onClose, selectedClientIds } ) => ( + <> + <ReusableBlockConvertButton + clientIds={ selectedClientIds } + rootClientId={ rootClientId } + onClose={ onClose } + /> + { selectedClientIds.length === 1 && ( + <ReusableBlocksManageButton + clientId={ selectedClientIds[ 0 ] } + /> + ) } + </> ) } - </> + </BlockSettingsMenuControls> ); } - -export default withSelect( ( select ) => { - const { getSelectedBlockClientIds } = select( blockEditorStore ); - return { - clientIds: getSelectedBlockClientIds(), - }; -} )( ReusableBlocksMenuItems ); diff --git a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js index 18824b892544a8..de29a930e89f18 100644 --- a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js +++ b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js @@ -3,8 +3,8 @@ */ import { hasBlockSupport, isReusableBlock } from '@wordpress/blocks'; import { - BlockSettingsMenuControls, store as blockEditorStore, + privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { useCallback, useState } from '@wordpress/element'; import { @@ -14,10 +14,11 @@ import { TextControl, __experimentalHStack as HStack, __experimentalVStack as VStack, + ToggleControl, } from '@wordpress/components'; import { symbol } from '@wordpress/icons'; import { useDispatch, useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; @@ -25,6 +26,7 @@ import { store as coreStore } from '@wordpress/core-data'; * Internal dependencies */ import { store } from '../../store'; +import { unlock } from '../../lock-unlock'; /** * Menu control to convert block(s) to reusable block. @@ -32,19 +34,35 @@ import { store } from '../../store'; * @param {Object} props Component props. * @param {string[]} props.clientIds Client ids of selected blocks. * @param {string} props.rootClientId ID of the currently selected top-level block. + * @param {()=>void} props.onClose Callback to close the menu. * @return {import('@wordpress/element').WPComponent} The menu control or null. */ export default function ReusableBlockConvertButton( { clientIds, rootClientId, + onClose, } ) { + const { useReusableBlocksRenameHint, ReusableBlocksRenameHint } = unlock( + blockEditorPrivateApis + ); + const showRenameHint = useReusableBlocksRenameHint(); + const [ syncType, setSyncType ] = useState( undefined ); const [ isModalOpen, setIsModalOpen ] = useState( false ); const [ title, setTitle ] = useState( '' ); const canConvert = useSelect( ( select ) => { const { canUser } = select( coreStore ); - const { getBlocksByClientId, canInsertBlockType } = - select( blockEditorStore ); + const { + getBlocksByClientId, + canInsertBlockType, + getBlockRootClientId, + } = select( blockEditorStore ); + + const rootId = + rootClientId || + ( clientIds.length > 0 + ? getBlockRootClientId( clientIds[ 0 ] ) + : undefined ); const blocks = getBlocksByClientId( clientIds ) ?? []; @@ -62,7 +80,7 @@ export default function ReusableBlockConvertButton( { // Hide when this is already a reusable block. ! isReusable && // Hide when reusable blocks are disabled. - canInsertBlockType( 'core/block', rootClientId ) && + canInsertBlockType( 'core/block', rootId ) && blocks.every( ( block ) => // Guard against the case where a regular block has *just* been converted. @@ -77,7 +95,7 @@ export default function ReusableBlockConvertButton( { return _canConvert; }, - [ clientIds ] + [ clientIds, rootClientId ] ); const { __experimentalConvertBlocksToReusable: convertBlocksToReusable } = @@ -88,17 +106,42 @@ export default function ReusableBlockConvertButton( { const onConvert = useCallback( async function ( reusableBlockTitle ) { try { - await convertBlocksToReusable( clientIds, reusableBlockTitle ); - createSuccessNotice( __( 'Reusable block created.' ), { - type: 'snackbar', - } ); + await convertBlocksToReusable( + clientIds, + reusableBlockTitle, + syncType + ); + createSuccessNotice( + ! syncType + ? sprintf( + // translators: %s: the name the user has given to the pattern. + __( 'Synced Pattern created: %s' ), + reusableBlockTitle + ) + : sprintf( + // translators: %s: the name the user has given to the pattern. + __( 'Unsynced Pattern created: %s' ), + reusableBlockTitle + ), + { + type: 'snackbar', + id: 'convert-to-reusable-block-success', + } + ); } catch ( error ) { createErrorNotice( error.message, { type: 'snackbar', + id: 'convert-to-reusable-block-error', } ); } }, - [ clientIds ] + [ + convertBlocksToReusable, + clientIds, + syncType, + createSuccessNotice, + createErrorNotice, + ] ); if ( ! canConvert ) { @@ -106,63 +149,71 @@ export default function ReusableBlockConvertButton( { } return ( - <BlockSettingsMenuControls> - { ( { onClose } ) => ( - <> - <MenuItem - icon={ symbol } - onClick={ () => { - setIsModalOpen( true ); + <> + <MenuItem icon={ symbol } onClick={ () => setIsModalOpen( true ) }> + { showRenameHint + ? __( 'Create pattern/reusable block' ) + : __( 'Create pattern' ) } + </MenuItem> + { isModalOpen && ( + <Modal + title={ __( 'Create pattern' ) } + onRequestClose={ () => { + setIsModalOpen( false ); + setTitle( '' ); + } } + overlayClassName="reusable-blocks-menu-items__convert-modal" + > + <form + onSubmit={ ( event ) => { + event.preventDefault(); + onConvert( title ); + setIsModalOpen( false ); + setTitle( '' ); + onClose(); } } > - { __( 'Create Reusable block' ) } - </MenuItem> - { isModalOpen && ( - <Modal - title={ __( 'Create Reusable block' ) } - onRequestClose={ () => { - setIsModalOpen( false ); - setTitle( '' ); - } } - overlayClassName="reusable-blocks-menu-items__convert-modal" - > - <form - onSubmit={ ( event ) => { - event.preventDefault(); - onConvert( title ); - setIsModalOpen( false ); - setTitle( '' ); - onClose(); + <VStack spacing="5"> + <ReusableBlocksRenameHint /> + <TextControl + __nextHasNoMarginBottom + label={ __( 'Name' ) } + value={ title } + onChange={ setTitle } + placeholder={ __( 'My pattern' ) } + /> + + <ToggleControl + label={ __( 'Synced' ) } + help={ __( + 'Editing the pattern will update it anywhere it is used.' + ) } + checked={ ! syncType } + onChange={ () => { + setSyncType( + ! syncType ? 'unsynced' : undefined + ); } } - > - <VStack spacing="5"> - <TextControl - __nextHasNoMarginBottom - label={ __( 'Name' ) } - value={ title } - onChange={ setTitle } - /> - <HStack justify="right"> - <Button - variant="tertiary" - onClick={ () => { - setIsModalOpen( false ); - setTitle( '' ); - } } - > - { __( 'Cancel' ) } - </Button> + /> + <HStack justify="right"> + <Button + variant="tertiary" + onClick={ () => { + setIsModalOpen( false ); + setTitle( '' ); + } } + > + { __( 'Cancel' ) } + </Button> - <Button variant="primary" type="submit"> - { __( 'Save' ) } - </Button> - </HStack> - </VStack> - </form> - </Modal> - ) } - </> + <Button variant="primary" type="submit"> + { __( 'Create' ) } + </Button> + </HStack> + </VStack> + </form> + </Modal> ) } - </BlockSettingsMenuControls> + </> ); } diff --git a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js index 665397df3d0109..6ca19269d40f64 100644 --- a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js +++ b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js @@ -5,10 +5,7 @@ import { MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { isReusableBlock } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; -import { - BlockSettingsMenuControls, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; import { addQueryArgs } from '@wordpress/url'; import { store as coreStore } from '@wordpress/core-data'; @@ -18,28 +15,41 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as reusableBlocksStore } from '../../store'; function ReusableBlocksManageButton( { clientId } ) { - const { canRemove, isVisible, innerBlockCount } = useSelect( - ( select ) => { - const { getBlock, canRemoveBlock, getBlockCount } = - select( blockEditorStore ); - const { canUser } = select( coreStore ); - const reusableBlock = getBlock( clientId ); + const { canRemove, isVisible, innerBlockCount, managePatternsUrl } = + useSelect( + ( select ) => { + const { getBlock, canRemoveBlock, getBlockCount, getSettings } = + select( blockEditorStore ); + const { canUser } = select( coreStore ); + const reusableBlock = getBlock( clientId ); + const isBlockTheme = getSettings().__unstableIsBlockBasedTheme; - return { - canRemove: canRemoveBlock( clientId ), - isVisible: - !! reusableBlock && - isReusableBlock( reusableBlock ) && - !! canUser( - 'update', - 'blocks', - reusableBlock.attributes.ref - ), - innerBlockCount: getBlockCount( clientId ), - }; - }, - [ clientId ] - ); + return { + canRemove: canRemoveBlock( clientId ), + isVisible: + !! reusableBlock && + isReusableBlock( reusableBlock ) && + !! canUser( + 'update', + 'blocks', + reusableBlock.attributes.ref + ), + innerBlockCount: getBlockCount( clientId ), + // The site editor and templates both check whether the user + // has edit_theme_options capabilities. We can leverage that here + // and omit the manage patterns link if the user can't access it. + managePatternsUrl: + isBlockTheme && canUser( 'read', 'templates' ) + ? addQueryArgs( 'site-editor.php', { + path: '/patterns', + } ) + : addQueryArgs( 'edit.php', { + post_type: 'wp_block', + } ), + }; + }, + [ clientId ] + ); const { __experimentalConvertBlockToStatic: convertBlockToStatic } = useDispatch( reusableBlocksStore ); @@ -49,20 +59,18 @@ function ReusableBlocksManageButton( { clientId } ) { } return ( - <BlockSettingsMenuControls> - <MenuItem - href={ addQueryArgs( 'edit.php', { post_type: 'wp_block' } ) } - > - { __( 'Manage Reusable blocks' ) } + <> + <MenuItem href={ managePatternsUrl }> + { __( 'Manage patterns' ) } </MenuItem> { canRemove && ( <MenuItem onClick={ () => convertBlockToStatic( clientId ) }> { innerBlockCount > 1 - ? __( 'Convert to regular blocks' ) - : __( 'Convert to regular block' ) } + ? __( 'Detach patterns' ) + : __( 'Detach pattern' ) } </MenuItem> ) } - </BlockSettingsMenuControls> + </> ); } diff --git a/packages/reusable-blocks/src/lock-unlock.js b/packages/reusable-blocks/src/lock-unlock.js new file mode 100644 index 00000000000000..c33f209c9d76ae --- /dev/null +++ b/packages/reusable-blocks/src/lock-unlock.js @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/reusable-blocks' +); diff --git a/packages/reusable-blocks/src/store/actions.js b/packages/reusable-blocks/src/store/actions.js index b039c2e3883de1..292a3082146aaa 100644 --- a/packages/reusable-blocks/src/store/actions.js +++ b/packages/reusable-blocks/src/store/actions.js @@ -40,28 +40,41 @@ export const __experimentalConvertBlockToStatic = }; /** - * Returns a generator converting one or more static blocks into a reusable block. + * Returns a generator converting one or more static blocks into a pattern. * - * @param {string[]} clientIds The client IDs of the block to detach. - * @param {string} title Reusable block title. + * @param {string[]} clientIds The client IDs of the block to detach. + * @param {string} title Pattern title. + * @param {undefined|'unsynced'} syncType They way block is synced, current undefined (synced) and 'unsynced'. */ export const __experimentalConvertBlocksToReusable = - ( clientIds, title ) => + ( clientIds, title, syncType ) => async ( { registry, dispatch } ) => { + const meta = + syncType === 'unsynced' + ? { + wp_pattern_sync_status: syncType, + } + : undefined; + const reusableBlock = { - title: title || __( 'Untitled Reusable block' ), + title: title || __( 'Untitled Pattern block' ), content: serialize( registry .select( blockEditorStore ) .getBlocksByClientId( clientIds ) ), status: 'publish', + meta, }; const updatedRecord = await registry .dispatch( 'core' ) .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); + if ( syncType === 'unsynced' ) { + return; + } + const newBlock = createBlock( 'core/block', { ref: updatedRecord.id, } ); diff --git a/packages/rich-text/CHANGELOG.md b/packages/rich-text/CHANGELOG.md index 665311a658370b..db497e6bc6f40c 100644 --- a/packages/rich-text/CHANGELOG.md +++ b/packages/rich-text/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 6.17.0 (2023-08-16) + +## 6.16.0 (2023-08-10) + +## 6.15.0 (2023-07-20) + +## 6.14.0 (2023-07-05) + +## 6.13.0 (2023-06-23) + +## 6.12.0 (2023-06-07) + ## 6.11.0 (2023-05-24) ## 6.10.0 (2023-05-10) diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 4ac606dc19fef7..6a92d30e008b38 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/rich-text", - "version": "6.11.0", + "version": "6.17.0", "description": "Rich text value and manipulation API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 8e8d1fa8f1ffeb..2b437e4436777b 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -99,6 +99,7 @@ export function useRichText( { const hadSelectionUpdate = useRef( false ); if ( ! record.current ) { + hadSelectionUpdate.current = isSelected; setRecordFromProps(); // Sometimes formats are added programmatically and we need to make // sure it's persisted to the block store / markup. If these formats @@ -124,6 +125,7 @@ export function useRichText( { ...record.current, start: selectionStart, end: selectionEnd, + activeFormats: undefined, }; } @@ -217,7 +219,7 @@ export function useRichText( { ref.current.focus(); } - applyFromProps(); + applyRecord( record.current ); hadSelectionUpdate.current = false; }, [ hadSelectionUpdate.current ] ); diff --git a/packages/rich-text/src/component/index.native.js b/packages/rich-text/src/component/index.native.js index 2d48d1263cb61a..14fd806d95b3a6 100644 --- a/packages/rich-text/src/component/index.native.js +++ b/packages/rich-text/src/component/index.native.js @@ -63,6 +63,35 @@ const flatColorPalettes = memize( ( colorsPalettes ) => [ ...( colorsPalettes?.default || [] ), ] ); +const getSelectionColor = memize( + ( + currentSelectionColor, + defaultSelectionColor, + baseGlobalStyles, + isBlockBasedTheme + ) => { + let selectionColor = defaultSelectionColor; + if ( currentSelectionColor ) { + selectionColor = currentSelectionColor; + } + + if ( isBlockBasedTheme ) { + const colordTextColor = colord( selectionColor ); + const colordBackgroundColor = colord( + baseGlobalStyles?.color?.background + ); + const isColordTextReadable = colordTextColor.isReadable( + colordBackgroundColor + ); + if ( ! isColordTextReadable ) { + selectionColor = baseGlobalStyles?.color?.text; + } + } + + return selectionColor; + } +); + const gutenbergFormatNamesToAztec = { 'core/bold': 'bold', 'core/italic': 'italic', @@ -1154,6 +1183,17 @@ export class RichText extends Component { }, ]; + const defaultSelectionColor = getStylesFromColorScheme( + styles[ 'rich-text-selection' ], + styles[ 'rich-text-selection--dark' ] + ).color; + const selectionColor = getSelectionColor( + this.props.selectionColor, + defaultSelectionColor, + baseGlobalStyles, + this.getIsBlockBasedTheme() + ); + const EditableView = ( props ) => { this.customEditableOnKeyDown = props?.onKeyDown; @@ -1238,7 +1278,7 @@ export class RichText extends Component { { ...( this.isIOS ? { maxWidth } : {} ) } minWidth={ minWidth } id={ this.props.id } - selectionColor={ this.props.selectionColor } + selectionColor={ selectionColor } disableAutocorrection={ this.props.disableAutocorrection } /> { isSelected && ( diff --git a/packages/rich-text/src/component/style.native.scss b/packages/rich-text/src/component/style.native.scss index 7a885c83063bba..3ba3be6a7150b6 100644 --- a/packages/rich-text/src/component/style.native.scss +++ b/packages/rich-text/src/component/style.native.scss @@ -12,9 +12,17 @@ } .richTextPlaceholder { - color: $gray; + color: $gray-20; } .richTextPlaceholderDark { color: $gray-50; } + +.rich-text-selection { + color: $black; +} + +.rich-text-selection--dark { + color: $white; +} diff --git a/packages/rich-text/src/component/use-anchor.js b/packages/rich-text/src/component/use-anchor.js index aa803df7c76660..5cc62a2de4ffdd 100644 --- a/packages/rich-text/src/component/use-anchor.js +++ b/packages/rich-text/src/component/use-anchor.js @@ -51,7 +51,7 @@ function getFormatElement( range, editableContentElement, tagName, className ) { /** * @typedef {Object} VirtualAnchorElement * @property {() => DOMRect} getBoundingClientRect A function returning a DOMRect - * @property {Document} ownerDocument The element's ownerDocument + * @property {HTMLElement} contextElement The actual DOM element */ /** @@ -64,7 +64,7 @@ function getFormatElement( range, editableContentElement, tagName, className ) { */ function createVirtualAnchorElement( range, editableContentElement ) { return { - ownerDocument: range.startContainer.ownerDocument, + contextElement: editableContentElement, getBoundingClientRect() { return editableContentElement.contains( range.startContainer ) ? range.getBoundingClientRect() diff --git a/packages/rich-text/src/component/use-boundary-style.js b/packages/rich-text/src/component/use-boundary-style.js index 214fd6862c1733..9f29b24e3ecc6b 100644 --- a/packages/rich-text/src/component/use-boundary-style.js +++ b/packages/rich-text/src/component/use-boundary-style.js @@ -9,11 +9,15 @@ import { useEffect, useRef } from '@wordpress/element'; */ export function useBoundaryStyle( { record } ) { const ref = useRef(); - const { activeFormats = [] } = record.current; + const { activeFormats = [], replacements, start } = record.current; + const activeReplacement = replacements[ start ]; useEffect( () => { // There's no need to recalculate the boundary styles if no formats are // active, because no boundary styles will be visible. - if ( ! activeFormats || ! activeFormats.length ) { + if ( + ( ! activeFormats || ! activeFormats.length ) && + ! activeReplacement + ) { return; } @@ -46,6 +50,6 @@ export function useBoundaryStyle( { record } ) { if ( globalStyle.innerHTML !== style ) { globalStyle.innerHTML = style; } - }, [ activeFormats ] ); + }, [ activeFormats, activeReplacement ] ); return ref; } diff --git a/packages/rich-text/src/component/use-copy-handler.js b/packages/rich-text/src/component/use-copy-handler.js index 6093b615a77277..b25e64428a1b4d 100644 --- a/packages/rich-text/src/component/use-copy-handler.js +++ b/packages/rich-text/src/component/use-copy-handler.js @@ -19,33 +19,43 @@ export function useCopyHandler( props ) { function onCopy( event ) { const { record, multilineTag, preserveWhiteSpace } = propsRef.current; + const { ownerDocument } = element; if ( isCollapsed( record.current ) || - ! element.contains( element.ownerDocument.activeElement ) + ! element.contains( ownerDocument.activeElement ) ) { return; } const selectedRecord = slice( record.current ); const plainText = getTextContent( selectedRecord ); - const html = toHTMLString( { + const tagName = element.tagName.toLowerCase(); + + let html = toHTMLString( { value: selectedRecord, multilineTag, preserveWhiteSpace, } ); + + if ( tagName && tagName !== 'span' && tagName !== 'div' ) { + html = `<${ tagName }>${ html }</${ tagName }>`; + } + event.clipboardData.setData( 'text/plain', plainText ); event.clipboardData.setData( 'text/html', html ); event.clipboardData.setData( 'rich-text', 'true' ); - event.clipboardData.setData( - 'rich-text-multi-line-tag', - multilineTag || '' - ); event.preventDefault(); + + if ( event.type === 'cut' ) { + ownerDocument.execCommand( 'delete' ); + } } element.addEventListener( 'copy', onCopy ); + element.addEventListener( 'cut', onCopy ); return () => { element.removeEventListener( 'copy', onCopy ); + element.removeEventListener( 'cut', onCopy ); }; }, [] ); } diff --git a/packages/rich-text/src/component/use-select-object.js b/packages/rich-text/src/component/use-select-object.js index 0866815be15758..e5db313494f488 100644 --- a/packages/rich-text/src/component/use-select-object.js +++ b/packages/rich-text/src/component/use-select-object.js @@ -9,23 +9,51 @@ export function useSelectObject() { const { target } = event; // If the child element has no text content, it must be an object. - if ( target === element || target.textContent ) { + if ( + target === element || + ( target.textContent && target.isContentEditable ) + ) { return; } const { ownerDocument } = target; const { defaultView } = ownerDocument; - const range = ownerDocument.createRange(); const selection = defaultView.getSelection(); - range.selectNode( target ); + // If it's already selected, do nothing and let default behavior + // happen. This means it's "click-through". + if ( selection.containsNode( target ) ) return; + + const range = ownerDocument.createRange(); + // If the target is within a non editable element, select the non + // editable element. + const nodeToSelect = target.isContentEditable + ? target + : target.closest( '[contenteditable]' ); + + range.selectNode( nodeToSelect ); selection.removeAllRanges(); selection.addRange( range ); + + event.preventDefault(); + } + + function onFocusIn( event ) { + // When there is incoming focus from a link, select the object. + if ( + event.relatedTarget && + ! element.contains( event.relatedTarget ) && + event.relatedTarget.tagName === 'A' + ) { + onClick( event ); + } } element.addEventListener( 'click', onClick ); + element.addEventListener( 'focusin', onFocusIn ); return () => { element.removeEventListener( 'click', onClick ); + element.removeEventListener( 'focusin', onFocusIn ); }; }, [] ); } diff --git a/packages/rich-text/src/component/use-selection-change-compat.js b/packages/rich-text/src/component/use-selection-change-compat.js index 7a684f584263e4..d067d5ec70ff7f 100644 --- a/packages/rich-text/src/component/use-selection-change-compat.js +++ b/packages/rich-text/src/component/use-selection-change-compat.js @@ -21,7 +21,7 @@ export function useSelectionChangeCompat() { return useRefEffect( ( element ) => { const { ownerDocument } = element; const { defaultView } = ownerDocument; - const selection = defaultView.getSelection(); + const selection = defaultView?.getSelection(); let range; diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 863e6b984cc794..fa2befc603b7e4 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -62,7 +62,7 @@ function toFormat( { tagName, attributes } ) { } if ( ! attributes ) { - return { type: formatType.name, tagName }; + return { formatType, type: formatType.name, tagName }; } const registeredAttributes = {}; @@ -95,7 +95,12 @@ function toFormat( { tagName, attributes } ) { unregisteredAttributes[ name ] = attributes[ name ]; } + if ( formatType.contentEditable === false ) { + delete unregisteredAttributes.contenteditable; + } + return { + formatType, type: formatType.name, tagName, attributes: registeredAttributes, @@ -419,6 +424,26 @@ function createFromElement( { attributes: getAttributes( { element: node } ), } ); + // When a format type is declared as not editable, replace it with an + // object replacement character and preserve the inner HTML. + if ( format?.formatType?.contentEditable === false ) { + delete format.formatType; + accumulateSelection( accumulator, node, range, createEmptyValue() ); + mergePair( accumulator, { + formats: [ , ], + replacements: [ + { + ...format, + innerHTML: node.innerHTML, + }, + ], + text: OBJECT_REPLACEMENT_CHARACTER, + } ); + continue; + } + + if ( format ) delete format.formatType; + if ( multilineWrapperTags && multilineWrapperTags.indexOf( tagName ) !== -1 diff --git a/packages/rich-text/src/register-format-type.js b/packages/rich-text/src/register-format-type.js index 8ea19a97f595ff..b2dd048d79e6fb 100644 --- a/packages/rich-text/src/register-format-type.js +++ b/packages/rich-text/src/register-format-type.js @@ -9,14 +9,15 @@ import { store as richTextStore } from './store'; /** * @typedef {Object} WPFormat * - * @property {string} name A string identifying the format. Must be - * unique across all registered formats. - * @property {string} tagName The HTML tag this format will wrap the - * selection with. - * @property {string} [className] A class to match the format. - * @property {string} title Name of the format. - * @property {Function} edit Should return a component for the user to - * interact with the new registered format. + * @property {string} name A string identifying the format. Must be + * unique across all registered formats. + * @property {string} tagName The HTML tag this format will wrap the + * selection with. + * @property {boolean} interactive Whether format makes content interactive or not. + * @property {string | null} [className] A class to match the format. + * @property {string} title Name of the format. + * @property {Function} edit Should return a component for the user to + * interact with the new registered format. */ /** diff --git a/packages/rich-text/src/store/actions.js b/packages/rich-text/src/store/actions.js index b8abea4d0aa29b..4fa522f5ec6e3c 100644 --- a/packages/rich-text/src/store/actions.js +++ b/packages/rich-text/src/store/actions.js @@ -1,6 +1,9 @@ /** * Returns an action object used in signalling that format types have been * added. + * Ignored from documentation as registerFormatType should be used instead from @wordpress/rich-text + * + * @ignore * * @param {Array|Object} formatTypes Format types received. * @@ -18,6 +21,10 @@ export function addFormatTypes( formatTypes ) { /** * Returns an action object used to remove a registered format type. * + * Ignored from documentation as unregisterFormatType should be used instead from @wordpress/rich-text + * + * @ignore + * * @param {string|Array} names Format name. * * @return {Object} Action object. diff --git a/packages/rich-text/src/store/selectors.js b/packages/rich-text/src/store/selectors.js index 52aa4dead99bf7..cdc597aee0d990 100644 --- a/packages/rich-text/src/store/selectors.js +++ b/packages/rich-text/src/store/selectors.js @@ -8,6 +8,32 @@ import createSelector from 'rememo'; * * @param {Object} state Data state. * + * @example + * ```js + * import { __, sprintf } from '@wordpress/i18n'; + * import { store as richTextStore } from '@wordpress/rich-text'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const { getFormatTypes } = useSelect( + * ( select ) => select( richTextStore ), + * [] + * ); + * + * const availableFormats = getFormatTypes(); + * + * return availableFormats ? ( + * <ul> + * { availableFormats?.map( ( format ) => ( + * <li>{ format.name }</li> + * ) ) } + * </ul> + * ) : ( + * __( 'No Formats available' ) + * ); + * }; + * ``` + * * @return {Array} Format types. */ export const getFormatTypes = createSelector( @@ -21,6 +47,34 @@ export const getFormatTypes = createSelector( * @param {Object} state Data state. * @param {string} name Format type name. * + * @example + * ```js + * import { __, sprintf } from '@wordpress/i18n'; + * import { store as richTextStore } from '@wordpress/rich-text'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const { getFormatType } = useSelect( + * ( select ) => select( richTextStore ), + * [] + * ); + * + * const boldFormat = getFormatType( 'core/bold' ); + * + * return boldFormat ? ( + * <ul> + * { Object.entries( boldFormat )?.map( ( [ key, value ] ) => ( + * <li> + * { key } : { value } + * </li> + * ) ) } + * </ul> + * ) : ( + * __( 'Not Found' ) + * ; + * }; + * ``` + * * @return {Object?} Format type. */ export function getFormatType( state, name ) { @@ -34,6 +88,25 @@ export function getFormatType( state, name ) { * @param {Object} state Data state. * @param {string} bareElementTagName The tag name of the element to find a * format type for. + * + * @example + * ```js + * import { __, sprintf } from '@wordpress/i18n'; + * import { store as richTextStore } from '@wordpress/rich-text'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const { getFormatTypeForBareElement } = useSelect( + * ( select ) => select( richTextStore ), + * [] + * ); + * + * const format = getFormatTypeForBareElement( 'strong' ); + * + * return format && <p>{ sprintf( __( 'Format name: %s' ), format.name ) }</p>; + * } + * ``` + * * @return {?Object} Format type. */ export function getFormatTypeForBareElement( state, bareElementTagName ) { @@ -54,6 +127,25 @@ export function getFormatTypeForBareElement( state, bareElementTagName ) { * @param {Object} state Data state. * @param {string} elementClassName The classes of the element to find a format * type for. + * + * @example + * ```js + * import { __, sprintf } from '@wordpress/i18n'; + * import { store as richTextStore } from '@wordpress/rich-text'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const { getFormatTypeForClassName } = useSelect( + * ( select ) => select( richTextStore ), + * [] + * ); + * + * const format = getFormatTypeForClassName( 'has-inline-color' ); + * + * return format && <p>{ sprintf( __( 'Format name: %s' ), format.name ) }</p>; + * }; + * ``` + * * @return {?Object} Format type. */ export function getFormatTypeForClassName( state, elementClassName ) { diff --git a/packages/rich-text/src/test/helpers/index.js b/packages/rich-text/src/test/helpers/index.js index 15aa032978c66b..ae7521e55e25bf 100644 --- a/packages/rich-text/src/test/helpers/index.js +++ b/packages/rich-text/src/test/helpers/index.js @@ -912,4 +912,29 @@ export const specWithRegistration = [ text: 'a', }, }, + { + description: 'should be non editable', + formatName: 'my-plugin/non-editable', + formatType: { + title: 'Non Editable', + tagName: 'a', + className: 'non-editable', + contentEditable: false, + edit() {}, + }, + html: '<a class="non-editable">a</a>', + value: { + formats: [ , ], + replacements: [ + { + type: 'my-plugin/non-editable', + tagName: 'a', + attributes: {}, + unregisteredAttributes: {}, + innerHTML: 'a', + }, + ], + text: OBJECT_REPLACEMENT_CHARACTER, + }, + }, ]; diff --git a/packages/rich-text/src/test/index.native.js b/packages/rich-text/src/test/index.native.js index 3f963fba32dc06..e0ce7ff78d6ccf 100644 --- a/packages/rich-text/src/test/index.native.js +++ b/packages/rich-text/src/test/index.native.js @@ -15,7 +15,6 @@ import { setDefaultBlockName, unregisterBlockType, } from '@wordpress/blocks'; -import '@wordpress/jest-console'; /** * Internal dependencies diff --git a/packages/rich-text/src/to-dom.js b/packages/rich-text/src/to-dom.js index 828e3a4e3f6cb5..305eebaf3e4a6e 100644 --- a/packages/rich-text/src/to-dom.js +++ b/packages/rich-text/src/to-dom.js @@ -57,6 +57,10 @@ function getNodeByPath( node, path ) { } function append( element, child ) { + if ( child.html !== undefined ) { + return ( element.innerHTML += child.html ); + } + if ( typeof child === 'string' ) { child = element.ownerDocument.createTextNode( child ); } diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index 74cc08581e83c4..4db974aaad7142 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -60,7 +60,7 @@ function fromFormat( { let elementAttributes = {}; - if ( boundaryClass ) { + if ( boundaryClass && isEditableTree ) { elementAttributes[ 'data-rich-text-format-boundary' ] = 'true'; } @@ -101,8 +101,14 @@ function fromFormat( { } } + // When a format is declared as non editable, make it non editable in the + // editor. + if ( isEditableTree && formatType.contentEditable === false ) { + elementAttributes.contenteditable = 'false'; + } + return { - type: formatType.tagName === '*' ? tagName : formatType.tagName, + type: tagName || formatType.tagName, object: formatType.object, attributes: restoreOnAttributes( elementAttributes, isEditableTree ), }; @@ -291,7 +297,12 @@ export function toTree( { } if ( character === OBJECT_REPLACEMENT_CHARACTER ) { - if ( ! isEditableTree && replacements[ i ]?.type === 'script' ) { + const replacement = replacements[ i ]; + if ( ! replacement ) continue; + const { type, attributes, innerHTML } = replacement; + const formatType = getFormatType( type ); + + if ( ! isEditableTree && type === 'script' ) { pointer = append( getParent( pointer ), fromFormat( { @@ -301,14 +312,30 @@ export function toTree( { ); append( pointer, { html: decodeURIComponent( - replacements[ i ].attributes[ 'data-rich-text-script' ] + attributes[ 'data-rich-text-script' ] ), } ); + } else if ( formatType?.contentEditable === false ) { + // For non editable formats, render the stored inner HTML. + pointer = append( + getParent( pointer ), + fromFormat( { + ...replacement, + isEditableTree, + boundaryClass: start === i && end === i + 1, + } ) + ); + + if ( innerHTML ) { + append( pointer, { + html: innerHTML, + } ); + } } else { pointer = append( getParent( pointer ), fromFormat( { - ...replacements[ i ], + ...replacement, object: true, isEditableTree, } ) @@ -351,9 +378,7 @@ export function toTree( { attributes: { 'data-rich-text-placeholder': placeholder, // Necessary to prevent the placeholder from catching - // selection. The placeholder is also not editable after - // all. - contenteditable: 'false', + // selection and being editable. style: 'pointer-events:none;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;', }, } ); diff --git a/packages/rich-text/src/types.ts b/packages/rich-text/src/types.ts index 1e4725260c27e0..5ef595fcdb7615 100644 --- a/packages/rich-text/src/types.ts +++ b/packages/rich-text/src/types.ts @@ -1,5 +1,5 @@ /** - * Stores the type of a rich rext format, such as core/bold. + * Stores the type of a rich text format, such as core/bold. */ export type RichTextFormat = { type: @@ -26,6 +26,6 @@ export type RichTextValue = { text: string; formats: Array< RichTextFormatList >; replacements: Array< RichTextFormat >; - start: number | undefined; - end: number | undefined; + start: number; + end: number; }; diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md index 7e89c599c11958..228169a90570de 100644 --- a/packages/router/CHANGELOG.md +++ b/packages/router/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.9.0 (2023-08-16) + +## 0.8.0 (2023-08-10) + +## 0.7.0 (2023-07-20) + +## 0.6.0 (2023-07-05) + +## 0.5.0 (2023-06-23) + +## 0.4.0 (2023-06-07) + ## 0.3.0 (2023-05-24) ## 0.2.0 (2023-05-10) diff --git a/packages/router/package.json b/packages/router/package.json index 1e3e2b345c1351..2906fef9cd8a94 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/router", - "version": "0.3.0", + "version": "0.9.0", "description": "Router API for WordPress pages.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/router/src/lock-unlock.js b/packages/router/src/lock-unlock.js new file mode 100644 index 00000000000000..d148f785fe9442 --- /dev/null +++ b/packages/router/src/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/router' + ); diff --git a/packages/router/src/private-apis.js b/packages/router/src/private-apis.js index e3c465502369bb..7b2945a24ab1a1 100644 --- a/packages/router/src/private-apis.js +++ b/packages/router/src/private-apis.js @@ -1,18 +1,8 @@ -/** - * WordPress dependencies - */ -import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; - /** * Internal dependencies */ import { useHistory, useLocation, RouterProvider } from './router'; - -export const { lock, unlock } = - __dangerousOptInToUnstableAPIsOnlyForCoreModules( - 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', - '@wordpress/router' - ); +import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { diff --git a/packages/scripts/CHANGELOG.md b/packages/scripts/CHANGELOG.md index f255969c0865fe..48bd891edc2dd5 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -2,6 +2,37 @@ ## Unreleased +## 26.11.0 (2023-08-16) + +### Enhancement + +- Updated `npm-package-json-lint` peer dependency to require v6.0.0 [#53636](https://github.com/WordPress/gutenberg/pull/53636) +- The bundled `@svgr/webpack` dependency has been updated from requiring ^6.2.1 to requiring ^8.0.1 ([#53630](https://github.com/WordPress/gutenberg/pull/53630)). +- The bundled `cssnano` dependency has been updated from requiring ^5.07 to requiring ^6.0.1 ([#53630](https://github.com/WordPress/gutenberg/pull/53630)). + +### Bug Fix + +- Fix prevent watch mode from aborting when encountering a `block.json` file that contains invalid JSON. ([#51971](https://github.com/WordPress/gutenberg/pull/51971)) + +## 26.10.0 (2023-08-10) + +## 26.9.0 (2023-07-20) + +## 26.8.0 (2023-07-05) + +## 26.7.0 (2023-06-23) + +## 26.6.0 (2023-06-07) + +### Enhancements + +- The bundled `terser-webpack-plugin` dependency has been updated from requiring `^5.1.4` to requiring `^5.3.9` ([#50994](https://github.com/WordPress/gutenberg/pull/50994)). +- Optimize updating render paths when developing blocks with the `start` command ([#51162](https://github.com/WordPress/gutenberg/pull/51162)). + +### Bug Fixes + +- Ensure files listed in `render` field of `block.json` files are always copied to the build folder when using the `start` command ([#50939](https://github.com/WordPress/gutenberg/pull/50939)). + ## 26.5.0 (2023-05-24) ## 26.4.0 (2023-05-10) @@ -40,6 +71,7 @@ - The `WP_DEVTOOL` environment variable can now be used to set the Webpack devtool option for sourcemaps in production builds ([#46812](https://github.com/WordPress/gutenberg/pull/46812)). Previously, this only worked for development builds. - Update default webpack config and lint-style script to allow PostCSS (`.pcss` extension) file usage ([#45352](https://github.com/WordPress/gutenberg/pull/45352)). +- Add `--no-watch` option to allow creating the unminified/dev JS without starting the watcher ([#44237](https://github.com/WordPress/gutenberg/pull/44237)). ## 25.3.0 (2023-02-01) diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 96a65787c0d8b8..d34dab634a2b4f 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -382,6 +382,7 @@ This is how you execute the script with presented setup: This script automatically use the optimized config but sometimes you may want to specify some custom options: - `--hot` – enables "Fast Refresh". The page will automatically reload if you make changes to the code. _For now, it requires that WordPress has the [`SCRIPT_DEBUG`](https://wordpress.org/documentation/article/debugging-in-wordpress/#script_debug) flag enabled and the [Gutenberg](https://wordpress.org/plugins/gutenberg/) plugin installed._ +- `--no-watch` – Starts the build for development without starting the watcher. - `--webpack-bundle-analyzer` – enables visualization for the size of webpack output files with an interactive zoomable treemap. - `--webpack-copy-php` – enables copying all PHP files from the source directory ( default is `src` ) and its subfolders to the output directory. - `--webpack-devtool` – controls how source maps are generated. See options at https://webpack.js.org/configuration/devtool/#devtool. diff --git a/packages/scripts/config/webpack.config.js b/packages/scripts/config/webpack.config.js index f6998ab99cdeb7..29a25c9353ff3c 100644 --- a/packages/scripts/config/webpack.config.js +++ b/packages/scripts/config/webpack.config.js @@ -4,6 +4,7 @@ const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' ); const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); +const { DefinePlugin } = require( 'webpack' ); const browserslist = require( 'browserslist' ); const MiniCSSExtractPlugin = require( 'mini-css-extract-plugin' ); const { basename, dirname, resolve } = require( 'path' ); @@ -38,8 +39,27 @@ if ( ! browserslist.findConfig( '.' ) ) { } const hasReactFastRefresh = hasArgInCLI( '--hot' ) && ! isProduction; -// Get paths of the `render` props included in `block.json` files -const renderPaths = getRenderPropPaths(); +/** + * The plugin recomputes the render paths once on each compilation. It is necessary to avoid repeating processing + * when filtering every discovered PHP file in the source folder. This is the most performant way to ensure that + * changes in `block.json` files are picked up in watch mode. + */ +class RenderPathsPlugin { + /** + * Paths with the `render` props included in `block.json` files. + * + * @type {string[]} + */ + static renderPaths; + + apply( compiler ) { + const pluginName = this.constructor.name; + + compiler.hooks.thisCompilation.tap( pluginName, () => { + this.constructor.renderPaths = getRenderPropPaths(); + } ); + } +} const cssLoaders = [ { @@ -224,6 +244,10 @@ const config = { ], }, plugins: [ + new DefinePlugin( { + // Inject the `SCRIPT_DEBUG` global, used for development features flagging. + SCRIPT_DEBUG: ! isProduction, + } ), // During rebuilds, all webpack assets that are not used anymore will be // removed automatically. There is an exception added in watch mode for // fonts and images. It is a known limitations: @@ -234,6 +258,7 @@ const config = { // multiple configurations returned in the webpack config. cleanStaleWebpackAssets: false, } ), + new RenderPathsPlugin(), new CopyWebpackPlugin( { patterns: [ { @@ -277,7 +302,7 @@ const config = { filter: ( filepath ) => { return ( process.env.WP_COPY_PHP_FILES_TO_DIST || - renderPaths.includes( filepath ) + RenderPathsPlugin.renderPaths.includes( filepath ) ); }, }, diff --git a/packages/scripts/package.json b/packages/scripts/package.json index b189bb0dd61965..3410c6e4d9e650 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/scripts", - "version": "26.5.0", + "version": "26.11.0", "description": "Collection of reusable scripts for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -34,7 +34,7 @@ "dependencies": { "@babel/core": "^7.16.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.2", - "@svgr/webpack": "^6.2.1", + "@svgr/webpack": "^8.0.1", "@wordpress/babel-preset-default": "file:../babel-preset-default", "@wordpress/browserslist-config": "file:../browserslist-config", "@wordpress/dependency-extraction-webpack-plugin": "file:../dependency-extraction-webpack-plugin", @@ -45,31 +45,31 @@ "@wordpress/prettier-config": "file:../prettier-config", "@wordpress/stylelint-config": "file:../stylelint-config", "adm-zip": "^0.5.9", - "babel-jest": "^29.5.0", + "babel-jest": "^29.6.2", "babel-loader": "^8.2.3", - "browserslist": "^4.17.6", + "browserslist": "^4.21.9", "chalk": "^4.0.0", "check-node-version": "^4.1.0", "clean-webpack-plugin": "^3.0.0", "copy-webpack-plugin": "^10.2.0", "cross-spawn": "^5.1.0", "css-loader": "^6.2.0", - "cssnano": "^5.0.7", + "cssnano": "^6.0.1", "cwd": "^0.10.0", "dir-glob": "^3.0.1", "eslint": "^8.3.0", "expect-puppeteer": "^4.4.0", "fast-glob": "^3.2.7", "filenamify": "^4.2.0", - "jest": "^29.5.0", + "jest": "^29.6.2", "jest-dev-server": "^6.0.2", - "jest-environment-jsdom": "^29.5.0", - "jest-environment-node": "^29.5.0", + "jest-environment-jsdom": "^29.6.2", + "jest-environment-node": "^29.6.2", "markdownlint-cli": "^0.31.1", "merge-deep": "^3.0.3", "mini-css-extract-plugin": "^2.5.1", "minimist": "^1.2.0", - "npm-package-json-lint": "^5.0.0", + "npm-package-json-lint": "^6.4.0", "npm-packlist": "^3.0.0", "postcss": "^8.4.5", "postcss-loader": "^6.2.1", @@ -82,7 +82,7 @@ "sass-loader": "^12.1.0", "source-map-loader": "^3.0.0", "stylelint": "^14.2.0", - "terser-webpack-plugin": "^5.1.4", + "terser-webpack-plugin": "^5.3.9", "url-loader": "^4.1.1", "webpack": "^5.47.1", "webpack-bundle-analyzer": "^4.4.2", diff --git a/packages/scripts/utils/config.js b/packages/scripts/utils/config.js index 2c2125d65cee48..fc00a613d34ac7 100644 --- a/packages/scripts/utils/config.js +++ b/packages/scripts/utils/config.js @@ -219,64 +219,79 @@ function getWebpackEntryPoints() { ); const entryPoints = blockMetadataFiles.reduce( ( accumulator, blockMetadataFile ) => { - const { editorScript, script, viewScript } = JSON.parse( - readFileSync( blockMetadataFile ) - ); - [ editorScript, script, viewScript ] - .flat() - .filter( ( value ) => value && value.startsWith( 'file:' ) ) - .forEach( ( value ) => { - // Removes the `file:` prefix. - const filepath = join( - dirname( blockMetadataFile ), - value.replace( 'file:', '' ) - ); - - // Takes the path without the file extension, and relative to the defined source directory. - if ( ! filepath.startsWith( srcDirectory ) ) { - log( - chalk.yellow( - `Skipping "${ value.replace( - 'file:', - '' - ) }" listed in "${ blockMetadataFile.replace( - fromProjectRoot( sep ), - '' - ) }". File is located outside of the "${ getWordPressSrcDirectory() }" directory.` - ) + // wrapping in try/catch in case the file is malformed + // this happens especially when new block.json files are added + // at which point they are completely empty and therefore not valid JSON + try { + const { editorScript, script, viewScript } = JSON.parse( + readFileSync( blockMetadataFile ) + ); + [ editorScript, script, viewScript ] + .flat() + .filter( + ( value ) => value && value.startsWith( 'file:' ) + ) + .forEach( ( value ) => { + // Removes the `file:` prefix. + const filepath = join( + dirname( blockMetadataFile ), + value.replace( 'file:', '' ) ); - return; - } - const entryName = filepath - .replace( extname( filepath ), '' ) - .replace( srcDirectory, '' ) - .replace( /\\/g, '/' ); - - // Detects the proper file extension used in the defined source directory. - const [ entryFilepath ] = glob( - `${ getWordPressSrcDirectory() }/${ entryName }.[jt]s?(x)`, - { - absolute: true, + + // Takes the path without the file extension, and relative to the defined source directory. + if ( ! filepath.startsWith( srcDirectory ) ) { + log( + chalk.yellow( + `Skipping "${ value.replace( + 'file:', + '' + ) }" listed in "${ blockMetadataFile.replace( + fromProjectRoot( sep ), + '' + ) }". File is located outside of the "${ getWordPressSrcDirectory() }" directory.` + ) + ); + return; } - ); - - if ( ! entryFilepath ) { - log( - chalk.yellow( - `Skipping "${ value.replace( - 'file:', - '' - ) }" listed in "${ blockMetadataFile.replace( - fromProjectRoot( sep ), - '' - ) }". File does not exist in the "${ getWordPressSrcDirectory() }" directory.` - ) + const entryName = filepath + .replace( extname( filepath ), '' ) + .replace( srcDirectory, '' ) + .replace( /\\/g, '/' ); + + // Detects the proper file extension used in the defined source directory. + const [ entryFilepath ] = glob( + `${ getWordPressSrcDirectory() }/${ entryName }.[jt]s?(x)`, + { + absolute: true, + } ); - return; - } - accumulator[ entryName ] = entryFilepath; - } ); - return accumulator; + + if ( ! entryFilepath ) { + log( + chalk.yellow( + `Skipping "${ value.replace( + 'file:', + '' + ) }" listed in "${ blockMetadataFile.replace( + fromProjectRoot( sep ), + '' + ) }". File does not exist in the "${ getWordPressSrcDirectory() }" directory.` + ) + ); + return; + } + accumulator[ entryName ] = entryFilepath; + } ); + return accumulator; + } catch ( error ) { + chalk.yellow( + `Skipping "${ blockMetadataFile.replace( + fromProjectRoot( sep ), + '' + ) }" due to malformed JSON.` + ); + return accumulator; + } }, {} ); diff --git a/packages/server-side-render/CHANGELOG.md b/packages/server-side-render/CHANGELOG.md index db12005fa06bdb..c6d734e9367601 100644 --- a/packages/server-side-render/CHANGELOG.md +++ b/packages/server-side-render/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 4.17.0 (2023-08-16) + +## 4.16.0 (2023-08-10) + +## 4.15.0 (2023-07-20) + +## 4.14.0 (2023-07-05) + +## 4.13.0 (2023-06-23) + +## 4.12.0 (2023-06-07) + ## 4.11.0 (2023-05-24) ## 4.10.0 (2023-05-10) diff --git a/packages/server-side-render/README.md b/packages/server-side-render/README.md index 9c780868931475..ba6fae302ca0a8 100644 --- a/packages/server-side-render/README.md +++ b/packages/server-side-render/README.md @@ -170,7 +170,7 @@ If you pass `attributes` to `ServerSideRender`, the block must also be registere register_block_type( 'core/archives', array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'showPostCounts' => array( 'type' => 'boolean', diff --git a/packages/server-side-render/package.json b/packages/server-side-render/package.json index 6a68826665bf44..a2d1f596cbb780 100644 --- a/packages/server-side-render/package.json +++ b/packages/server-side-render/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/server-side-render", - "version": "4.11.0", + "version": "4.17.0", "description": "The component used with WordPress to server-side render a preview of dynamic blocks to display in the editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/shortcode/CHANGELOG.md b/packages/shortcode/CHANGELOG.md index 35d95197e11cd7..75fda3827e6e0c 100644 --- a/packages/shortcode/CHANGELOG.md +++ b/packages/shortcode/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/shortcode/package.json b/packages/shortcode/package.json index 0a13cada82e0fc..940eba49cd28ab 100644 --- a/packages/shortcode/package.json +++ b/packages/shortcode/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/shortcode", - "version": "3.34.0", + "version": "3.40.0", "description": "Shortcode module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/style-engine/CHANGELOG.md b/packages/style-engine/CHANGELOG.md index 60ce7f949cb21f..fbf1aa1eb6ba95 100644 --- a/packages/style-engine/CHANGELOG.md +++ b/packages/style-engine/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +## 1.23.0 (2023-08-16) + +## 1.22.0 (2023-08-10) + +### Bug Fixes +- Style engine: switch off optimize by default [#53085](https://github.com/WordPress/gutenberg/pull/53085). + +## 1.21.0 (2023-07-20) + +## 1.20.0 (2023-07-05) + +## 1.19.0 (2023-06-23) + +## 1.18.0 (2023-06-07) + ## 1.17.0 (2023-05-24) ## 1.16.0 (2023-05-10) diff --git a/packages/style-engine/README.md b/packages/style-engine/README.md index 999fab2aa835e9..290e04ee60ae83 100644 --- a/packages/style-engine/README.md +++ b/packages/style-engine/README.md @@ -108,7 +108,7 @@ _Returns_ Useful for when you wish to compile a bespoke set of CSS rules from a series of selector + declaration items. -The Style Engine will return a sanitized and optimized stylesheet. By passing a `context` identifier in the options, the Style Engine will store the styles for later retrieval, for example, should you wish to batch enqueue a set of CSS rules. +The Style Engine will return a sanitized stylesheet. By passing a `context` identifier in the options, the Style Engine will store the styles for later retrieval, for example, should you wish to batch enqueue a set of CSS rules. You can call `wp_style_engine_get_stylesheet_from_css_rules()` multiple times, and, so long as your styles use the same `context` identifier, they will be stored together. diff --git a/packages/style-engine/class-wp-style-engine-processor.php b/packages/style-engine/class-wp-style-engine-processor.php index 4974768c652673..81d9f2316d7061 100644 --- a/packages/style-engine/class-wp-style-engine-processor.php +++ b/packages/style-engine/class-wp-style-engine-processor.php @@ -81,6 +81,8 @@ public function add_rules( $css_rules ) { /** * Get the CSS rules as a string. * + * Since 6.4.0 Optimization is no longer the default. + * * @param array $options { * Optional. An array of options. Default empty array. * @@ -92,7 +94,7 @@ public function add_rules( $css_rules ) { */ public function get_css( $options = array() ) { $defaults = array( - 'optimize' => true, + 'optimize' => false, 'prettify' => SCRIPT_DEBUG, ); $options = wp_parse_args( $options, $defaults ); diff --git a/packages/style-engine/class-wp-style-engine.php b/packages/style-engine/class-wp-style-engine.php index d62f245364103e..7304b57ec56123 100644 --- a/packages/style-engine/class-wp-style-engine.php +++ b/packages/style-engine/class-wp-style-engine.php @@ -24,14 +24,16 @@ */ final class WP_Style_Engine { /** - * Style definitions that contain the instructions to - * parse/output valid Gutenberg styles from a block's attributes. + * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. * For every style definition, the follow properties are valid: * - classnames => (array) an array of classnames to be returned for block styles. The key is a classname or pattern. * A value of `true` means the classname should be applied always. Otherwise, a valid CSS property (string) * to match the incoming value, e.g., "color" to match var:preset|color|somePresetSlug. - * - css_vars => (array) an array of key value pairs used to generate CSS var values. The key is a CSS var pattern, whose `$slug` fragment will be replaced with a preset slug. - * The value should be a valid CSS property (string) to match the incoming value, e.g., "color" to match var:preset|color|somePresetSlug. + * - css_vars => (array) an array of key value pairs used to generate CSS var values. + * The key should be the CSS property name that matches the second element of the preset string value, + * i.e., "color" in var:preset|color|somePresetSlug. The value is a CSS var pattern (e.g. `--wp--preset--color--$slug`), + * whose `$slug` fragment will be replaced with the preset slug, which is the third element of the preset string value, + * i.e., `somePresetSlug` in var:preset|color|somePresetSlug. * - property_keys => (array) array of keys whose values represent a valid CSS property, e.g., "margin" or "border". * - path => (array) a path that accesses the corresponding style value in the block style object. * - value_func => (string) the name of a function to generate a CSS definition array for a particular style object. The output of this function should be `array( "$property" => "$value", ... )`. @@ -241,6 +243,12 @@ final class WP_Style_Engine { ), 'path' => array( 'typography', 'letterSpacing' ), ), + 'writingMode' => array( + 'property_keys' => array( + 'default' => 'writing-mode', + ), + 'path' => array( 'typography', 'writingMode' ), + ), ), ); diff --git a/packages/style-engine/package.json b/packages/style-engine/package.json index 54b2921698307b..30b1c2a1c8036c 100644 --- a/packages/style-engine/package.json +++ b/packages/style-engine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/style-engine", - "version": "1.17.0", + "version": "1.23.0", "description": "A suite of parsers and compilers for WordPress styles.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -30,7 +30,7 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.16.0", - "lodash": "^4.17.21" + "change-case": "^4.1.2" }, "publishConfig": { "access": "public" diff --git a/packages/style-engine/src/index.ts b/packages/style-engine/src/index.ts index 05ec31fcbccbcf..fe5b9929877be7 100644 --- a/packages/style-engine/src/index.ts +++ b/packages/style-engine/src/index.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { kebabCase } from 'lodash'; +import { paramCase as kebabCase } from 'change-case'; /** * Internal dependencies diff --git a/packages/style-engine/src/styles/typography/index.ts b/packages/style-engine/src/styles/typography/index.ts index 66e89d0e562090..92c40d2e156198 100644 --- a/packages/style-engine/src/styles/typography/index.ts +++ b/packages/style-engine/src/styles/typography/index.ts @@ -112,6 +112,18 @@ const textTransform = { }, }; +const writingMode = { + name: 'writingMode', + generate: ( style: Style, options: StyleOptions ) => { + return generateRule( + style, + options, + [ 'typography', 'writingMode' ], + 'writingMode' + ); + }, +}; + export default [ fontFamily, fontSize, @@ -122,4 +134,5 @@ export default [ textColumns, textDecoration, textTransform, + writingMode, ]; diff --git a/packages/style-engine/src/styles/utils.ts b/packages/style-engine/src/styles/utils.ts index 09819dc0e6cf28..06f278cb7c1287 100644 --- a/packages/style-engine/src/styles/utils.ts +++ b/packages/style-engine/src/styles/utils.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { get, kebabCase } from 'lodash'; +import { paramCase as kebabCase } from 'change-case'; /** * Internal dependencies @@ -19,6 +19,25 @@ import { VARIABLE_PATH_SEPARATOR_TOKEN_STYLE, } from './constants'; +/** + * Helper util to return a value from a certain path of the object. + * Path is specified as an array of properties, like `[ 'x', 'y' ]`. + * + * @param object Input object. + * @param path Path to the object property. + * @return Value of the object property at the specified path. + */ +export const getStyleValueByPath = ( + object: Record< any, any >, + path: string[] +) => { + let value: any = object; + path.forEach( ( fieldName: string ) => { + value = value?.[ fieldName ]; + } ); + return value; +}; + /** * Returns a JSON representation of the generated CSS rules. * @@ -35,7 +54,7 @@ export function generateRule( path: string[], ruleKey: string ): GeneratedCSSRule[] { - const styleValue: string | undefined = get( style, path ); + const styleValue: string | undefined = getStyleValueByPath( style, path ); return styleValue ? [ @@ -66,7 +85,10 @@ export function generateBoxRules( ruleKeys: CssRulesKeys, individualProperties: string[] = [ 'top', 'right', 'bottom', 'left' ] ): GeneratedCSSRule[] { - const boxStyle: Box | string | undefined = get( style, path ); + const boxStyle: Box | string | undefined = getStyleValueByPath( + style, + path + ); if ( ! boxStyle ) { return []; } @@ -82,7 +104,7 @@ export function generateBoxRules( const sideRules = individualProperties.reduce( ( acc: GeneratedCSSRule[], side: string ) => { const value: string | undefined = getCSSVarFromStyleValue( - get( boxStyle, [ side ] ) + getStyleValueByPath( boxStyle, [ side ] ) ); if ( value ) { acc.push( { @@ -119,7 +141,16 @@ export function getCSSVarFromStyleValue( styleValue: string ): string { const variable = styleValue .slice( VARIABLE_REFERENCE_PREFIX.length ) .split( VARIABLE_PATH_SEPARATOR_TOKEN_ATTRIBUTE ) - .map( ( presetVariable ) => kebabCase( presetVariable ) ) + .map( ( presetVariable ) => + kebabCase( presetVariable, { + splitRegexp: [ + /([a-z0-9])([A-Z])/g, // fooBar => foo-bar, 3Bar => 3-bar + /([0-9])([a-z])/g, // 3bar => 3-bar + /([A-Za-z])([0-9])/g, // Foo3 => foo-3, foo3 => foo-3 + /([A-Z])([A-Z][a-z])/g, // FOOBar => foo-bar + ], + } ) + ) .join( VARIABLE_PATH_SEPARATOR_TOKEN_STYLE ); return `var(--wp--${ variable })`; } diff --git a/packages/style-engine/src/test/utils.js b/packages/style-engine/src/test/utils.js index a45d9256dc1c09..9f1f84d2b45310 100644 --- a/packages/style-engine/src/test/utils.js +++ b/packages/style-engine/src/test/utils.js @@ -33,10 +33,30 @@ describe( 'utils', () => { ).toEqual( 'var(--wp--preset--font-size--h-1)' ); } ); + it( 'should kebab case numbers as prefix', () => { + expect( + getCSSVarFromStyleValue( 'var:preset|font-size|1px' ) + ).toEqual( 'var(--wp--preset--font-size--1-px)' ); + } ); + + it( 'should kebab case both sides of numbers', () => { + expect( + getCSSVarFromStyleValue( 'var:preset|color|orange11orange' ) + ).toEqual( 'var(--wp--preset--color--orange-11-orange)' ); + } ); + it( 'should kebab case camel case', () => { expect( getCSSVarFromStyleValue( 'var:preset|color|heavenlyBlue' ) ).toEqual( 'var(--wp--preset--color--heavenly-blue)' ); } ); + + it( 'should kebab case underscores', () => { + expect( + getCSSVarFromStyleValue( + 'var:preset|background|dark_Secrets_100' + ) + ).toEqual( 'var(--wp--preset--background--dark-secrets-100)' ); + } ); } ); } ); diff --git a/packages/style-engine/src/types.ts b/packages/style-engine/src/types.ts index 23d3e38cc43c22..4b6a9aa72257ed 100644 --- a/packages/style-engine/src/types.ts +++ b/packages/style-engine/src/types.ts @@ -55,6 +55,7 @@ export interface Style { textColumns?: CSSProperties[ 'columnCount' ]; textDecoration?: CSSProperties[ 'textDecoration' ]; textTransform?: CSSProperties[ 'textTransform' ]; + writingMode?: CSSProperties[ 'writingMode' ]; }; color?: { text?: CSSProperties[ 'color' ]; diff --git a/packages/stylelint-config/CHANGELOG.md b/packages/stylelint-config/CHANGELOG.md index be89d666c46767..697007028d26d9 100644 --- a/packages/stylelint-config/CHANGELOG.md +++ b/packages/stylelint-config/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 21.23.0 (2023-08-16) + +## 21.22.0 (2023-08-10) + +## 21.21.0 (2023-07-20) + +## 21.20.0 (2023-07-05) + +## 21.19.0 (2023-06-23) + +## 21.18.0 (2023-06-07) + ## 21.17.0 (2023-05-24) ## 21.16.0 (2023-05-10) diff --git a/packages/stylelint-config/package.json b/packages/stylelint-config/package.json index aff4acd54b946f..9255a5eae9f5ef 100644 --- a/packages/stylelint-config/package.json +++ b/packages/stylelint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/stylelint-config", - "version": "21.17.0", + "version": "21.23.0", "description": "stylelint config for WordPress development.", "author": "The WordPress Contributors", "license": "MIT", diff --git a/packages/sync/.npmrc b/packages/sync/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/sync/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/sync/CHANGELOG.md b/packages/sync/CHANGELOG.md new file mode 100644 index 00000000000000..4cae82da1cac88 --- /dev/null +++ b/packages/sync/CHANGELOG.md @@ -0,0 +1,5 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased + +## 0.2.0 (2023-08-16) diff --git a/packages/sync/README.md b/packages/sync/README.md new file mode 100644 index 00000000000000..62fe20af4f2fbb --- /dev/null +++ b/packages/sync/README.md @@ -0,0 +1,66 @@ +# Sync + +Sync data between multiple peers and persist in a local database. + +## Installation + +Install the module + +```bash +npm install @wordpress/sync --save +``` + +## API + +<!-- START TOKEN(Autogenerated API docs) --> + +### connectIndexDb + +Connect function to the IndexedDB persistence provider. + +_Parameters_ + +- _objectId_ `ObjectID`: The object ID. +- _objectType_ `ObjectType`: The object type. +- _doc_ `CRDTDoc`: The CRDT document. + +_Returns_ + +- `Promise<() => void>`: Promise that resolves when the connection is established. + +### connectWebRTC + +Connect function to the WebRTC provider. + +_Parameters_ + +- _objectId_ `ObjectID`: The object ID. +- _objectType_ `ObjectType`: The object type. +- _doc_ `CRDTDoc`: The CRDT document. + +_Returns_ + +- `Promise<() => void>`: Promise that resolves when the connection is established. + +### createSyncProvider + +Create a sync provider. + +_Parameters_ + +- _connectLocal_ `ConnectDoc`: Connect the document to a local database. +- _connectRemote_ `ConnectDoc`: Connect the document to a remote sync connection. + +_Returns_ + +- `SyncProvider`: Sync provider. + +<!-- END TOKEN(Autogenerated API docs) --> + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/sync/package.json b/packages/sync/package.json new file mode 100644 index 00000000000000..04e7355f14ead5 --- /dev/null +++ b/packages/sync/package.json @@ -0,0 +1,38 @@ +{ + "name": "@wordpress/sync", + "version": "0.2.0", + "description": "Sync Data.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "sync" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/sync/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/sync" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "y-indexeddb": "~9.0.11", + "y-webrtc": "~10.2.5", + "yjs": "~13.6.6" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sync/src/connect-indexdb.js b/packages/sync/src/connect-indexdb.js new file mode 100644 index 00000000000000..ee56a463fd9956 --- /dev/null +++ b/packages/sync/src/connect-indexdb.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +// @ts-ignore +import { IndexeddbPersistence } from 'y-indexeddb'; + +/** @typedef {import('./types').ObjectType} ObjectType */ +/** @typedef {import('./types').ObjectID} ObjectID */ +/** @typedef {import('./types').CRDTDoc} CRDTDoc */ +/** @typedef {import('./types').ConnectDoc} ConnectDoc */ +/** @typedef {import('./types').SyncProvider} SyncProvider */ + +/** + * Connect function to the IndexedDB persistence provider. + * + * @param {ObjectID} objectId The object ID. + * @param {ObjectType} objectType The object type. + * @param {CRDTDoc} doc The CRDT document. + * + * @return {Promise<() => void>} Promise that resolves when the connection is established. + */ +export function connectIndexDb( objectId, objectType, doc ) { + const docName = `${ objectType }-${ objectId }`; + const provider = new IndexeddbPersistence( docName, doc ); + + return new Promise( ( resolve ) => { + provider.on( 'synced', () => { + resolve( () => provider.destroy() ); + } ); + } ); +} diff --git a/packages/sync/src/connect-webrtc.js b/packages/sync/src/connect-webrtc.js new file mode 100644 index 00000000000000..867bba39d68927 --- /dev/null +++ b/packages/sync/src/connect-webrtc.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +// @ts-ignore +import { WebrtcProvider } from 'y-webrtc'; + +/** @typedef {import('./types').ObjectType} ObjectType */ +/** @typedef {import('./types').ObjectID} ObjectID */ +/** @typedef {import('./types').CRDTDoc} CRDTDoc */ + +/** + * Connect function to the WebRTC provider. + * + * @param {ObjectID} objectId The object ID. + * @param {ObjectType} objectType The object type. + * @param {CRDTDoc} doc The CRDT document. + * + * @return {Promise<() => void>} Promise that resolves when the connection is established. + */ +export function connectWebRTC( objectId, objectType, doc ) { + const docName = `${ objectType }-${ objectId }`; + new WebrtcProvider( docName, doc, { + // @ts-ignore + password: window.__experimentalCollaborativeEditingSecret, + } ); + + return Promise.resolve( () => true ); +} diff --git a/packages/sync/src/index.js b/packages/sync/src/index.js new file mode 100644 index 00000000000000..975fb52989f5d2 --- /dev/null +++ b/packages/sync/src/index.js @@ -0,0 +1,3 @@ +export { connectIndexDb } from './connect-indexdb'; +export { connectWebRTC } from './connect-webrtc'; +export { createSyncProvider } from './provider'; diff --git a/packages/sync/src/provider.js b/packages/sync/src/provider.js new file mode 100644 index 00000000000000..15d972dbcd4f09 --- /dev/null +++ b/packages/sync/src/provider.js @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +// @ts-ignore +import * as Y from 'yjs'; + +/** @typedef {import('./types').ObjectType} ObjectType */ +/** @typedef {import('./types').ObjectID} ObjectID */ +/** @typedef {import('./types').ObjectConfig} ObjectConfig */ +/** @typedef {import('./types').CRDTDoc} CRDTDoc */ +/** @typedef {import('./types').ConnectDoc} ConnectDoc */ +/** @typedef {import('./types').SyncProvider} SyncProvider */ + +/** + * Create a sync provider. + * + * @param {ConnectDoc} connectLocal Connect the document to a local database. + * @param {ConnectDoc} connectRemote Connect the document to a remote sync connection. + * @return {SyncProvider} Sync provider. + */ +export const createSyncProvider = ( connectLocal, connectRemote ) => { + /** + * @type {Record<string,ObjectConfig>} + */ + const config = {}; + + /** + * @type {Record<string,Record<string,()=>void>>} + */ + const listeners = {}; + + /** + * @type {Record<string,Record<string,CRDTDoc>>} + */ + const docs = {}; + + /** + * Registeres an object type. + * + * @param {ObjectType} objectType Object type to register. + * @param {ObjectConfig} objectConfig Object config. + */ + function register( objectType, objectConfig ) { + config[ objectType ] = objectConfig; + } + + /** + * Fetch data from local database or remote source. + * + * @param {ObjectType} objectType Object type to load. + * @param {ObjectID} objectId Object ID to load. + * @param {Function} handleChanges Callback to call when data changes. + */ + async function bootstrap( objectType, objectId, handleChanges ) { + const doc = new Y.Doc(); + docs[ objectType ] = docs[ objectType ] || {}; + docs[ objectType ][ objectId ] = doc; + + const updateHandler = () => { + const data = config[ objectType ].fromCRDTDoc( doc ); + handleChanges( data ); + }; + doc.on( 'update', updateHandler ); + + // connect to locally saved database. + const destroyLocalConnection = await connectLocal( + objectId, + objectType, + doc + ); + + // Once the database syncing is done, start the remote syncing + if ( connectRemote ) { + await connectRemote( objectId, objectType, doc ); + } + + const loadRemotely = config[ objectType ].fetch; + if ( loadRemotely ) { + loadRemotely( objectId ).then( ( data ) => { + doc.transact( () => { + config[ objectType ].applyChangesToDoc( doc, data ); + } ); + } ); + } + + listeners[ objectType ] = listeners[ objectType ] || {}; + listeners[ objectType ][ objectId ] = () => { + destroyLocalConnection(); + doc.off( 'update', updateHandler ); + }; + } + + /** + * Fetch data from local database or remote source. + * + * @param {ObjectType} objectType Object type to load. + * @param {ObjectID} objectId Object ID to load. + * @param {any} data Updates to make. + */ + async function update( objectType, objectId, data ) { + const doc = docs[ objectType ][ objectId ]; + if ( ! doc ) { + throw 'Error doc ' + objectType + ' ' + objectId + ' not found'; + } + doc.transact( () => { + config[ objectType ].applyChangesToDoc( doc, data ); + } ); + } + + /** + * Stop updating a document and discard it. + * + * @param {ObjectType} objectType Object type to load. + * @param {ObjectID} objectId Object ID to load. + */ + async function discard( objectType, objectId ) { + if ( listeners?.[ objectType ]?.[ objectId ] ) { + listeners[ objectType ][ objectId ](); + } + } + + return { + register, + bootstrap, + update, + discard, + }; +}; diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts new file mode 100644 index 00000000000000..03439ecf280319 --- /dev/null +++ b/packages/sync/src/types.ts @@ -0,0 +1,27 @@ +export type ObjectID = string; +export type ObjectType = string; +export type ObjectData = any; +export type CRDTDoc = any; + +export type ObjectConfig = { + fetch: ( id: ObjectID ) => Promise< ObjectData >; + applyChangesToDoc: ( doc: CRDTDoc, data: any ) => void; + fromCRDTDoc: ( doc: CRDTDoc ) => any; +}; + +export type ConnectDoc = ( + id: ObjectID, + type: ObjectType, + doc: CRDTDoc +) => Promise< () => void >; + +export type SyncProvider = { + register: ( type: ObjectType, config: ObjectConfig ) => void; + bootstrap: ( + type: ObjectType, + id: ObjectID, + handleChanges: ( data: any ) => void + ) => Promise< CRDTDoc >; + update: ( type: ObjectType, id: ObjectID, data: any ) => void; + discard: ( type: ObjectType, id: ObjectID ) => Promise< CRDTDoc >; +}; diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json new file mode 100644 index 00000000000000..d2d94e16acd2ce --- /dev/null +++ b/packages/sync/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "node" ] + }, + "include": [ "src/**/*" ] +} diff --git a/packages/token-list/CHANGELOG.md b/packages/token-list/CHANGELOG.md index 8ede2e3a48e5d8..741560797cac38 100644 --- a/packages/token-list/CHANGELOG.md +++ b/packages/token-list/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.40.0 (2023-08-16) + +## 2.39.0 (2023-08-10) + +## 2.38.0 (2023-07-20) + +## 2.37.0 (2023-07-05) + +## 2.36.0 (2023-06-23) + +## 2.35.0 (2023-06-07) + ## 2.34.0 (2023-05-24) ## 2.33.0 (2023-05-10) diff --git a/packages/token-list/package.json b/packages/token-list/package.json index 7cca33a6e9b2dc..978a3810233149 100644 --- a/packages/token-list/package.json +++ b/packages/token-list/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/token-list", - "version": "2.34.0", + "version": "2.40.0", "description": "Constructable, plain JavaScript DOMTokenList implementation, supporting non-browser runtimes.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/url/CHANGELOG.md b/packages/url/CHANGELOG.md index 4d54a9705f867b..e51bf2521f7d22 100644 --- a/packages/url/CHANGELOG.md +++ b/packages/url/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.41.0 (2023-08-16) + +## 3.40.0 (2023-08-10) + +## 3.39.0 (2023-07-20) + +## 3.38.0 (2023-07-05) + +## 3.37.0 (2023-06-23) + +## 3.36.0 (2023-06-07) + ## 3.35.0 (2023-05-24) ## 3.34.0 (2023-05-10) diff --git a/packages/url/package.json b/packages/url/package.json index ac806969bd783c..e0aee8699528d3 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/url", - "version": "3.35.0", + "version": "3.41.0", "description": "WordPress URL utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -28,7 +28,7 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.16.0", - "remove-accents": "^0.4.2" + "remove-accents": "^0.5.0" }, "publishConfig": { "access": "public" diff --git a/packages/url/src/test/fixtures/wpt-data.json b/packages/url/src/test/fixtures/wpt-data.json index 63a5cae28a51de..7072bddc7f7612 100644 --- a/packages/url/src/test/fixtures/wpt-data.json +++ b/packages/url/src/test/fixtures/wpt-data.json @@ -1 +1 @@ -[{"input":"https://test:@test"},{"input":"https://:@test"},{"input":"non-special://test:@test/x"},{"input":"non-special://:@test/x"},{"input":"lolscheme:x x#x x"},{"input":"file://example:1/","failure":true},{"input":"file://example:test/","failure":true},{"input":"file://example%/","failure":true},{"input":"file://[example]/","failure":true},{"input":"http://example.com/././foo"},{"input":"http://example.com/./.foo"},{"input":"http://example.com/foo/."},{"input":"http://example.com/foo/./"},{"input":"http://example.com/foo/bar/.."},{"input":"http://example.com/foo/bar/../"},{"input":"http://example.com/foo/..bar"},{"input":"http://example.com/foo/bar/../ton"},{"input":"http://example.com/foo/bar/../ton/../../a"},{"input":"http://example.com/foo/../../.."},{"input":"http://example.com/foo/../../../ton"},{"input":"http://example.com/foo/%2e"},{"input":"http://example.com/foo/%2e%2"},{"input":"http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar"},{"input":"http://example.com////../.."},{"input":"http://example.com/foo/bar//../.."},{"input":"http://example.com/foo/bar//.."},{"input":"http://example.com/foo"},{"input":"http://example.com/%20foo"},{"input":"http://example.com/foo%"},{"input":"http://example.com/foo%2"},{"input":"http://example.com/foo%2zbar"},{"input":"http://example.com/foo%2©zbar"},{"input":"http://example.com/foo%41%7a"},{"input":"http://example.com/foo\t‘%91"},{"input":"http://example.com/foo%00%51"},{"input":"http://example.com/(%28:%3A%29)"},{"input":"http://example.com/%3A%3a%3C%3c"},{"input":"http://example.com/foo\tbar"},{"input":"http://example.com\\\\foo\\\\bar"},{"input":"http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd"},{"input":"http://example.com/@asdf%40"},{"input":"http://example.com/你好你好"},{"input":"http://example.com/‥/foo"},{"input":"http://example.com//foo"},{"input":"http://example.com/‮/foo/‭/bar"},{"input":"http://www.google.com/foo?bar=baz#"},{"input":"http://www.google.com/foo?bar=baz# »"},{"input":"data:test# »"},{"input":"http://www.google.com"},{"input":"http://192.0x00A80001"},{"input":"http://www/foo%2Ehtml"},{"input":"http://www/foo/%2E/html"},{"input":"http://user:pass@/","failure":true},{"input":"http://%25DOMAIN:foobar@foodomain.com/"},{"input":"http:\\\\www.google.com\\foo"},{"input":"http://foo:80/"},{"input":"http://foo:81/"},{"input":"httpa://foo:80/"},{"input":"http://foo:-80/","failure":true},{"input":"https://foo:443/"},{"input":"https://foo:80/"},{"input":"ftp://foo:21/"},{"input":"ftp://foo:80/"},{"input":"gopher://foo:70/"},{"input":"gopher://foo:443/"},{"input":"ws://foo:80/"},{"input":"ws://foo:81/"},{"input":"ws://foo:443/"},{"input":"ws://foo:815/"},{"input":"wss://foo:80/"},{"input":"wss://foo:81/"},{"input":"wss://foo:443/"},{"input":"wss://foo:815/"},{"input":"http:/example.com/"},{"input":"ftp:/example.com/"},{"input":"https:/example.com/"},{"input":"madeupscheme:/example.com/"},{"input":"file:/example.com/"},{"input":"ftps:/example.com/"},{"input":"gopher:/example.com/"},{"input":"ws:/example.com/"},{"input":"wss:/example.com/"},{"input":"data:/example.com/"},{"input":"javascript:/example.com/"},{"input":"mailto:/example.com/"},{"input":"http:example.com/"},{"input":"ftp:example.com/"},{"input":"https:example.com/"},{"input":"madeupscheme:example.com/"},{"input":"ftps:example.com/"},{"input":"gopher:example.com/"},{"input":"ws:example.com/"},{"input":"wss:example.com/"},{"input":"data:example.com/"},{"input":"javascript:example.com/"},{"input":"mailto:example.com/"},{"input":"http:@www.example.com"},{"input":"http:/@www.example.com"},{"input":"http://@www.example.com"},{"input":"http:a:b@www.example.com"},{"input":"http:/a:b@www.example.com"},{"input":"http://a:b@www.example.com"},{"input":"http://@pple.com"},{"input":"http::b@www.example.com"},{"input":"http:/:b@www.example.com"},{"input":"http://:b@www.example.com"},{"input":"http:/:@/www.example.com","failure":true},{"input":"http://user@/www.example.com","failure":true},{"input":"http:@/www.example.com","failure":true},{"input":"http:/@/www.example.com","failure":true},{"input":"http://@/www.example.com","failure":true},{"input":"https:@/www.example.com","failure":true},{"input":"http:a:b@/www.example.com","failure":true},{"input":"http:/a:b@/www.example.com","failure":true},{"input":"http://a:b@/www.example.com","failure":true},{"input":"http::@/www.example.com","failure":true},{"input":"http:a:@www.example.com"},{"input":"http:/a:@www.example.com"},{"input":"http://a:@www.example.com"},{"input":"http://www.@pple.com"},{"input":"http:@:www.example.com","failure":true},{"input":"http:/@:www.example.com","failure":true},{"input":"http://@:www.example.com","failure":true},{"input":"http://:@www.example.com"},{"input":"\u0000\u001b\u0004\u0012 http://example.com/\u001f \r "},{"input":"https://�","failure":true},{"input":"https://%EF%BF%BD","failure":true},{"input":"https://x/�?�#�"},{"input":"http://a.b.c.xn--pokxncvks","failure":true},{"input":"http://10.0.0.xn--pokxncvks","failure":true},{"input":"https://faß.ExAmPlE/"},{"input":"sc://faß.ExAmPlE/"},{"input":"https://x x:12","failure":true},{"input":"http://./"},{"input":"http://../"},{"input":"http://[www.google.com]/","failure":true},{"input":"http://host/?'"},{"input":"notspecial://host/?'"},{"input":"about:/../"},{"input":"data:/../"},{"input":"javascript:/../"},{"input":"mailto:/../"},{"input":"sc://ñ.test/"},{"input":"sc://\u0000/","failure":true},{"input":"sc:// /","failure":true},{"input":"sc://%/"},{"input":"sc://@/","failure":true},{"input":"sc://te@s:t@/","failure":true},{"input":"sc://:/","failure":true},{"input":"sc://:12/","failure":true},{"input":"sc://[/","failure":true},{"input":"sc://\\/","failure":true},{"input":"sc://]/","failure":true},{"input":"sc:\\../"},{"input":"sc::a@example.net"},{"input":"wow:%NBD"},{"input":"wow:%1G"},{"input":"wow:￿"},{"input":"http://example.com/\ud800𐟾\udfff﷐﷏﷯ﷰ￾￿?\ud800𐟾\udfff﷐﷏﷯ﷰ￾￿"},{"input":"http://a<b","failure":true},{"input":"http://a>b","failure":true},{"input":"http://a^b","failure":true},{"input":"non-special://a<b","failure":true},{"input":"non-special://a>b","failure":true},{"input":"non-special://a^b","failure":true},{"input":"foo://ho\u0000st/","failure":true},{"input":"foo://ho|st/","failure":true},{"input":"foo://ho\tst/"},{"input":"foo://ho\nst/"},{"input":"foo://ho\rst/"},{"input":"http://ho%00st/","failure":true},{"input":"http://ho%09st/","failure":true},{"input":"http://ho%0Ast/","failure":true},{"input":"http://ho%0Dst/","failure":true},{"input":"http://ho%20st/","failure":true},{"input":"http://ho%23st/","failure":true},{"input":"http://ho%2Fst/","failure":true},{"input":"http://ho%3Ast/","failure":true},{"input":"http://ho%3Cst/","failure":true},{"input":"http://ho%3Est/","failure":true},{"input":"http://ho%3Fst/","failure":true},{"input":"http://ho%40st/","failure":true},{"input":"http://ho%5Bst/","failure":true},{"input":"http://ho%5Cst/","failure":true},{"input":"http://ho%5Dst/","failure":true},{"input":"http://ho%7Cst/","failure":true},{"input":"http://\u001f!\"$&'()*+,-.;=_`{}~/"},{"input":"sc://\u001f!\"$&'()*+,-.;=_`{}~/"},{"input":"ftp://example.com%80/","failure":true},{"input":"ftp://example.com%A0/","failure":true},{"input":"https://example.com%80/","failure":true},{"input":"https://example.com%A0/","failure":true},{"input":"ftp://%e2%98%83"},{"input":"https://%e2%98%83"},{"input":"http://127.0.0.1:10100/relative_import.html"},{"input":"http://facebook.com/?foo=%7B%22abc%22"},{"input":"https://localhost:3000/jqueryui@1.2.3"},{"input":"h\tt\nt\rp://h\to\ns\rt:9\t0\n0\r0/p\ta\nt\rh?q\tu\ne\rry#f\tr\na\rg"},{"input":"http://foo.bar/baz?qux#foo\bbar"},{"input":"http://foo.bar/baz?qux#foo\"bar"},{"input":"http://foo.bar/baz?qux#foo<bar"},{"input":"http://foo.bar/baz?qux#foo>bar"},{"input":"http://foo.bar/baz?qux#foo`bar"},{"input":"https://0x.0x.0"},{"input":"https://0x100000000/test","failure":true},{"input":"https://256.0.0.1/test","failure":true},{"input":"file:///C%3A/"},{"input":"file:///C%7C/"},{"input":"file://%43%3A","failure":true},{"input":"file://%43%7C","failure":true},{"input":"file://%43|","failure":true},{"input":"file://C%7C","failure":true},{"input":"file://%43%7C/","failure":true},{"input":"https://%43%7C/","failure":true},{"input":"asdf://%43|/","failure":true},{"input":"asdf://%43%7C/"},{"input":"file:\\\\//"},{"input":"file:\\\\\\\\"},{"input":"file:\\\\\\\\?fox"},{"input":"file:\\\\\\\\#guppy"},{"input":"file://spider///"},{"input":"file:\\\\localhost//"},{"input":"file:///localhost//cat"},{"input":"file://\\/localhost//cat"},{"input":"file://localhost//a//../..//"},{"input":"file://example.net/C:/"},{"input":"file://1.2.3.4/C:/"},{"input":"file://[1::8]/C:/"},{"input":"file:/C|/"},{"input":"file://C|/"},{"input":"file:"},{"input":"file:?q=v"},{"input":"file:#frag"},{"input":"file:///Y:"},{"input":"file:///Y:/"},{"input":"file:///./Y"},{"input":"file:///./Y:"},{"input":"\\\\\\.\\Y:","failure":true},{"input":"file:///y:"},{"input":"file:///y:/"},{"input":"file:///./y"},{"input":"file:///./y:"},{"input":"\\\\\\.\\y:","failure":true},{"input":"file://localhost//a//../..//foo"},{"input":"file://localhost////foo"},{"input":"file:////foo"},{"input":"file:.//p"},{"input":"file:/.//p"},{"input":"https://[0::0::0]","failure":true},{"input":"https://[0:.0]","failure":true},{"input":"https://[0:0:]","failure":true},{"input":"https://[0:1:2:3:4:5:6:7.0.0.0.1]","failure":true},{"input":"https://[0:1.00.0.0.0]","failure":true},{"input":"https://[0:1.290.0.0.0]","failure":true},{"input":"https://[0:1.23.23]","failure":true},{"input":"http://?","failure":true},{"input":"http://#","failure":true},{"input":"sc://ñ"},{"input":"sc://ñ?x"},{"input":"sc://ñ#x"},{"input":"sc://?"},{"input":"sc://#"},{"input":"tftp://foobar.com/someconfig;mode=netascii"},{"input":"telnet://user:pass@foobar.com:23/"},{"input":"ut2004://10.10.10.10:7777/Index.ut2"},{"input":"redis://foo:bar@somehost:6379/0?baz=bam&qux=baz"},{"input":"rsync://foo@host:911/sup"},{"input":"git://github.com/foo/bar.git"},{"input":"irc://myserver.com:6999/channel?passwd"},{"input":"dns://fw.example.org:9999/foo.bar.org?type=TXT"},{"input":"ldap://localhost:389/ou=People,o=JNDITutorial"},{"input":"git+https://github.com/foo/bar"},{"input":"urn:ietf:rfc:2648"},{"input":"tag:joe@example.org,2001:foo/bar"},{"input":"non-spec:/.//"},{"input":"non-spec:/..//"},{"input":"non-spec:/a/..//"},{"input":"non-spec:/.//path"},{"input":"non-spec:/..//path"},{"input":"non-spec:/a/..//path"},{"input":"non-special://%E2%80%A0/"},{"input":"non-special://H%4fSt/path"},{"input":"non-special://[1:2:0:0:5:0:0:0]/"},{"input":"non-special://[1:2:0:0:0:0:0:3]/"},{"input":"non-special://[1:2::3]:80/"},{"input":"non-special://[:80/","failure":true},{"input":"blob:https://example.com:443/"},{"input":"blob:d3958f5c-0777-0845-9dcf-2cb28783acaf"},{"input":"http://0x7f.0.0.0x7g"},{"input":"http://0X7F.0.0.0X7G"},{"input":"http://[::127.0.0.0.1]","failure":true},{"input":"http://[0:1:0:1:0:1:0:1]"},{"input":"http://[1:0:1:0:1:0:1:0]"},{"input":"http://example.org/test?\""},{"input":"http://example.org/test?#"},{"input":"http://example.org/test?<"},{"input":"http://example.org/test?>"},{"input":"http://example.org/test?⌣"},{"input":"http://example.org/test?%23%23"},{"input":"http://example.org/test?%GH"},{"input":"http://example.org/test?a#%EF"},{"input":"http://example.org/test?a#%GH"},{"input":"a","failure":true},{"input":"a/","failure":true},{"input":"a//","failure":true},{"input":"http://example.org/test?a#b\u0000c"},{"input":"non-spec://example.org/test?a#b\u0000c"},{"input":"non-spec:/test?a#b\u0000c"},{"input":"file://a­b/p"},{"input":"file://a%C2%ADb/p"},{"input":"file://­/p","failure":true},{"input":"file://%C2%AD/p","failure":true},{"input":"file://xn--/p","failure":true},{"input":"non-special:cannot-be-a-base-url-\u0000\u0001\u001f\u001e~€"},{"input":"https://www.example.com/path{path.html?query'=query#fragment<fragment"},{"input":"foo:// !\"$%&'()*+,-.;<=>@[\\]^_`{|}~@host/"},{"input":"wss:// !\"$%&'()*+,-.;<=>@[]^_`{|}~@host/"},{"input":"foo://joe: !\"$%&'()*+,-.:;<=>@[\\]^_`{|}~@host/"},{"input":"wss://joe: !\"$%&'()*+,-.:;<=>@[]^_`{|}~@host/"},{"input":"foo://!\"$%&'()*+,-.;=_`{}~/"},{"input":"wss://!\"$&'()*+,-.;=_`{}~/"},{"input":"foo://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~"},{"input":"wss://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~"},{"input":"foo://host/dir/? !\"$%&'()*+,-./:;<=>?@[\\]^_`{|}~"},{"input":"wss://host/dir/? !\"$%&'()*+,-./:;<=>?@[\\]^_`{|}~"},{"input":"foo://host/dir/# !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"},{"input":"wss://host/dir/# !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"}] \ No newline at end of file +[{"input":"https://test:@test"},{"input":"https://:@test"},{"input":"non-special://test:@test/x"},{"input":"non-special://:@test/x"},{"input":"lolscheme:x x#x x"},{"input":"file://example:1/","failure":true},{"input":"file://example:test/","failure":true},{"input":"file://example%/","failure":true},{"input":"file://[example]/","failure":true},{"input":"http://example.com/././foo"},{"input":"http://example.com/./.foo"},{"input":"http://example.com/foo/."},{"input":"http://example.com/foo/./"},{"input":"http://example.com/foo/bar/.."},{"input":"http://example.com/foo/bar/../"},{"input":"http://example.com/foo/..bar"},{"input":"http://example.com/foo/bar/../ton"},{"input":"http://example.com/foo/bar/../ton/../../a"},{"input":"http://example.com/foo/../../.."},{"input":"http://example.com/foo/../../../ton"},{"input":"http://example.com/foo/%2e"},{"input":"http://example.com/foo/%2e%2"},{"input":"http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar"},{"input":"http://example.com////../.."},{"input":"http://example.com/foo/bar//../.."},{"input":"http://example.com/foo/bar//.."},{"input":"http://example.com/foo"},{"input":"http://example.com/%20foo"},{"input":"http://example.com/foo%"},{"input":"http://example.com/foo%2"},{"input":"http://example.com/foo%2zbar"},{"input":"http://example.com/foo%2©zbar"},{"input":"http://example.com/foo%41%7a"},{"input":"http://example.com/foo\t‘%91"},{"input":"http://example.com/foo%00%51"},{"input":"http://example.com/(%28:%3A%29)"},{"input":"http://example.com/%3A%3a%3C%3c"},{"input":"http://example.com/foo\tbar"},{"input":"http://example.com\\\\foo\\\\bar"},{"input":"http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd"},{"input":"http://example.com/@asdf%40"},{"input":"http://example.com/你好你好"},{"input":"http://example.com/‥/foo"},{"input":"http://example.com//foo"},{"input":"http://example.com/‮/foo/‭/bar"},{"input":"http://www.google.com/foo?bar=baz#"},{"input":"http://www.google.com/foo?bar=baz# »"},{"input":"data:test# »"},{"input":"http://www.google.com"},{"input":"http://192.0x00A80001"},{"input":"http://www/foo%2Ehtml"},{"input":"http://www/foo/%2E/html"},{"input":"http://user:pass@/","failure":true},{"input":"http://%25DOMAIN:foobar@foodomain.com/"},{"input":"http:\\\\www.google.com\\foo"},{"input":"http://foo:80/"},{"input":"http://foo:81/"},{"input":"httpa://foo:80/"},{"input":"http://foo:-80/","failure":true},{"input":"https://foo:443/"},{"input":"https://foo:80/"},{"input":"ftp://foo:21/"},{"input":"ftp://foo:80/"},{"input":"gopher://foo:70/"},{"input":"gopher://foo:443/"},{"input":"ws://foo:80/"},{"input":"ws://foo:81/"},{"input":"ws://foo:443/"},{"input":"ws://foo:815/"},{"input":"wss://foo:80/"},{"input":"wss://foo:81/"},{"input":"wss://foo:443/"},{"input":"wss://foo:815/"},{"input":"http:/example.com/"},{"input":"ftp:/example.com/"},{"input":"https:/example.com/"},{"input":"madeupscheme:/example.com/"},{"input":"file:/example.com/"},{"input":"ftps:/example.com/"},{"input":"gopher:/example.com/"},{"input":"ws:/example.com/"},{"input":"wss:/example.com/"},{"input":"data:/example.com/"},{"input":"javascript:/example.com/"},{"input":"mailto:/example.com/"},{"input":"http:example.com/"},{"input":"ftp:example.com/"},{"input":"https:example.com/"},{"input":"madeupscheme:example.com/"},{"input":"ftps:example.com/"},{"input":"gopher:example.com/"},{"input":"ws:example.com/"},{"input":"wss:example.com/"},{"input":"data:example.com/"},{"input":"javascript:example.com/"},{"input":"mailto:example.com/"},{"input":"http:@www.example.com"},{"input":"http:/@www.example.com"},{"input":"http://@www.example.com"},{"input":"http:a:b@www.example.com"},{"input":"http:/a:b@www.example.com"},{"input":"http://a:b@www.example.com"},{"input":"http://@pple.com"},{"input":"http::b@www.example.com"},{"input":"http:/:b@www.example.com"},{"input":"http://:b@www.example.com"},{"input":"http:/:@/www.example.com","failure":true},{"input":"http://user@/www.example.com","failure":true},{"input":"http:@/www.example.com","failure":true},{"input":"http:/@/www.example.com","failure":true},{"input":"http://@/www.example.com","failure":true},{"input":"https:@/www.example.com","failure":true},{"input":"http:a:b@/www.example.com","failure":true},{"input":"http:/a:b@/www.example.com","failure":true},{"input":"http://a:b@/www.example.com","failure":true},{"input":"http::@/www.example.com","failure":true},{"input":"http:a:@www.example.com"},{"input":"http:/a:@www.example.com"},{"input":"http://a:@www.example.com"},{"input":"http://www.@pple.com"},{"input":"http:@:www.example.com","failure":true},{"input":"http:/@:www.example.com","failure":true},{"input":"http://@:www.example.com","failure":true},{"input":"http://:@www.example.com"},{"input":"\u0000\u001b\u0004\u0012 http://example.com/\u001f \r "},{"input":"https://�","failure":true},{"input":"https://%EF%BF%BD","failure":true},{"input":"https://x/�?�#�"},{"input":"http://a.b.c.xn--pokxncvks","failure":true},{"input":"http://10.0.0.xn--pokxncvks","failure":true},{"input":"https://faß.ExAmPlE/"},{"input":"sc://faß.ExAmPlE/"},{"input":"https://x x:12","failure":true},{"input":"http://./"},{"input":"http://../"},{"input":"http://[www.google.com]/","failure":true},{"input":"http://host/?'"},{"input":"notspecial://host/?'"},{"input":"about:/../"},{"input":"data:/../"},{"input":"javascript:/../"},{"input":"mailto:/../"},{"input":"sc://ñ.test/"},{"input":"sc://\u0000/","failure":true},{"input":"sc:// /","failure":true},{"input":"sc://%/"},{"input":"sc://@/","failure":true},{"input":"sc://te@s:t@/","failure":true},{"input":"sc://:/","failure":true},{"input":"sc://:12/","failure":true},{"input":"sc://[/","failure":true},{"input":"sc://\\/","failure":true},{"input":"sc://]/","failure":true},{"input":"sc:\\../"},{"input":"sc::a@example.net"},{"input":"wow:%NBD"},{"input":"wow:%1G"},{"input":"wow:￿"},{"input":"http://example.com/\ud800𐟾\udfff﷐﷏﷯ﷰ￾￿?\ud800𐟾\udfff﷐﷏﷯ﷰ￾￿"},{"input":"http://a<b","failure":true},{"input":"http://a>b","failure":true},{"input":"http://a^b","failure":true},{"input":"non-special://a<b","failure":true},{"input":"non-special://a>b","failure":true},{"input":"non-special://a^b","failure":true},{"input":"foo://ho\u0000st/","failure":true},{"input":"foo://ho|st/","failure":true},{"input":"foo://ho\tst/"},{"input":"foo://ho\nst/"},{"input":"foo://ho\rst/"},{"input":"http://ho%00st/","failure":true},{"input":"http://ho%09st/","failure":true},{"input":"http://ho%0Ast/","failure":true},{"input":"http://ho%0Dst/","failure":true},{"input":"http://ho%20st/","failure":true},{"input":"http://ho%23st/","failure":true},{"input":"http://ho%2Fst/","failure":true},{"input":"http://ho%3Ast/","failure":true},{"input":"http://ho%3Cst/","failure":true},{"input":"http://ho%3Est/","failure":true},{"input":"http://ho%3Fst/","failure":true},{"input":"http://ho%40st/","failure":true},{"input":"http://ho%5Bst/","failure":true},{"input":"http://ho%5Cst/","failure":true},{"input":"http://ho%5Dst/","failure":true},{"input":"http://ho%7Cst/","failure":true},{"input":"sc://\u001f!\"$&'()*+,-.;=_`{}~/"},{"input":"ftp://example.com%80/","failure":true},{"input":"ftp://example.com%A0/","failure":true},{"input":"https://example.com%80/","failure":true},{"input":"https://example.com%A0/","failure":true},{"input":"ftp://%e2%98%83"},{"input":"https://%e2%98%83"},{"input":"http://127.0.0.1:10100/relative_import.html"},{"input":"http://facebook.com/?foo=%7B%22abc%22"},{"input":"https://localhost:3000/jqueryui@1.2.3"},{"input":"h\tt\nt\rp://h\to\ns\rt:9\t0\n0\r0/p\ta\nt\rh?q\tu\ne\rry#f\tr\na\rg"},{"input":"http://foo.bar/baz?qux#foo\bbar"},{"input":"http://foo.bar/baz?qux#foo\"bar"},{"input":"http://foo.bar/baz?qux#foo<bar"},{"input":"http://foo.bar/baz?qux#foo>bar"},{"input":"http://foo.bar/baz?qux#foo`bar"},{"input":"https://0x.0x.0"},{"input":"https://0x100000000/test","failure":true},{"input":"https://256.0.0.1/test","failure":true},{"input":"file:///C%3A/"},{"input":"file:///C%7C/"},{"input":"file://%43%3A","failure":true},{"input":"file://%43%7C","failure":true},{"input":"file://%43|","failure":true},{"input":"file://C%7C","failure":true},{"input":"file://%43%7C/","failure":true},{"input":"https://%43%7C/","failure":true},{"input":"asdf://%43|/","failure":true},{"input":"asdf://%43%7C/"},{"input":"file:\\\\//"},{"input":"file:\\\\\\\\"},{"input":"file:\\\\\\\\?fox"},{"input":"file:\\\\\\\\#guppy"},{"input":"file://spider///"},{"input":"file:\\\\localhost//"},{"input":"file:///localhost//cat"},{"input":"file://\\/localhost//cat"},{"input":"file://localhost//a//../..//"},{"input":"file://example.net/C:/"},{"input":"file://1.2.3.4/C:/"},{"input":"file://[1::8]/C:/"},{"input":"file:/C|/"},{"input":"file://C|/"},{"input":"file:"},{"input":"file:?q=v"},{"input":"file:#frag"},{"input":"file:///Y:"},{"input":"file:///Y:/"},{"input":"file:///./Y"},{"input":"file:///./Y:"},{"input":"\\\\\\.\\Y:","failure":true},{"input":"file:///y:"},{"input":"file:///y:/"},{"input":"file:///./y"},{"input":"file:///./y:"},{"input":"\\\\\\.\\y:","failure":true},{"input":"file://localhost//a//../..//foo"},{"input":"file://localhost////foo"},{"input":"file:////foo"},{"input":"file:.//p"},{"input":"file:/.//p"},{"input":"https://[0::0::0]","failure":true},{"input":"https://[0:.0]","failure":true},{"input":"https://[0:0:]","failure":true},{"input":"https://[0:1:2:3:4:5:6:7.0.0.0.1]","failure":true},{"input":"https://[0:1.00.0.0.0]","failure":true},{"input":"https://[0:1.290.0.0.0]","failure":true},{"input":"https://[0:1.23.23]","failure":true},{"input":"http://?","failure":true},{"input":"http://#","failure":true},{"input":"sc://ñ"},{"input":"sc://ñ?x"},{"input":"sc://ñ#x"},{"input":"sc://?"},{"input":"sc://#"},{"input":"tftp://foobar.com/someconfig;mode=netascii"},{"input":"telnet://user:pass@foobar.com:23/"},{"input":"ut2004://10.10.10.10:7777/Index.ut2"},{"input":"redis://foo:bar@somehost:6379/0?baz=bam&qux=baz"},{"input":"rsync://foo@host:911/sup"},{"input":"git://github.com/foo/bar.git"},{"input":"irc://myserver.com:6999/channel?passwd"},{"input":"dns://fw.example.org:9999/foo.bar.org?type=TXT"},{"input":"ldap://localhost:389/ou=People,o=JNDITutorial"},{"input":"git+https://github.com/foo/bar"},{"input":"urn:ietf:rfc:2648"},{"input":"tag:joe@example.org,2001:foo/bar"},{"input":"non-spec:/.//"},{"input":"non-spec:/..//"},{"input":"non-spec:/a/..//"},{"input":"non-spec:/.//path"},{"input":"non-spec:/..//path"},{"input":"non-spec:/a/..//path"},{"input":"non-special://%E2%80%A0/"},{"input":"non-special://H%4fSt/path"},{"input":"non-special://[1:2:0:0:5:0:0:0]/"},{"input":"non-special://[1:2:0:0:0:0:0:3]/"},{"input":"non-special://[1:2::3]:80/"},{"input":"non-special://[:80/","failure":true},{"input":"blob:https://example.com:443/"},{"input":"blob:d3958f5c-0777-0845-9dcf-2cb28783acaf"},{"input":"http://0x7f.0.0.0x7g"},{"input":"http://0X7F.0.0.0X7G"},{"input":"http://[::127.0.0.0.1]","failure":true},{"input":"http://[0:1:0:1:0:1:0:1]"},{"input":"http://[1:0:1:0:1:0:1:0]"},{"input":"http://example.org/test?\""},{"input":"http://example.org/test?#"},{"input":"http://example.org/test?<"},{"input":"http://example.org/test?>"},{"input":"http://example.org/test?⌣"},{"input":"http://example.org/test?%23%23"},{"input":"http://example.org/test?%GH"},{"input":"http://example.org/test?a#%EF"},{"input":"http://example.org/test?a#%GH"},{"input":"a","failure":true},{"input":"a/","failure":true},{"input":"a//","failure":true},{"input":"http://example.org/test?a#b\u0000c"},{"input":"non-spec://example.org/test?a#b\u0000c"},{"input":"non-spec:/test?a#b\u0000c"},{"input":"file://a­b/p"},{"input":"file://a%C2%ADb/p"},{"input":"file://­/p","failure":true},{"input":"file://%C2%AD/p","failure":true},{"input":"file://xn--/p","failure":true},{"input":"non-special:cannot-be-a-base-url-\u0000\u0001\u001f\u001e~€"},{"input":"https://www.example.com/path{path.html?query'=query#fragment<fragment"},{"input":"foo:// !\"$%&'()*+,-.;<=>@[\\]^_`{|}~@host/"},{"input":"wss:// !\"$%&'()*+,-.;<=>@[]^_`{|}~@host/"},{"input":"foo://joe: !\"$%&'()*+,-.:;<=>@[\\]^_`{|}~@host/"},{"input":"wss://joe: !\"$%&'()*+,-.:;<=>@[]^_`{|}~@host/"},{"input":"foo://!\"$%&'()*+,-.;=_`{}~/"},{"input":"wss://!\"$&'()*+,-.;=_`{}~/"},{"input":"foo://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~"},{"input":"wss://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~"},{"input":"foo://host/dir/? !\"$%&'()*+,-./:;<=>?@[\\]^_`{|}~"},{"input":"wss://host/dir/? !\"$%&'()*+,-./:;<=>?@[\\]^_`{|}~"},{"input":"foo://host/dir/# !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"},{"input":"wss://host/dir/# !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"}] \ No newline at end of file diff --git a/packages/viewport/CHANGELOG.md b/packages/viewport/CHANGELOG.md index 0446c090f28c29..eb1fb65de3bb4e 100644 --- a/packages/viewport/CHANGELOG.md +++ b/packages/viewport/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 5.17.0 (2023-08-16) + +## 5.16.0 (2023-08-10) + +## 5.15.0 (2023-07-20) + +## 5.14.0 (2023-07-05) + +## 5.13.0 (2023-06-23) + +## 5.12.0 (2023-06-07) + ## 5.11.0 (2023-05-24) ## 5.10.0 (2023-05-10) diff --git a/packages/viewport/package.json b/packages/viewport/package.json index b770b99b77161d..981e39c3527ff0 100644 --- a/packages/viewport/package.json +++ b/packages/viewport/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/viewport", - "version": "5.11.0", + "version": "5.17.0", "description": "Viewport module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -27,7 +27,8 @@ "dependencies": { "@babel/runtime": "^7.16.0", "@wordpress/compose": "file:../compose", - "@wordpress/data": "file:../data" + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element" }, "peerDependencies": { "react": "^18.0.0" diff --git a/packages/warning/CHANGELOG.md b/packages/warning/CHANGELOG.md index facc4a8a91c10e..93d9395d68c251 100644 --- a/packages/warning/CHANGELOG.md +++ b/packages/warning/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 2.40.0 (2023-08-16) + +## 2.39.0 (2023-08-10) + +## 2.38.0 (2023-07-20) + +## 2.37.0 (2023-07-05) + +## 2.36.0 (2023-06-23) + +## 2.35.0 (2023-06-07) + ## 2.34.0 (2023-05-24) ## 2.33.0 (2023-05-10) diff --git a/packages/warning/README.md b/packages/warning/README.md index 6adfe73b6d4594..67b471d154eccf 100644 --- a/packages/warning/README.md +++ b/packages/warning/README.md @@ -20,7 +20,7 @@ To prevent that, you should: 1. Put `@wordpress/warning/babel-plugin` into your [babel config](https://babeljs.io/docs/en/plugins#plugin-options) or use [`@wordpress/babel-preset-default`](https://www.npmjs.com/package/@wordpress/babel-preset-default), which already includes the babel plugin. - This will make sure your `warning` calls are wrapped within a condition that checks if `process.env.NODE_ENV !== 'production'`. + This will make sure your `warning` calls are wrapped within a condition that checks if `SCRIPT_DEBUG === true`. 2. Use [UglifyJS](https://github.com/mishoo/UglifyJS2), [Terser](https://github.com/terser/terser) or any other JavaScript parser that performs [dead code elimination](https://en.wikipedia.org/wiki/Dead_code_elimination). This is usually used in conjunction with JavaScript bundlers, such as [webpack](https://github.com/webpack/webpack). diff --git a/packages/warning/babel-plugin.js b/packages/warning/babel-plugin.js index f94de3a4f4ca28..02c466b5de24ec 100644 --- a/packages/warning/babel-plugin.js +++ b/packages/warning/babel-plugin.js @@ -5,7 +5,7 @@ const pkg = require( './package.json' ); /** * Babel plugin which transforms `warning` function calls to wrap within a - * condition that checks if `process.env.NODE_ENV !== 'production'`. + * condition that checks if `SCRIPT_DEBUG === true`. * * @param {import('@babel/core')} babel Current Babel object. * @@ -16,34 +16,20 @@ function babelPlugin( { types: t } ) { const typeofProcessExpression = t.binaryExpression( '!==', - t.unaryExpression( 'typeof', t.identifier( 'process' ), false ), + t.unaryExpression( 'typeof', t.identifier( 'SCRIPT_DEBUG' ), false ), t.stringLiteral( 'undefined' ) ); - const processEnvExpression = t.memberExpression( - t.identifier( 'process' ), - t.identifier( 'env' ), - false - ); - - const nodeEnvCheckExpression = t.binaryExpression( - '!==', - t.memberExpression( - processEnvExpression, - t.identifier( 'NODE_ENV' ), - false - ), - t.stringLiteral( 'production' ) + const scriptDebugCheckExpression = t.binaryExpression( + '===', + t.identifier( 'SCRIPT_DEBUG' ), + t.booleanLiteral( true ) ); const logicalExpression = t.logicalExpression( '&&', - t.logicalExpression( - '&&', - typeofProcessExpression, - processEnvExpression - ), - nodeEnvCheckExpression + typeofProcessExpression, + scriptDebugCheckExpression ); return { @@ -80,7 +66,7 @@ function babelPlugin( { types: t } ) { // Turns this code: // warning(argument); // into this: - // typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warning(argument) : void 0; + // typeof SCRIPT_DEBUG !== 'undefined' && SCRIPT_DEBUG === true ? warning(argument) : void 0; node[ seen ] = true; path.replaceWith( t.ifStatement( diff --git a/packages/warning/package.json b/packages/warning/package.json index 15eb619b71b0fc..09a83537ec8c6d 100644 --- a/packages/warning/package.json +++ b/packages/warning/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/warning", - "version": "2.34.0", + "version": "2.40.0", "description": "Warning utility for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/warning/src/index.js b/packages/warning/src/index.js index 38afc42caf0b0b..89ce71db112a21 100644 --- a/packages/warning/src/index.js +++ b/packages/warning/src/index.js @@ -4,11 +4,7 @@ import { logged } from './utils'; function isDev() { - return ( - typeof process !== 'undefined' && - process.env && - process.env.NODE_ENV !== 'production' - ); + return typeof SCRIPT_DEBUG !== 'undefined' && SCRIPT_DEBUG === true; } /** diff --git a/packages/warning/src/test/index.js b/packages/warning/src/test/index.js index a7ecbfb4a8ffec..a32e5f1e0fae4e 100644 --- a/packages/warning/src/test/index.js +++ b/packages/warning/src/test/index.js @@ -4,27 +4,28 @@ import warning from '..'; import { logged } from '../utils'; -const initialNodeEnv = process.env.NODE_ENV; - describe( 'warning', () => { + const initialScriptDebug = global.SCRIPT_DEBUG; + afterEach( () => { - process.env.NODE_ENV = initialNodeEnv; + global.SCRIPT_DEBUG = initialScriptDebug; logged.clear(); } ); - it( 'logs to console.warn when NODE_ENV is not "production"', () => { - process.env.NODE_ENV = 'development'; + it( 'logs to console.warn when SCRIPT_DEBUG is set to `true`', () => { + global.SCRIPT_DEBUG = true; warning( 'warning' ); expect( console ).toHaveWarnedWith( 'warning' ); } ); - it( 'does not log to console.warn if NODE_ENV is "production"', () => { - process.env.NODE_ENV = 'production'; + it( 'does not log to console.warn if SCRIPT_DEBUG not set to `true`', () => { + global.SCRIPT_DEBUG = false; warning( 'warning' ); expect( console ).not.toHaveWarned(); } ); it( 'should show a message once', () => { + global.SCRIPT_DEBUG = true; warning( 'warning' ); warning( 'warning' ); diff --git a/packages/warning/test/babel-plugin.js b/packages/warning/test/babel-plugin.js index a98d270a9845bc..a3c4bd55745efd 100644 --- a/packages/warning/test/babel-plugin.js +++ b/packages/warning/test/babel-plugin.js @@ -28,7 +28,7 @@ describe( 'babel-plugin', () => { ); const expected = join( 'import warning from "@wordpress/warning";', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warning("a") : void 0;' + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warning("a") : void 0;' ); expect( transformCode( input ) ).toEqual( expected ); @@ -45,7 +45,7 @@ describe( 'babel-plugin', () => { const input = 'warning("a");'; const options = { callee: 'warning' }; const expected = - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warning("a") : void 0;'; + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warning("a") : void 0;'; expect( transformCode( input, options ) ).toEqual( expected ); } ); @@ -59,9 +59,9 @@ describe( 'babel-plugin', () => { ); const expected = join( 'import warning from "@wordpress/warning";', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warning("a") : void 0;', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warning("b") : void 0;', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warning("c") : void 0;' + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warning("a") : void 0;', + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warning("b") : void 0;', + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warning("c") : void 0;' ); expect( transformCode( input ) ).toEqual( expected ); @@ -76,9 +76,9 @@ describe( 'babel-plugin', () => { ); const expected = join( 'import warn from "@wordpress/warning";', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warn("a") : void 0;', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warn("b") : void 0;', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warn("c") : void 0;' + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warn("a") : void 0;', + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warn("b") : void 0;', + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warn("c") : void 0;' ); expect( transformCode( input ) ).toEqual( expected ); @@ -93,9 +93,9 @@ describe( 'babel-plugin', () => { ); const expected = join( 'import warn from "@wordpress/warning";', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warn("a") : void 0;', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warn("b") : void 0;', - 'typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production" ? warn("c") : void 0;' + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warn("a") : void 0;', + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warn("b") : void 0;', + 'typeof SCRIPT_DEBUG !== "undefined" && SCRIPT_DEBUG === true ? warn("c") : void 0;' ); expect( transformCode( input ) ).toEqual( expected ); diff --git a/packages/widgets/CHANGELOG.md b/packages/widgets/CHANGELOG.md index 1b3aa84c9b943e..856dc7555bad04 100644 --- a/packages/widgets/CHANGELOG.md +++ b/packages/widgets/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.17.0 (2023-08-16) + +## 3.16.0 (2023-08-10) + +## 3.15.0 (2023-07-20) + +## 3.14.0 (2023-07-05) + +## 3.13.0 (2023-06-23) + +## 3.12.0 (2023-06-07) + ## 3.11.0 (2023-05-24) ## 3.10.0 (2023-05-10) diff --git a/packages/widgets/package.json b/packages/widgets/package.json index c9229e3b9e6276..4650479b71b478 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/widgets", - "version": "3.11.0", + "version": "3.17.0", "description": "Functionality used by the widgets block editor in the Widgets screen and the Customizer.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/widgets/src/blocks/legacy-widget/block.json b/packages/widgets/src/blocks/legacy-widget/block.json index 30b60c6448835e..6b0c1e2a916fdd 100644 --- a/packages/widgets/src/blocks/legacy-widget/block.json +++ b/packages/widgets/src/blocks/legacy-widget/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "core/legacy-widget", "title": "Legacy Widget", "category": "widgets", diff --git a/packages/widgets/src/blocks/widget-group/block.json b/packages/widgets/src/blocks/widget-group/block.json index ec48d90eda5ca7..c29e811554ac11 100644 --- a/packages/widgets/src/blocks/widget-group/block.json +++ b/packages/widgets/src/blocks/widget-group/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "core/widget-group", "category": "widgets", "attributes": { diff --git a/packages/wordcount/CHANGELOG.md b/packages/wordcount/CHANGELOG.md index 196a02a47e1209..d0b54b70a81d47 100644 --- a/packages/wordcount/CHANGELOG.md +++ b/packages/wordcount/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.40.0 (2023-08-16) + +## 3.39.0 (2023-08-10) + +## 3.38.0 (2023-07-20) + +## 3.37.0 (2023-07-05) + +## 3.36.0 (2023-06-23) + +## 3.35.0 (2023-06-07) + ## 3.34.0 (2023-05-24) ## 3.33.0 (2023-05-10) diff --git a/packages/wordcount/package.json b/packages/wordcount/package.json index a0ed6ee5bc5b8c..f0f5cf017f7e1b 100644 --- a/packages/wordcount/package.json +++ b/packages/wordcount/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/wordcount", - "version": "3.34.0", + "version": "3.40.0", "description": "WordPress word count utility.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/patches/@react-navigation+native+5.7.0.patch b/patches/@react-navigation+native+5.7.0.patch deleted file mode 100644 index 8f7cc19c1f9489..00000000000000 --- a/patches/@react-navigation+native+5.7.0.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/node_modules/@react-navigation/native/src/useLinking.native.tsx b/node_modules/@react-navigation/native/src/useLinking.native.tsx -index 22274f1..acc95f5 100644 ---- a/node_modules/@react-navigation/native/src/useLinking.native.tsx -+++ b/node_modules/@react-navigation/native/src/useLinking.native.tsx -@@ -113,9 +113,12 @@ export default function useLinking( - } - }; - -- Linking.addEventListener('url', listener); -+ // The following `linkingSubscription` changes were added via -+ // patch-package. The patch can be removed once we upgrade to -+ // react-navigation@^6.0.0. https://git.io/JP7OG -+ const linkingSubscription = Linking.addEventListener('url', listener); - -- return () => Linking.removeEventListener('url', listener); -+ return () => linkingSubscription.remove(); - }, [enabled, extractPathFromURL, ref]); - - return { diff --git a/patches/react-devtools-core+4.24.0.patch b/patches/react-devtools-core+4.24.0.patch deleted file mode 100644 index 8730e5c5c6f18d..00000000000000 --- a/patches/react-devtools-core+4.24.0.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-devtools-core/dist/backend.js b/node_modules/react-devtools-core/dist/backend.js -index b9ada48..135ee39 100644 ---- a/node_modules/react-devtools-core/dist/backend.js -+++ b/node_modules/react-devtools-core/dist/backend.js -@@ -14106,7 +14106,7 @@ function hideOverlay() { - } - function showOverlay(elements, componentName, hideAfterTimeout) { - // TODO (npm-packages) Detect RN and support it somehow -- if (window.document == null) { -+ if (window.document == null || window.document.__isJsdom) { - return; - } - diff --git a/patches/react-devtools-core+4.28.0.patch b/patches/react-devtools-core+4.28.0.patch new file mode 100644 index 00000000000000..490d11b5e04ad3 --- /dev/null +++ b/patches/react-devtools-core+4.28.0.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-devtools-core/dist/backend.js b/node_modules/react-devtools-core/dist/backend.js +index b17dd1c..acb41ad 100644 +--- a/node_modules/react-devtools-core/dist/backend.js ++++ b/node_modules/react-devtools-core/dist/backend.js +@@ -4819,7 +4819,7 @@ function hideOverlay(agent) { + } + } + function showOverlay(elements, componentName, agent, hideAfterTimeout) { +- if (window.document == null) { ++ if (window.document == null || window.document.__isJsdom) { + if (elements != null && elements[0] != null) { + agent.emit('showNativeHighlight', elements[0]); + } diff --git a/patches/react-native+0.71.11.patch b/patches/react-native+0.71.11.patch new file mode 100644 index 00000000000000..d6248810094668 --- /dev/null +++ b/patches/react-native+0.71.11.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +index 0758df6..18c5687 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +@@ -1410,7 +1410,6 @@ function InternalTextInput(props: Props): React.Node { + {...props} + {...eventHandlers} + accessible={accessible} +- accessibilityState={_accessibilityState} + submitBehavior={submitBehavior} + caretHidden={caretHidden} + dataDetectorTypes={props.dataDetectorTypes} diff --git a/patches/react-native-modal+11.10.0.patch b/patches/react-native-modal+11.10.0.patch deleted file mode 100644 index 0f94cc513322f4..00000000000000 --- a/patches/react-native-modal+11.10.0.patch +++ /dev/null @@ -1,33 +0,0 @@ -diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js -index 165cd55..85f820c 100644 ---- a/node_modules/react-native-modal/dist/modal.js -+++ b/node_modules/react-native-modal/dist/modal.js -@@ -403,7 +403,13 @@ export class ReactNativeModal extends React.Component { - if (this.props.onSwipe) { - console.warn('`<Modal onSwipe="..." />` is deprecated and will be removed starting from 13.0.0. Use `<Modal onSwipeComplete="..." />` instead.'); - } -- DeviceEventEmitter.addListener('didUpdateDimensions', this.handleDimensionsUpdate); -+ // The following `didUpdateDimensionsEmitter` changes were added via -+ // patch-package. The patch can be removed once we upgrade to -+ // react-native-modal@^13.0.0. https://git.io/JPQgq -+ this.didUpdateDimensionsEmitter = DeviceEventEmitter.addListener( -+ 'didUpdateDimensions', -+ this.handleDimensionsUpdate -+ ); - if (this.state.isVisible) { - this.open(); - } -@@ -411,7 +417,12 @@ export class ReactNativeModal extends React.Component { - } - componentWillUnmount() { - BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); -- DeviceEventEmitter.removeListener('didUpdateDimensions', this.handleDimensionsUpdate); -+ // The following `didUpdateDimensionsEmitter` changes were added via -+ // patch-package. The patch can be removed once we upgrade to -+ // react-native-modal@^13.0.0. https://git.io/JPQgq -+ if ( this.didUpdateDimensionsEmitter ) { -+ this.didUpdateDimensionsEmitter.remove(); -+ } - } - componentDidUpdate(prevProps) { - // If the animations have been changed then rebuild them to make sure we're diff --git a/patches/react-native-reanimated+2.17.0.patch b/patches/react-native-reanimated+2.17.0.patch new file mode 100644 index 00000000000000..8d4d634ea13747 --- /dev/null +++ b/patches/react-native-reanimated+2.17.0.patch @@ -0,0 +1,14 @@ +This patch will be removed when the Gutenberg demo app uses Hermes. + +diff --git a/node_modules/react-native-reanimated/RNReanimated.podspec b/node_modules/react-native-reanimated/RNReanimated.podspec +index 1cbeafc..34f49db 100644 +--- a/node_modules/react-native-reanimated/RNReanimated.podspec ++++ b/node_modules/react-native-reanimated/RNReanimated.podspec +@@ -80,6 +80,7 @@ Pod::Spec.new do |s| + s.dependency 'Yoga' + s.dependency 'DoubleConversion' + s.dependency 'glog' ++ s.dependency 'React-jsc' + + if config[:react_native_minor_version] == 62 + s.dependency 'ReactCommon/callinvoker' diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 6f40a38522ad6c..e8c672f130f7c5 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -2,7 +2,8 @@ <ruleset name="WordPress Coding Standards for Gutenberg Plugin"> <description>Sniffs for WordPress plugins, with minor modifications for Gutenberg</description> - <config name="testVersion" value="5.6-"/> + <!-- Check for cross-version support for PHP 7.0 and higher. --> + <config name="testVersion" value="7.0-"/> <rule ref="PHPCompatibilityWP"> <include-pattern>*\.php$</include-pattern> </rule> @@ -50,6 +51,7 @@ <!-- Exclude third party libraries --> <exclude-pattern>./vendor/*</exclude-pattern> + <exclude-pattern>./test/php/gutenberg-coding-standards/*</exclude-pattern> <!-- These special comments are markers for the build process --> <rule ref="Squiz.Commenting.InlineComment.WrongStyle"> @@ -83,6 +85,8 @@ <exclude-pattern>phpunit/*</exclude-pattern> </rule> <rule ref="Squiz.Commenting.FunctionCommentThrowTag.Missing"> + <!-- Ignore until squizlabs/PHP_CodeSniffer#3685 is fixed: it thinks that a caught exception escapes the function. --> + <exclude-pattern>lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php</exclude-pattern> <exclude-pattern>phpunit/*</exclude-pattern> </rule> @@ -109,4 +113,74 @@ <rule ref="PEAR.NamingConventions.ValidClassName.Invalid"> <exclude-pattern>/phpunit/*</exclude-pattern> </rule> + + <!-- Enforce checks against redeclaration for functions and classes. --> + <rule ref="Gutenberg.CodeAnalysis.GuardedFunctionAndClassNames"> + <exclude-pattern>./phpunit/*</exclude-pattern> + <exclude-pattern>./packages/*</exclude-pattern> + <exclude-pattern>./bin/generate-gutenberg-php</exclude-pattern> + <properties> + <property name="functionsWhiteList" type="array"> + <element value="/^_?gutenberg.+/"/> + </property> + <property name="classesWhiteList" type="array"> + <element value="/^Gutenberg.+/"/> + </property> + </properties> + </rule> + + <rule ref="Gutenberg.NamingConventions.ValidBlockLibraryFunctionName"> + <include-pattern>./packages/block-library/src/*/*.php</include-pattern> + <properties> + <property name="prefixes" type="array"> + <element value="block_core_"/> + <element value="render_block_core_"/> + <element value="register_block_core_"/> + </property> + <!-- + The following list of functions is final and must not be extended. + It includes functions that cannot be renamed due to backward compatibility concerns. + --> + <property name="allowed_functions" type="array"> + <element value="_delete_custom_logo_on_remove_site_logo"/> + <element value="_delete_site_logo_on_remove_custom_logo"/> + <element value="_delete_site_logo_on_remove_custom_logo_on_setup_theme"/> + <element value="_delete_site_logo_on_remove_theme_mods"/> + <element value="_override_custom_logo_theme_mod"/> + <element value="_sync_custom_logo_to_site_logo"/> + <element value="_wp_rest_api_autosave_meta"/> + <element value="_wp_rest_api_force_autosave_difference"/> + <element value="apply_block_core_search_border_style"/> + <element value="apply_block_core_search_border_styles"/> + <element value="build_dropdown_script_block_core_categories"/> + <element value="build_template_part_block_area_variations"/> + <element value="build_template_part_block_instance_variations"/> + <element value="build_template_part_block_variations"/> + <element value="build_variation_for_navigation_link"/> + <element value="classnames_for_block_core_search"/> + <element value="comments_block_form_defaults"/> + <element value="enqueue_legacy_post_comments_block_styles"/> + <element value="get_block_core_avatar_border_attributes"/> + <element value="get_block_core_post_featured_image_border_attributes"/> + <element value="get_block_core_post_featured_image_overlay_element_markup"/> + <element value="get_border_color_classes_for_block_core_search"/> + <element value="get_color_classes_for_block_core_search"/> + <element value="get_typography_classes_for_block_core_search"/> + <element value="get_typography_styles_for_block_core_search"/> + <element value="gutenberg_block_core_file_update_interactive_view_script"/> + <element value="gutenberg_block_core_navigation_update_interactive_view_script"/> + <element value="post_comments_form_block_form_defaults"/> + <element value="register_block_core_site_icon_setting"/> + <element value="register_legacy_post_comments_block"/> + <element value="styles_for_block_core_search"/> + <element value="wp_add_footnotes_revisions_to_post_meta"/> + <element value="wp_add_footnotes_to_revision"/> + <element value="wp_get_footnotes_from_revision"/> + <element value="wp_keep_footnotes_revision_id"/> + <element value="wp_latest_comments_draft_or_post_title"/> + <element value="wp_restore_footnotes_from_revision"/> + <element value="wp_save_footnotes_meta"/> + </property> + </properties> + </rule> </ruleset> diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2bc6b7b29900de..4a4c9bf6569356 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,15 +9,18 @@ > <php> <env name="WORDPRESS_TABLE_PREFIX" value="wptests_" /> + <const name="FONT_LIBRARY_ENABLE" value="true"/> </php> <testsuites> <testsuite name="default"> <directory suffix="-test.php">./phpunit/</directory> + <directory suffix=".php">./phpunit/tests/</directory> </testsuite> </testsuites> <groups> <exclude> <group>ms-required</group> + <group>fontsapi</group> </exclude> </groups> </phpunit> diff --git a/phpunit/block-supports/border-test.php b/phpunit/block-supports/border-test.php index 7c8bd9975d35bb..858e4e92cc1740 100644 --- a/phpunit/block-supports/border-test.php +++ b/phpunit/block-supports/border-test.php @@ -36,7 +36,7 @@ private function register_bordered_block_with_support( $block_name, $supports = register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'borderColor' => array( 'type' => 'string', diff --git a/phpunit/block-supports/colors-test.php b/phpunit/block-supports/colors-test.php index f21f6477f4162a..310b7e6c525cbf 100644 --- a/phpunit/block-supports/colors-test.php +++ b/phpunit/block-supports/colors-test.php @@ -28,7 +28,7 @@ public function test_color_slugs_with_numbers_are_kebab_cased_properly() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'textColor' => array( 'type' => 'string', @@ -69,7 +69,7 @@ public function test_color_with_skipped_serialization_block_supports() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -107,7 +107,7 @@ public function test_gradient_with_individual_skipped_serialization_block_suppor register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', diff --git a/phpunit/block-supports/dimensions-test.php b/phpunit/block-supports/dimensions-test.php index 2ec0859b7cb7f8..d428977c2c0a6d 100644 --- a/phpunit/block-supports/dimensions-test.php +++ b/phpunit/block-supports/dimensions-test.php @@ -28,7 +28,7 @@ public function test_dimensions_style_is_applied() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -64,7 +64,7 @@ public function test_dimensions_with_skipped_serialization_block_supports() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -99,7 +99,7 @@ public function test_min_height_with_individual_skipped_serialization_block_supp register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', diff --git a/phpunit/block-supports/position-test.php b/phpunit/block-supports/position-test.php index c304ffa68e7de5..d9f97060cf6478 100644 --- a/phpunit/block-supports/position-test.php +++ b/phpunit/block-supports/position-test.php @@ -78,7 +78,7 @@ public function test_position_block_support( $theme_name, $block_name, $position register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', diff --git a/phpunit/block-supports/spacing-test.php b/phpunit/block-supports/spacing-test.php index 1ac0c86e620428..93df6f55ac109e 100644 --- a/phpunit/block-supports/spacing-test.php +++ b/phpunit/block-supports/spacing-test.php @@ -28,7 +28,7 @@ public function test_spacing_style_is_applied() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -73,7 +73,7 @@ public function test_spacing_with_skipped_serialization_block_supports() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -117,7 +117,7 @@ public function test_margin_with_individual_skipped_serialization_block_supports register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', diff --git a/phpunit/block-supports/typography-test.php b/phpunit/block-supports/typography-test.php index 6fd81bcbeca6ca..f6d5344222678c 100644 --- a/phpunit/block-supports/typography-test.php +++ b/phpunit/block-supports/typography-test.php @@ -78,7 +78,7 @@ public function test_should_kebab_case_font_size_slug_with_numbers() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'fontSize' => array( 'type' => 'string', @@ -112,7 +112,7 @@ public function test_should_generate_font_family_with_legacy_inline_styles_using register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -145,7 +145,7 @@ public function test_should_skip_serialization_for_typography_block_supports() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -191,7 +191,7 @@ public function test_should_skip_serialization_for_letter_spacing_block_supports register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -227,7 +227,7 @@ public function test_should_generate_css_var_for_font_family_with_legacy_inline_ register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -260,7 +260,7 @@ public function test_should_generate_classname_for_font_family() { register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -432,6 +432,30 @@ public function data_generate_font_size_preset_fixtures() { 'expected_output' => 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 0.789), 28px)', ), + 'returns clamp value where min and max fluid values defined' => array( + 'font_size' => array( + 'size' => '80px', + 'fluid' => array( + 'min' => '70px', + 'max' => '125px', + ), + ), + 'should_use_fluid_typography' => true, + 'expected_output' => 'clamp(70px, 4.375rem + ((1vw - 3.2px) * 4.297), 125px)', + ), + + 'returns clamp value where max is equal to size' => array( + 'font_size' => array( + 'size' => '7.8125rem', + 'fluid' => array( + 'min' => '4.375rem', + 'max' => '7.8125rem', + ), + ), + 'should_use_fluid_typography' => true, + 'expected_output' => 'clamp(4.375rem, 4.375rem + ((1vw - 0.2rem) * 4.298), 7.8125rem)', + ), + 'returns clamp value if min font size is greater than max' => array( 'font_size' => array( 'size' => '3rem', @@ -601,7 +625,7 @@ public function test_should_covert_font_sizes_to_fluid_values( $font_size_value, register_block_type( $this->test_block_name, array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'style' => array( 'type' => 'object', @@ -655,13 +679,18 @@ public function data_generate_block_supports_font_size_fixtures() { 'returns clamp value using custom fluid config' => array( 'font_size_value' => '17px', 'theme_slug' => 'block-theme-child-with-fluid-typography-config', - 'expected_output' => 'font-size:clamp(16px, 1rem + ((1vw - 3.2px) * 0.147), 17px);', + 'expected_output' => 'font-size:clamp(16px, 1rem + ((1vw - 6.4px) * 0.179), 17px);', ), 'returns value when font size <= custom min font size bound' => array( 'font_size_value' => '15px', 'theme_slug' => 'block-theme-child-with-fluid-typography-config', 'expected_output' => 'font-size:15px;', ), + 'returns clamp value using default config if layout is fluid' => array( + 'font_size_value' => '15px', + 'theme_slug' => 'block-theme-child-with-fluid-layout', + 'expected_output' => 'font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.078), 15px);', + ), ); } @@ -863,4 +892,87 @@ public function data_invalid_size_wp_get_typography_value_and_unit() { 'size: array' => array( array( '10' ) ), ); } + + /** + * Tests computed font size values. + * + * @covers ::gutenberg_get_computed_fluid_typography_value + * + * @dataProvider data_get_computed_fluid_typography_value + * + * @param array $args { + * Optional. An associative array of values to calculate a fluid formula for font size. Default is empty array. + * + * @type string $maximum_viewport_width Maximum size up to which type will have fluidity. + * @type string $minimum_viewport_width Minimum viewport size from which type will have fluidity. + * @type string $maximum_font_size Maximum font size for any clamp() calculation. + * @type string $minimum_font_size Minimum font size for any clamp() calculation. + * @type int $scale_factor A scale factor to determine how fast a font scales within boundaries. + * } + * @param string $expected_output Expected value of style property from gutenberg_apply_typography_support(). + */ + public function test_get_computed_fluid_typography_value( $args, $expected_output ) { + $actual = gutenberg_get_computed_fluid_typography_value( $args ); + $this->assertSame( $expected_output, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_computed_fluid_typography_value() { + return array( + 'returns clamped value with valid args' => array( + 'args' => array( + 'minimum_viewport_width' => '320px', + 'maximum_viewport_width' => '1000px', + 'minimum_font_size' => '50px', + 'maximum_font_size' => '100px', + 'scale_factor' => 1, + ), + 'expected_output' => 'clamp(50px, 3.125rem + ((1vw - 3.2px) * 7.353), 100px)', + ), + 'returns `null` when `maximum_viewport_width` is an unsupported unit' => array( + 'args' => array( + 'minimum_viewport_width' => '320px', + 'maximum_viewport_width' => 'calc(100% - 60px)', + 'minimum_font_size' => '50px', + 'maximum_font_size' => '100px', + 'scale_factor' => 1, + ), + 'expected_output' => null, + ), + 'returns `null` when `minimum_viewport_width` is an unsupported unit' => array( + 'args' => array( + 'minimum_viewport_width' => 'calc(100% - 60px)', + 'maximum_viewport_width' => '1000px', + 'minimum_font_size' => '50px', + 'maximum_font_size' => '100px', + 'scale_factor' => 1, + ), + 'expected_output' => null, + ), + 'returns `null` when `minimum_font_size` is an unsupported unit' => array( + 'args' => array( + 'minimum_viewport_width' => '320em', + 'maximum_viewport_width' => '1000em', + 'minimum_font_size' => '10vw', + 'maximum_font_size' => '100em', + 'scale_factor' => 1, + ), + 'expected_output' => null, + ), + 'returns `null` when `maximum_font_size` is an unsupported unit' => array( + 'args' => array( + 'minimum_viewport_width' => '320em', + 'maximum_viewport_width' => '1000em', + 'minimum_font_size' => '50px', + 'maximum_font_size' => '100%', + 'scale_factor' => 1, + ), + 'expected_output' => null, + ), + ); + } } diff --git a/phpunit/block-template-utils-test.php b/phpunit/block-template-utils-test.php index ac2e4145cd0ed2..08fb0c2c2c1e3b 100644 --- a/phpunit/block-template-utils-test.php +++ b/phpunit/block-template-utils-test.php @@ -9,9 +9,6 @@ class Tests_Block_Template_Utils extends WP_UnitTestCase { public function set_up() { parent::set_up(); switch_theme( 'emptytheme' ); - } - - public static function wpSetUpBeforeClass() { register_post_type( 'custom_book', array( @@ -22,9 +19,10 @@ public static function wpSetUpBeforeClass() { register_taxonomy( 'book_type', 'custom_book' ); } - public static function wpTearDownAfterClass() { + public function tear_down() { unregister_post_type( 'custom_book' ); unregister_taxonomy( 'book_type' ); + parent::tear_down(); } public function test_get_template_hierarchy() { diff --git a/phpunit/blocks/render-block-file-test.php b/phpunit/blocks/render-block-file-test.php index 7fdeb60a707a9e..1dffc320d9671e 100644 --- a/phpunit/blocks/render-block-file-test.php +++ b/phpunit/blocks/render-block-file-test.php @@ -27,7 +27,13 @@ public function test_render_block_core_file() { ); $content = '<div class="wp-block-file"><object class="wp-block-file__embed" data="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf" type="application/pdf" style="width:100%;height:370px" aria-label="yolo"></object><a id="wp-block-file--media-_clientId_0" href="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf">yolo</a><a href="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf" class="wp-block-file__button wp-element-button" download aria-describedby="wp-block-file--media-_clientId_0">Download</a></div>'; - $new_content = gutenberg_render_block_core_file( $attributes, $content ); + $parsed_blocks = parse_blocks( + '<!-- wp:file {"href":"http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf","displayPreview":true} -->' + ); + $parsed_block = $parsed_blocks[0]; + $block = new WP_Block( $parsed_block ); + + $new_content = gutenberg_render_block_core_file( $attributes, $content, $block ); $this->assertStringContainsString( 'aria-label="Embed of yolo."', $new_content ); } @@ -45,7 +51,13 @@ public function test_render_block_core_file_custom_filename() { ); $content = '<div class="wp-block-file"><object class="wp-block-file__embed" data="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf" type="application/pdf" style="width:100%;height:370px" aria-label="custom filename"></object><a id="wp-block-file--media-_clientId_0" href="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf">custom filename</a><a href="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf" class="wp-block-file__button wp-element-button" download aria-describedby="wp-block-file--media-_clientId_0">Download</a></div>'; - $new_content = gutenberg_render_block_core_file( $attributes, $content ); + $parsed_blocks = parse_blocks( + '<!-- wp:file {"href":"http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf","displayPreview":true} -->' + ); + $parsed_block = $parsed_blocks[0]; + $block = new WP_Block( $parsed_block ); + + $new_content = gutenberg_render_block_core_file( $attributes, $content, $block ); $this->assertStringContainsString( 'aria-label="Embed of custom filename."', $new_content ); } @@ -63,7 +75,13 @@ public function test_render_block_core_file_empty_filename() { ); $content = '<div class="wp-block-file"><object class="wp-block-file__embed" data="' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf" type="application/pdf" style="width:100%;height:370px" aria-label="PDF embed"></object><a id="wp-block-file--media-_clientId_0" href="http://' . WP_TESTS_DOMAIN . 'wp-content/uploads/2021/04/yolo.pdf">yolo</a><a href="http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf" class="wp-block-file__button wp-element-button" download aria-describedby="wp-block-file--media-_clientId_0">Download</a></div>'; - $new_content = gutenberg_render_block_core_file( $attributes, $content ); + $parsed_blocks = parse_blocks( + '<!-- wp:file {"href":"http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/2021/04/yolo.pdf","displayPreview":true} -->' + ); + $parsed_block = $parsed_blocks[0]; + $block = new WP_Block( $parsed_block ); + + $new_content = gutenberg_render_block_core_file( $attributes, $content, $block ); $this->assertStringContainsString( 'aria-label="PDF embed"', $new_content ); } } diff --git a/phpunit/blocks/render-comment-template-test.php b/phpunit/blocks/render-comment-template-test.php new file mode 100644 index 00000000000000..c297d1729d0dc5 --- /dev/null +++ b/phpunit/blocks/render-comment-template-test.php @@ -0,0 +1,175 @@ +<?php +/** + * Tests for the Comment Template block rendering. + * + * @package WordPress + * @subpackage Blocks + * @since 6.3.0 + * + * @group blocks + */ +class Tests_Blocks_RenderCommentTemplateBlock extends WP_UnitTestCase { + + private static $custom_post; + private static $comment_ids; + private static $per_page = 5; + + /** + * Array of the comments options and their original values. + * Used to reset the options after each test. + * + * @var array + */ + private static $original_options; + + public static function set_up_before_class() { + parent::set_up_before_class(); + + // Store the original option values. + $options = array( + 'comment_order', + 'comments_per_page', + 'default_comments_page', + 'page_comments', + 'previous_default_page', + 'thread_comments_depth', + ); + foreach ( $options as $option ) { + static::$original_options[ $option ] = get_option( $option ); + } + } + + public function tear_down() { + // Reset the comment options to their original values. + foreach ( static::$original_options as $option => $original_value ) { + update_option( $option, $original_value ); + } + + parent::tear_down(); + } + + public function set_up() { + parent::set_up(); + + update_option( 'page_comments', true ); + update_option( 'comments_per_page', self::$per_page ); + + self::$custom_post = self::factory()->post->create_and_get( + array( + 'post_type' => 'dogs', + 'post_status' => 'publish', + 'post_name' => 'metaldog', + 'post_title' => 'Metal Dog', + 'post_content' => 'Metal Dog content', + 'post_excerpt' => 'Metal Dog', + ) + ); + + self::$comment_ids = self::factory()->comment->create_post_comments( + self::$custom_post->ID, + 1, + array( + 'comment_author' => 'Test', + 'comment_author_email' => 'test@example.org', + 'comment_author_url' => 'http://example.com/author-url/', + 'comment_content' => 'Hello world', + ) + ); + } + + public function test_rendering_comment_template_sets_comment_id_context() { + $parsed_comment_author_name_block = parse_blocks( '<!-- wp:comment-author-name /-->' )[0]; + $comment_author_name_block = new WP_Block( + $parsed_comment_author_name_block, + array( + 'commentId' => self::$comment_ids[0], + ) + ); + $comment_author_name_block_markup = $comment_author_name_block->render(); + $this->assertNotEmpty( + $comment_author_name_block_markup, + 'Comment Author Name block rendered markup is empty.' + ); + + $render_block_callback = static function( $block_content, $block ) use ( $parsed_comment_author_name_block ) { + // Insert a Comment Author Name block (which requires `commentId` + // block context to work) after the Comment Content block. + if ( 'core/comment-content' !== $block['blockName'] ) { + return $block_content; + } + + $inserted_content = render_block( $parsed_comment_author_name_block ); + return $inserted_content . $block_content; + }; + + add_filter( 'render_block', $render_block_callback, 10, 3 ); + $parsed_blocks = parse_blocks( + '<!-- wp:comment-template --><!-- wp:comment-content /--><!-- /wp:comment-template -->' + ); + $block = new WP_Block( + $parsed_blocks[0], + array( + 'postId' => self::$custom_post->ID, + ) + ); + $markup = $block->render(); + remove_filter( 'render_block', $render_block_callback ); + + $this->assertStringContainsString( + $comment_author_name_block_markup, + $markup, + "Rendered markup doesn't contain Comment Author Name block." + ); + } + + public function test_inner_block_inserted_by_render_block_data_is_retained() { + $render_block_callback = new MockAction(); + add_filter( 'render_block', array( $render_block_callback, 'filter' ), 10, 3 ); + + $render_block_data_callback = static function( $parsed_block ) { + // Add a Social Links block to a Comment Template block's inner blocks. + if ( 'core/comment-template' === $parsed_block['blockName'] ) { + $inserted_block_markup = <<<END +<!-- wp:social-links --> +<ul class="wp-block-social-links"><!-- wp:social-link {"url":"https://wordpress.org","service":"wordpress"} /--></ul> +<!-- /wp:social-links -->' +END; + + $inserted_blocks = parse_blocks( $inserted_block_markup ); + + $parsed_block['innerBlocks'][] = $inserted_blocks[0]; + } + return $parsed_block; + }; + + add_filter( 'render_block_data', $render_block_data_callback, 10, 1 ); + $parsed_blocks = parse_blocks( + '<!-- wp:comments --><!-- wp:comment-template --><!-- wp:comment-content /--><!-- /wp:comment-template --><!-- /wp:comments -->' + ); + $block = new WP_Block( + $parsed_blocks[0], + array( + 'postId' => self::$custom_post->ID, + ) + ); + $block->render(); + remove_filter( 'render_block_data', $render_block_data_callback ); + + $this->assertSame( 5, $render_block_callback->get_call_count() ); + + $args = $render_block_callback->get_args(); + $this->assertSame( 'core/comment-content', $args[0][2]->name ); + $this->assertSame( 'core/comment-template', $args[1][2]->name ); + $this->assertCount( 2, $args[1][2]->inner_blocks, "Inner block inserted by render_block_data filter wasn't retained." ); + $this->assertInstanceOf( + 'WP_Block', + $args[1][2]->inner_blocks[1], + "Inner block inserted by render_block_data isn't a WP_Block class instance." + ); + $this->assertSame( + 'core/social-links', + $args[1][2]->inner_blocks[1]->name, + "Inner block inserted by render_block_data isn't named as expected." + ); + } +} diff --git a/phpunit/blocks/render-post-template-test.php b/phpunit/blocks/render-post-template-test.php new file mode 100644 index 00000000000000..95a90f12ca3e24 --- /dev/null +++ b/phpunit/blocks/render-post-template-test.php @@ -0,0 +1,159 @@ +<?php +/** + * Tests for the Post Template block rendering. + * + * @package WordPress + * @subpackage Blocks + * @since 6.0.0 + * + * @group blocks + */ +class Tests_Blocks_RenderPostTemplateBlock extends WP_UnitTestCase { + + private static $post; + private static $other_post; + + public function set_up() { + parent::set_up(); + + self::$post = self::factory()->post->create_and_get( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_name' => 'metaldog', + 'post_title' => 'Metal Dog', + 'post_content' => 'Metal Dog content', + 'post_excerpt' => 'Metal Dog', + ) + ); + + self::$other_post = self::factory()->post->create_and_get( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_name' => 'ceilingcat', + 'post_title' => 'Ceiling Cat', + 'post_content' => 'Ceiling Cat content', + 'post_excerpt' => 'Ceiling Cat', + ) + ); + } + + public function test_rendering_post_template() { + $parsed_blocks = parse_blocks( + '<!-- wp:post-template --><!-- wp:post-title /--><!-- wp:post-excerpt /--><!-- /wp:post-template -->' + ); + $block = new WP_Block( $parsed_blocks[0] ); + $markup = $block->render(); + + $post_id = self::$post->ID; + $other_post_id = self::$other_post->ID; + + $expected = <<<END +<ul class="wp-block-post-template is-layout-flow wp-block-post-template-is-layout-flow"> + <li class="wp-block-post post-$other_post_id post type-post status-publish format-standard hentry category-uncategorized"> + <h2 class="wp-block-post-title">Ceiling Cat</h2> + <div class="wp-block-post-excerpt"> + <p class="wp-block-post-excerpt__excerpt">Ceiling Cat </p> + </div> + </li> + <li class="wp-block-post post-$post_id post type-post status-publish format-standard hentry category-uncategorized"> + <h2 class="wp-block-post-title">Metal Dog</h2> + <div class="wp-block-post-excerpt"> + <p class="wp-block-post-excerpt__excerpt">Metal Dog </p> + </div> + </li> +</ul> +END; + $this->assertSame( + str_replace( array( "\n", "\t" ), '', $expected ), + str_replace( array( "\n", "\t" ), '', $markup ) + ); + } + + /** + * Tests that the `core/post-template` block triggers the main query loop when rendering within a corresponding + * `core/query` block. + */ + public function test_rendering_post_template_with_main_query_loop() { + global $wp_query, $wp_the_query; + + // Query block with post template block. + $content = '<!-- wp:query {"query":{"inherit":true}} -->'; + $content .= '<!-- wp:post-template {"align":"wide"} -->'; + $content .= '<!-- wp:post-title /--><!-- wp:test/in-the-loop-logger /-->'; + $content .= '<!-- /wp:post-template -->'; + $content .= '<!-- /wp:query -->'; + + $expected = '<ul class="alignwide wp-block-post-template is-layout-flow wp-block-post-template-is-layout-flow wp-block-query-is-layout-flow">'; + $expected .= '<li class="wp-block-post post-' . self::$post->ID . ' post type-post status-publish format-standard hentry category-uncategorized">'; + $expected .= '<h2 class="wp-block-post-title">' . self::$post->post_title . '</h2>'; + $expected .= '</li>'; + $expected .= '</ul>'; + + // Set main query to single post. + $wp_query = new WP_Query( array( 'p' => self::$post->ID ) ); + $wp_the_query = $wp_query; + + // Register test block to log `in_the_loop()` results. + $in_the_loop_logs = array(); + register_block_type( + 'test/in-the-loop-logger', + array( + 'render_callback' => static function() use ( &$in_the_loop_logs ) { + $in_the_loop_logs[] = in_the_loop(); + return ''; + }, + ) + ); + + $output = do_blocks( $content ); + $this->assertSame( $expected, $output, 'Unexpected parsed blocks content' ); + $this->assertSame( array( true ), $in_the_loop_logs, 'Unexpected in_the_loop() result' ); + } + + /** + * Tests that the `core/post-template` block does not tamper with the main query loop when rendering within a post + * as the main query loop has already been started. In this case, the main query object needs to be cloned to + * prevent an infinite loop. + */ + public function test_rendering_post_template_with_main_query_loop_already_started() { + global $wp_query, $wp_the_query; + + // Query block with post template block. + $content = '<!-- wp:query {"query":{"inherit":true}} -->'; + $content .= '<!-- wp:post-template {"align":"wide"} -->'; + $content .= '<!-- wp:post-title /-->'; + $content .= '<!-- /wp:post-template -->'; + $content .= '<!-- /wp:query -->'; + + $expected = '<ul class="alignwide wp-block-post-template is-layout-flow wp-block-post-template-is-layout-flow wp-block-query-is-layout-flow">'; + $expected .= '<li class="wp-block-post post-' . self::$post->ID . ' post type-post status-publish format-standard hentry category-uncategorized">'; + $expected .= '<h2 class="wp-block-post-title">' . self::$post->post_title . '</h2>'; + $expected .= '</li>'; + $expected .= '</ul>'; + + // Update the post's content to have a query block for the same query as the main query. + wp_update_post( + array( + 'ID' => self::$post->ID, + 'post_content' => $content, + 'post_content_filtered' => $content, + ) + ); + + // Set main query to single post. + $wp_query = new WP_Query( array( 'p' => self::$post->ID ) ); + $wp_the_query = $wp_query; + + // Get post content within main query loop. + $output = ''; + while ( $wp_query->have_posts() ) { + $wp_query->the_post(); + + $output = get_echo( 'the_content' ); + } + + $this->assertSame( $expected, $output, 'Unexpected parsed post content' ); + } +} diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index e15509e43143eb..7084df68443baa 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -21,6 +21,14 @@ if ( ! defined( 'LOCAL_WP_ENVIRONMENT_TYPE' ) ) { define( 'LOCAL_WP_ENVIRONMENT_TYPE', 'local' ); } +define( 'GUTENBERG_DIR_TESTDATA', __DIR__ . '/data/' ); +define( 'GUTENBERG_DIR_TESTFIXTURES', __DIR__ . '/fixtures/' ); + +// Pretend that these are Core unit tests. This is needed so that +// wp_theme_has_theme_json() does not cache its return value between each test. +if ( ! defined( 'WP_RUN_CORE_TESTS' ) ) { + define( 'WP_RUN_CORE_TESTS', true ); +} // Require composer dependencies. require_once dirname( __DIR__ ) . '/vendor/autoload.php'; @@ -105,7 +113,7 @@ function gutenberg_register_test_block_for_feature_selectors() { WP_Block_Type_Registry::get_instance()->register( 'test/test', array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'textColor' => array( 'type' => 'string', diff --git a/phpunit/class-block-context-test.php b/phpunit/class-block-context-test.php index 4cf30478aa5682..6c06cfd099bd4f 100644 --- a/phpunit/class-block-context-test.php +++ b/phpunit/class-block-context-test.php @@ -104,7 +104,7 @@ public function test_provides_block_context() { 'gutenberg/contextWithAssigned', 'gutenberg/contextWithoutDefault', ), - 'render_callback' => function( $attributes, $content, $block ) use ( &$provided_context ) { + 'render_callback' => static function( $attributes, $content, $block ) use ( &$provided_context ) { $provided_context[] = $block->context; return ''; @@ -142,7 +142,7 @@ public function test_provides_default_context() { 'gutenberg/test-context-consumer', array( 'uses_context' => array( 'postId', 'postType' ), - 'render_callback' => function( $attributes, $content, $block ) use ( &$provided_context ) { + 'render_callback' => static function( $attributes, $content, $block ) use ( &$provided_context ) { $provided_context[] = $block->context; return ''; @@ -173,7 +173,7 @@ public function test_default_context_is_filterable() { 'gutenberg/test-context-consumer', array( 'uses_context' => array( 'example' ), - 'render_callback' => function( $attributes, $content, $block ) use ( &$provided_context ) { + 'render_callback' => static function( $attributes, $content, $block ) use ( &$provided_context ) { $provided_context[] = $block->context; return ''; @@ -181,7 +181,7 @@ public function test_default_context_is_filterable() { ) ); - $filter_block_context = function( $context ) { + $filter_block_context = static function( $context ) { $context['example'] = 'ok'; return $context; }; diff --git a/phpunit/class-gutenberg-classic-to-block-menu-converter-test.php b/phpunit/class-gutenberg-classic-to-block-menu-converter-test.php new file mode 100644 index 00000000000000..5f0a8ceee52a10 --- /dev/null +++ b/phpunit/class-gutenberg-classic-to-block-menu-converter-test.php @@ -0,0 +1,215 @@ +<?php +/** + * Tests Gutenberg_Classic_To_Block_Menu_Converter + * + * @package WordPress + */ + +/** + * Tests for the Gutenberg_Classic_To_Block_Menu_Converter_Test class. + */ +class Gutenberg_Classic_To_Block_Menu_Converter_Test extends WP_UnitTestCase { + + /** + * @covers WP_Classic_To_Block_Menu_Converter::get_fallback + */ + public function test_class_exists() { + $this->assertTrue( class_exists( 'Gutenberg_Classic_To_Block_Menu_Converter' ) ); + } + + /** + * @covers WP_Classic_To_Block_Menu_Converter::convert + * @dataProvider provider_test_passing_non_menu_object_to_converter_returns_wp_error + */ + public function test_passing_non_menu_object_to_converter_returns_wp_error( $data ) { + + $result = Gutenberg_Classic_To_Block_Menu_Converter::convert( $data ); + + $this->assertTrue( is_wp_error( $result ), 'Should be a WP_Error instance' ); + + $this->assertEquals( 'invalid_menu', $result->get_error_code(), 'Error code should indicate invalidity of menu argument.' ); + + $this->assertEquals( 'The menu provided is not a valid menu.', $result->get_error_message(), 'Error message should communicate invalidity of menu argument.' ); + } + + /** + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function provider_test_passing_non_menu_object_to_converter_returns_wp_error() { + return array( + array( 1 ), + array( -1 ), + array( '1' ), + array( 'not a menu object' ), + array( true ), + array( false ), + array( array() ), + array( new stdClass() ), + ); + } + + /** + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function test_can_convert_classic_menu_to_blocks() { + + $menu_id = wp_create_nav_menu( 'Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + $second_menu_item_id = wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 2', + 'menu-item-url' => '/classic-menu-item-2', + 'menu-item-status' => 'publish', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Nested Menu Item 1', + 'menu-item-url' => '/nested-menu-item-1', + 'menu-item-status' => 'publish', + 'menu-item-parent-id' => $second_menu_item_id, + ) + ); + + $classic_nav_menu = wp_get_nav_menu_object( $menu_id ); + + $blocks = Gutenberg_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + $this->assertNotEmpty( $blocks ); + + $parsed_blocks = parse_blocks( $blocks ); + + $first_block = $parsed_blocks[0]; + $second_block = $parsed_blocks[1]; + $nested_block = $parsed_blocks[1]['innerBlocks'][0]; + + $this->assertEquals( 'core/navigation-link', $first_block['blockName'], 'First block name should be "core/navigation-link"' ); + + $this->assertEquals( 'Classic Menu Item 1', $first_block['attrs']['label'], 'First block label should match.' ); + + $this->assertEquals( '/classic-menu-item-1', $first_block['attrs']['url'], 'First block URL should match.' ); + + // Assert parent of nested menu item is a submenu block. + $this->assertEquals( 'core/navigation-submenu', $second_block['blockName'], 'Second block name should be "core/navigation-submenu"' ); + + $this->assertEquals( 'Classic Menu Item 2', $second_block['attrs']['label'], 'Second block label should match.' ); + + $this->assertEquals( '/classic-menu-item-2', $second_block['attrs']['url'], 'Second block URL should match.' ); + + $this->assertEquals( 'core/navigation-link', $nested_block['blockName'], 'Nested block name should be "core/navigation-link"' ); + + $this->assertEquals( 'Nested Menu Item 1', $nested_block['attrs']['label'], 'Nested block label should match.' ); + + $this->assertEquals( '/nested-menu-item-1', $nested_block['attrs']['url'], 'Nested block URL should match.' ); + + wp_delete_nav_menu( $menu_id ); + } + + /** + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function test_does_not_convert_menu_items_with_non_publish_status() { + + $menu_id = wp_create_nav_menu( 'Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'draft', + 'menu-item-title' => 'Draft Menu Item', + 'menu-item-url' => '/draft-menu-item', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'private', + 'menu-item-title' => 'Private Item', + 'menu-item-url' => '/private-menu-item', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'pending', + 'menu-item-title' => 'Pending Menu Item', + 'menu-item-url' => '/pending-menu-item', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'future', + 'menu-item-title' => 'Future Menu Item', + 'menu-item-url' => '/future-menu-item', + ) + ); + + $classic_nav_menu = wp_get_nav_menu_object( $menu_id ); + + $blocks = Gutenberg_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + $this->assertNotEmpty( $blocks ); + + $parsed_blocks = parse_blocks( $blocks ); + + $this->assertCount( 1, $parsed_blocks, 'Should only be one block in the array.' ); + + $this->assertEquals( 'core/navigation-link', $parsed_blocks[0]['blockName'], 'First block name should be "core/navigation-link"' ); + + $this->assertEquals( 'Classic Menu Item 1', $parsed_blocks[0]['attrs']['label'], 'First block label should match.' ); + + $this->assertEquals( '/classic-menu-item-1', $parsed_blocks[0]['attrs']['url'], 'First block URL should match.' ); + + wp_delete_nav_menu( $menu_id ); + } + + /** + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function test_returns_empty_array_for_menus_with_no_items() { + $menu_id = wp_create_nav_menu( 'Empty Menu' ); + + $classic_nav_menu = wp_get_nav_menu_object( $menu_id ); + + $blocks = Gutenberg_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + $this->assertEmpty( $blocks, 'Result should be empty.' ); + + $this->assertIsArray( $blocks, 'Result should be empty array.' ); + + wp_delete_nav_menu( $menu_id ); + } +} diff --git a/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php b/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php new file mode 100644 index 00000000000000..edc48f1c71761b --- /dev/null +++ b/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php @@ -0,0 +1,355 @@ +<?php +/** + * Tests Gutenberg_Navigation_Fallback + * + * @package WordPress + */ + +/** + * Tests for the Gutenberg_Navigation_Fallback class. + */ +class Gutenberg_Navigation_Fallback_Test extends WP_UnitTestCase { + + protected static $admin_user; + protected static $editor_user; + + public static function wpSetUpBeforeClass( $factory ) { + self::$admin_user = $factory->user->create( array( 'role' => 'administrator' ) ); + + self::$editor_user = $factory->user->create( array( 'role' => 'editor' ) ); + } + + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_user ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller + */ + public function test_it_exists() { + $this->assertTrue( class_exists( 'Gutenberg_Navigation_Fallback' ), 'Gutenberg_Navigation_Fallback class should exist.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_a_default_fallback_navigation_menu_in_absence_of_other_fallbacks() { + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'wp_navigation', $data->post_type, 'Fallback menu type should be `wp_navigation`' ); + + $this->assertEquals( 'Navigation', $data->post_title, 'Fallback menu title should be the default fallback title' ); + + $this->assertEquals( 'navigation', $data->post_name, 'Fallback menu slug (post_name) should be the default slug' ); + + $this->assertEquals( '<!-- wp:page-list /-->', $data->post_content ); + + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_not_automatically_create_fallback_if_filter_is_falsey() { + + add_filter( 'gutenberg_navigation_should_create_fallback', '__return_false' ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertEmpty( $data ); + + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 0, $navs_in_db, 'The fallback Navigation post should not have been created.' ); + + remove_filter( 'gutenberg_navigation_should_create_fallback', '__return_false' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_a_default_fallback_navigation_menu_with_no_blocks_if_page_list_block_is_not_registered() { + + $original_page_list_block = WP_Block_Type_Registry::get_instance()->get_registered( 'core/page-list' ); + + unregister_block_type( 'core/page-list' ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertNotEquals( '<!-- wp:page-list /-->', $data->post_content, 'Navigation Menu should not contain a Page List block.' ); + + $this->assertEmpty( $data->post_content, 'Menu should be empty.' ); + + register_block_type( 'core/page-list', $original_page_list_block ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_handle_consecutive_invocations() { + // Invoke the method multiple times to ensure that it doesn't create a new fallback menu on each invocation. + Gutenberg_Navigation_Fallback::get_fallback(); + Gutenberg_Navigation_Fallback::get_fallback(); + + // Assert on the final invocation. + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Navigation', $data->post_title, 'Fallback menu title should be the default title' ); + + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_the_most_recently_created_navigation_menu() { + + self::factory()->post->create_and_get( + array( + 'post_type' => 'wp_navigation', + 'post_title' => 'Existing Navigation Menu 1', + 'post_content' => '<!-- wp:page-list /-->', + ) + ); + + $most_recently_published_nav = self::factory()->post->create_and_get( + array( + 'post_type' => 'wp_navigation', + 'post_title' => 'Existing Navigation Menu 2', + 'post_content' => '<!-- wp:navigation-link {"label":"Hello world","type":"post","id":1,"url":"/hello-world","kind":"post-type"} /-->', + ) + ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( $most_recently_published_nav->post_title, $data->post_title, 'Fallback menu title should be the same as the most recently created menu.' ); + + $this->assertEquals( $most_recently_published_nav->post_name, $data->post_name, 'Post name should be the same as the most recently created menu.' ); + + $this->assertEquals( $most_recently_published_nav->post_content, $data->post_content, 'Post content should be the same as the most recently created menu.' ); + + // Check that no new Navigation menu was created. + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 2, $navs_in_db, 'Only the existing Navigation menus should be present in the database.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_fallback_navigation_from_existing_classic_menu_if_no_navigation_menus_exist() { + $menu_id = wp_create_nav_menu( 'Existing Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Existing Classic Menu', $data->post_title, 'Fallback menu title should be the same as the classic menu.' ); + + // Assert that the fallback contains a navigation-link block. + $this->assertStringContainsString( '<!-- wp:navigation-link', $data->post_content, 'The fallback Navigation Menu should contain a `core/navigation-link` block.' ); + + // Assert that fallback post_content contains the expected menu item title. + $this->assertStringContainsString( '"label":"Classic Menu Item 1"', $data->post_content, 'The fallback Navigation Menu should contain menu item with a label matching the title of the menu item from the Classic Menu.' ); + + // Assert that fallback post_content contains the expected menu item url. + $this->assertStringContainsString( '"url":"/classic-menu-item-1"', $data->post_content, 'The fallback Navigation Menu should contain menu item with a url matching the slug of the menu item from the Classic Menu.' ); + + // Check that only a single Navigation fallback was created. + $navs_in_db = $this->get_navigations_in_database(); + $this->assertCount( 1, $navs_in_db, 'A single Navigation menu should be present in the database.' ); + + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_prioritise_fallback_to_classic_menu_in_primary_location() { + $pl_menu_id = wp_create_nav_menu( 'Classic Menu in Primary Location' ); + + wp_update_nav_menu_item( + $pl_menu_id, + 0, + array( + 'menu-item-title' => 'PL Classic Menu Item', + 'menu-item-url' => '/pl-classic-menu-item', + 'menu-item-status' => 'publish', + ) + ); + + $another_menu_id = wp_create_nav_menu( 'Another Classic Menu' ); + + wp_update_nav_menu_item( + $another_menu_id, + 0, + array( + 'menu-item-title' => 'Another Classic Menu Item', + 'menu-item-url' => '/another-classic-menu-item', + 'menu-item-status' => 'publish', + ) + ); + + $locations = get_nav_menu_locations(); + $locations['primary'] = $pl_menu_id; + $locations['header'] = $another_menu_id; + set_theme_mod( 'nav_menu_locations', $locations ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Classic Menu in Primary Location', $data->post_title, 'Fallback menu title should match the menu in the "primary" location.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_fallback_to_classic_menu_with_primary_slug() { + + // Creates a classic menu with the slug "primary". + $primary_menu_id = wp_create_nav_menu( 'Primary' ); + + wp_update_nav_menu_item( + $primary_menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item', + 'menu-item-url' => '/classic-menu-item', + 'menu-item-status' => 'publish', + ) + ); + + $another_menu_id = wp_create_nav_menu( 'Another Classic Menu' ); + + wp_update_nav_menu_item( + $another_menu_id, + 0, + array( + 'menu-item-title' => 'Another Classic Menu Item', + 'menu-item-url' => '/another-classic-menu-item', + 'menu-item-status' => 'publish', + ) + ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Primary', $data->post_title, 'Fallback menu title should match the menu with the slug "primary".' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_fallback_to_most_recently_created_classic_menu() { + + // Creates a classic menu with the slug "primary". + $primary_menu_id = wp_create_nav_menu( 'Older Classic Menu' ); + + wp_update_nav_menu_item( + $primary_menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item', + 'menu-item-url' => '/classic-menu-item', + 'menu-item-status' => 'publish', + ) + ); + + $most_recent_menu_id = wp_create_nav_menu( 'Most Recent Classic Menu' ); + + wp_update_nav_menu_item( + $most_recent_menu_id, + 0, + array( + 'menu-item-title' => 'Another Classic Menu Item', + 'menu-item-url' => '/another-classic-menu-item', + 'menu-item-status' => 'publish', + ) + ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Most Recent Classic Menu', $data->post_title, 'Fallback menu title should match the menu that was created most recently.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_not_create_fallback_from_classic_menu_if_a_navigation_menu_already_exists() { + $menu_id = wp_create_nav_menu( 'Existing Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + $existing_navigation_menu = self::factory()->post->create_and_get( + array( + 'post_type' => 'wp_navigation', + 'post_title' => 'Existing Navigation Menu 1', + 'post_content' => '<!-- wp:page-list /-->', + ) + ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( $existing_navigation_menu->post_title, $data->post_title, 'Fallback menu title should be the same as the existing Navigation menu.' ); + + $this->assertNotEquals( 'Existing Classic Menu', $data->post_title, 'Fallback menu title should not be the same as the Classic Menu.' ); + + // Check that only a single Navigation fallback was created. + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'Only the existing Navigation menus should be present in the database.' ); + + } + + private function get_navigations_in_database() { + $navs_in_db = new WP_Query( + array( + 'post_type' => 'wp_navigation', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $navs_in_db->posts ? $navs_in_db->posts : array(); + } + +} diff --git a/phpunit/class-gutenberg-rest-global-styles-controller-test.php b/phpunit/class-gutenberg-rest-global-styles-controller-test.php deleted file mode 100644 index 65f8736449acd0..00000000000000 --- a/phpunit/class-gutenberg-rest-global-styles-controller-test.php +++ /dev/null @@ -1,178 +0,0 @@ -<?php - -class Gutenberg_REST_Global_Styles_Controller_Test extends WP_Test_REST_Controller_Testcase { - /** - * @var int - */ - protected static $admin_id; - - /** - * @var int - */ - protected static $global_styles_id; - - public function set_up() { - parent::set_up(); - switch_theme( 'emptytheme' ); - } - - /** - * Create fake data before our tests run. - * - * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. - */ - public static function wpSetupBeforeClass( $factory ) { - self::$admin_id = $factory->user->create( - array( - 'role' => 'administrator', - ) - ); - // This creates the global styles for the current theme. - self::$global_styles_id = wp_insert_post( - array( - 'post_content' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', - 'post_status' => 'publish', - 'post_title' => __( 'Custom Styles', 'default' ), - 'post_type' => 'wp_global_styles', - 'post_name' => 'wp-global-styles-emptytheme', - 'tax_input' => array( - 'wp_theme' => 'emptytheme', - ), - ), - true - ); - } - - - public function test_register_routes() { - $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( - // '/wp/v2/global-styles/(?P<id>[\/\s%\w\.\(\)\[\]\@_\-]+)', - '/wp/v2/global-styles/(?P<id>[\/\w-]+)', - $routes, - 'Single global style based on the given ID route does not exist' - ); - $this->assertArrayHasKey( - '/wp/v2/global-styles/themes/(?P<stylesheet>[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', - $routes, - 'Theme global styles route does not exist' - ); - $this->assertArrayHasKey( - '/wp/v2/global-styles/themes/(?P<stylesheet>[\/\s%\w\.\(\)\[\]\@_\-]+)/variations', - $routes, - 'Theme global styles variations route does not exist' - ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_context_param() { - // Controller does not use get_context_param(). - } - - public function test_get_theme_items() { - wp_set_current_user( self::$admin_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/themes/emptytheme/variations' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $expected = array( - array( - 'version' => 2, - 'settings' => array( - 'color' => array( - 'palette' => array( - 'theme' => array( - array( - 'slug' => 'foreground', - 'color' => '#3F67C6', - 'name' => 'Foreground', - ), - ), - ), - ), - ), - 'styles' => array( - 'blocks' => array( - 'core/post-title' => array( - 'typography' => array( - 'fontWeight' => '700', - ), - ), - ), - ), - 'title' => 'variation', - ), - ); - $this->assertSameSetsWithIndex( $data, $expected ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_items() { - // Controller does not implement get_items(). - } - - public function test_get_item() { - wp_set_current_user( self::$admin_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - unset( $data['_links'] ); - - $this->assertEquals( - array( - 'id' => self::$global_styles_id, - 'title' => array( - 'raw' => 'Custom Styles', - 'rendered' => 'Custom Styles', - ), - 'settings' => new stdClass(), - 'styles' => new stdClass(), - ), - $data - ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_create_item() { - // Controller does not implement create_item(). - } - - public function test_update_item() { - wp_set_current_user( self::$admin_id ); - $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id ); - $request->set_body_params( - array( - 'title' => 'My new global styles title', - ) - ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $this->assertEquals( 'My new global styles title', $data['title']['raw'] ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_delete_item() { - // Controller does not implement delete_item(). - } - - /** - * @doesNotPerformAssertions - */ - public function test_prepare_item() { - // Controller does not implement prepare_item(). - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_item_schema() { - // Covered by the core. - } -} diff --git a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php index 6856638bf8754e..b015a85723bed0 100644 --- a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php +++ b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php @@ -1,4 +1,12 @@ <?php +/** + * Unit tests covering Gutenberg_REST_Global_Styles_Revisions_Controller_6_4 functionality. + * + * Note: the bulk of the tests covering this class and its methods have been ported to Core as of 6.3. + * + * @package WordPress + * @subpackage REST API + */ class Gutenberg_REST_Global_Styles_Revisions_Controller_Test extends WP_Test_REST_Controller_Testcase { /** @@ -21,10 +29,40 @@ class Gutenberg_REST_Global_Styles_Revisions_Controller_Test extends WP_Test_RES */ protected static $global_styles_id; - public function set_up() { - parent::set_up(); - switch_theme( 'emptytheme' ); - } + /** + * @var int + */ + private $total_revisions; + + /** + * @var array + */ + private $revision_1; + + /** + * @var int + */ + private $revision_1_id; + + /** + * @var array + */ + private $revision_2; + + /** + * @var int + */ + private $revision_2_id; + + /** + * @var array + */ + private $revision_3; + + /** + * @var int + */ + private $revision_3_id; /** * Create fake data before our tests run. @@ -47,145 +85,261 @@ public static function wpSetupBeforeClass( $factory ) { 'role' => 'author', ) ); + + wp_set_current_user( self::$admin_id ); // This creates the global styles for the current theme. - self::$global_styles_id = wp_insert_post( + self::$global_styles_id = $factory->post->create( array( - 'post_content' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', + 'post_content' => '{"version": ' . WP_Theme_JSON::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', 'post_status' => 'publish', 'post_title' => __( 'Custom Styles', 'default' ), 'post_type' => 'wp_global_styles', - 'post_name' => 'wp-global-styles-emptytheme', + 'post_name' => 'wp-global-styles-tt1-blocks-revisions', 'tax_input' => array( - 'wp_theme' => 'emptytheme', + 'wp_theme' => 'tt1-blocks', ), + ) + ); + + // Update post to create a new revisions. + $new_styles_post = array( + 'ID' => self::$global_styles_id, + 'post_content' => wp_json_encode( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'isGlobalStylesUserThemeJSON' => true, + 'styles' => array( + 'color' => array( + 'background' => 'hotpink', + ), + ), + 'settings' => array( + 'color' => array( + 'palette' => array( + 'custom' => array( + array( + 'name' => 'Ghost', + 'slug' => 'ghost', + 'color' => 'ghost', + ), + ), + ), + ), + ), + 'behaviors' => array( + 'default' => true, + ), + ) + ), + ); + + wp_update_post( $new_styles_post, true, false ); + + $new_styles_post = array( + 'ID' => self::$global_styles_id, + 'post_content' => wp_json_encode( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'isGlobalStylesUserThemeJSON' => true, + 'styles' => array( + 'color' => array( + 'background' => 'lemonchiffon', + ), + ), + 'settings' => array( + 'color' => array( + 'palette' => array( + 'custom' => array( + array( + 'name' => 'Gwanda', + 'slug' => 'gwanda', + 'color' => 'gwanda', + ), + ), + ), + ), + ), + 'behaviors' => array( + 'lightbox' => true, + ), + ) ), - true ); + + wp_update_post( $new_styles_post, true, false ); + + $new_styles_post = array( + 'ID' => self::$global_styles_id, + 'post_content' => wp_json_encode( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'isGlobalStylesUserThemeJSON' => true, + 'styles' => array( + 'color' => array( + 'background' => 'chocolate', + ), + ), + 'settings' => array( + 'color' => array( + 'palette' => array( + 'custom' => array( + array( + 'name' => 'Stacy', + 'slug' => 'stacy', + 'color' => 'stacy', + ), + ), + ), + ), + ), + 'behaviors' => array( + 'default' => true, + ), + ) + ), + ); + + wp_update_post( $new_styles_post, true, false ); + wp_set_current_user( 0 ); + } + + /** + * Removes users after our tests run. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$second_admin_id ); + self::delete_user( self::$author_id ); } /** - * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::register_routes + * Sets up before tests. + */ + public function set_up() { + parent::set_up(); + switch_theme( 'emptytheme' ); + $revisions = wp_get_post_revisions( self::$global_styles_id ); + $this->total_revisions = count( $revisions ); + + $this->revision_1 = array_pop( $revisions ); + $this->revision_1_id = $this->revision_1->ID; + + $this->revision_2 = array_pop( $revisions ); + $this->revision_2_id = $this->revision_2->ID; + + $this->revision_3 = array_pop( $revisions ); + $this->revision_3_id = $this->revision_3->ID; + + /* + * For some reason the `rest_api_init` doesn't run early enough to ensure an overwritten `get_item_schema()` + * is used. So we manually call it here. + * See: https://github.com/WordPress/gutenberg/pull/52370#issuecomment-1643331655. + */ + $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_4(); + $global_styles_revisions_controller->register_routes(); + } + + /** + * @ticket 58524 + * + * @covers WP_REST_Global_Styles_Controller::register_routes */ public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/global-styles/(?P<parent>[\d]+)/revisions', $routes, - 'Global style revisions based on the given parentID route does not exist' + 'Global style revisions based on the given parentID route does not exist.' ); } /** - * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::get_items + * Utility function to check the items in WP_REST_Global_Styles_Controller::get_items + * against the expected values. + * + * @ticket 58524 */ - public function test_get_items() { - wp_set_current_user( self::$admin_id ); - // Update post to create a new revision. - $config = array( - 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, - 'isGlobalStylesUserThemeJSON' => true, - 'styles' => array( - 'color' => array( - 'background' => 'hotpink', - ), - ), - ); - $new_styles_post = array( - 'ID' => self::$global_styles_id, - 'post_content' => wp_json_encode( $config ), - ); - - wp_update_post( $new_styles_post, true, false ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertCount( 1, $data, 'Check that only one revision exists' ); - $this->assertArrayHasKey( 'id', $data[0], 'Check that an id key exists' ); - $this->assertEquals( self::$global_styles_id, $data[0]['parent'], 'Check that an id for the parent exists' ); - - // Dates. - $this->assertArrayHasKey( 'date', $data[0], 'Check that an date key exists' ); - $this->assertArrayHasKey( 'date_gmt', $data[0], 'Check that an date_gmt key exists' ); - $this->assertArrayHasKey( 'modified', $data[0], 'Check that an modified key exists' ); - $this->assertArrayHasKey( 'modified_gmt', $data[0], 'Check that an modified_gmt key exists' ); - $this->assertArrayHasKey( 'modified_gmt', $data[0], 'Check that an modified_gmt key exists' ); - - // Author information. - $this->assertEquals( self::$admin_id, $data[0]['author'], 'Check that author id returns expected value' ); + protected function check_get_revision_response( $response_revision_item, $revision_expected_item ) { + $this->assertSame( (int) $revision_expected_item->post_author, $response_revision_item['author'], 'Check that the revision item `author` exists.' ); + $this->assertSame( mysql_to_rfc3339( $revision_expected_item->post_date ), $response_revision_item['date'], 'Check that the revision item `date` exists.' ); + $this->assertSame( mysql_to_rfc3339( $revision_expected_item->post_date_gmt ), $response_revision_item['date_gmt'], 'Check that the revision item `date_gmt` exists.' ); + $this->assertSame( mysql_to_rfc3339( $revision_expected_item->post_modified ), $response_revision_item['modified'], 'Check that the revision item `modified` exists.' ); + $this->assertSame( mysql_to_rfc3339( $revision_expected_item->post_modified_gmt ), $response_revision_item['modified_gmt'], 'Check that the revision item `modified_gmt` exists.' ); + $this->assertSame( $revision_expected_item->post_parent, $response_revision_item['parent'], 'Check that an id for the parent exists.' ); // Global styles. + $config = ( new WP_Theme_JSON_Gutenberg( json_decode( $revision_expected_item->post_content, true ), 'custom' ) )->get_raw_data(); $this->assertEquals( - $data[0]['settings'], - new stdClass(), + $config['settings'], + $response_revision_item['settings'], 'Check that the revision settings exist in the response.' ); $this->assertEquals( - $data[0]['styles'], - array( - 'color' => array( - 'background' => 'hotpink', - ), - ), - 'Check that the revision styles match the last updated styles.' + $config['styles'], + $response_revision_item['styles'], + 'Check that the revision styles match the updated styles.' ); - - // Checks that the revisions are returned for all eligible users. - wp_set_current_user( self::$second_admin_id ); - $config['styles']['color']['background'] = 'blue'; - $new_styles_post = array( - 'ID' => self::$global_styles_id, - 'post_content' => wp_json_encode( $config ), + $this->assertEquals( + $config['behaviors'], + $response_revision_item['behaviors'], + 'Check that the revision behaviors match the updated behaviors.' ); + } - wp_update_post( $new_styles_post, true, false ); + /** + * @ticket 58524 + * + * @covers Gutenberg_REST_Global_Styles_Revisions_Controller_6_4::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); - $this->assertCount( 2, $data, 'Check that two revisions exists' ); - $this->assertEquals( self::$second_admin_id, $data[0]['author'], 'Check that second author id returns expected value' ); - $this->assertEquals( self::$admin_id, $data[1]['author'], 'Check that second author id returns expected value' ); + $this->assertSame( 200, $response->get_status(), 'Response status is 200.' ); + $this->assertCount( $this->total_revisions, $data, 'Check that correct number of revisions exists.' ); + + // Reverse chronology. + $this->assertSame( $this->revision_3_id, $data[0]['id'] ); + $this->check_get_revision_response( $data[0], $this->revision_3 ); + + $this->assertSame( $this->revision_2_id, $data[1]['id'] ); + $this->check_get_revision_response( $data[1], $this->revision_2 ); + + $this->assertSame( $this->revision_1_id, $data[2]['id'] ); + $this->check_get_revision_response( $data[2], $this->revision_1 ); } /** - * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::get_item_schema + * @ticket 58524 + * + * @covers Gutenberg_REST_Global_Styles_Revisions_Controller_6_4::get_item_schema */ public function test_get_item_schema() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 9, $properties, 'Schema properties array does not have exactly 9 elements' ); - $this->assertArrayHasKey( 'id', $properties, 'Schema properties array does not have "id" key' ); - $this->assertArrayHasKey( 'styles', $properties, 'Schema properties array does not have "styles" key' ); - $this->assertArrayHasKey( 'settings', $properties, 'Schema properties array does not have "settings" key' ); - $this->assertArrayHasKey( 'parent', $properties, 'Schema properties array does not have "parent" key' ); - $this->assertArrayHasKey( 'author', $properties, 'Schema properties array does not have "author" key' ); - $this->assertArrayHasKey( 'date', $properties, 'Schema properties array does not have "date" key' ); - $this->assertArrayHasKey( 'date_gmt', $properties, 'Schema properties array does not have "date_gmt" key' ); - $this->assertArrayHasKey( 'modified', $properties, 'Schema properties array does not have "modified" key' ); - $this->assertArrayHasKey( 'modified_gmt', $properties, 'Schema properties array does not have "modified_gmt" key' ); - } - /** - * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::get_item_permissions_check - */ - public function test_get_item_permissions_check() { - wp_set_current_user( self::$author_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_view', $response, 403 ); + $this->assertCount( 10, $properties, 'Schema properties array has exactly 10 elements.' ); + $this->assertArrayHasKey( 'id', $properties, 'Schema properties array has "id" key.' ); + $this->assertArrayHasKey( 'styles', $properties, 'Schema properties array has "styles" key.' ); + $this->assertArrayHasKey( 'settings', $properties, 'Schema properties array has "settings" key.' ); + $this->assertArrayHasKey( 'behaviors', $properties, 'Schema properties array has "behaviors" key.' ); + $this->assertArrayHasKey( 'parent', $properties, 'Schema properties array has "parent" key.' ); + $this->assertArrayHasKey( 'author', $properties, 'Schema properties array has "author" key.' ); + $this->assertArrayHasKey( 'date', $properties, 'Schema properties array has "date" key.' ); + $this->assertArrayHasKey( 'date_gmt', $properties, 'Schema properties array has "date_gmt" key.' ); + $this->assertArrayHasKey( 'modified', $properties, 'Schema properties array has "modified" key.' ); + $this->assertArrayHasKey( 'modified_gmt', $properties, 'Schema properties array has "modified_gmt" key.' ); } - /** * @doesNotPerformAssertions */ public function test_context_param() { - // Controller does not use get_context_param(). + // Controller does not implement test_context_param(). } /** diff --git a/phpunit/class-gutenberg-rest-navigation-fallback-controller-test.php b/phpunit/class-gutenberg-rest-navigation-fallback-controller-test.php new file mode 100644 index 00000000000000..d6df0c3c504ee3 --- /dev/null +++ b/phpunit/class-gutenberg-rest-navigation-fallback-controller-test.php @@ -0,0 +1,234 @@ +<?php +/** + * Unit tests covering Gutenberg_REST_Navigation_Fallback_Controller functionality. + * + * Note: that these tests are designed to provide high level coverage only. The majority of the tests + * are made directly against the WP_Navigation_Fallback_Gutenberg class as this: + * + * - is where the bulk of the logic is. + * - is also consumed by the Navigation block's server side rendering. + * + * @package WordPress + * @subpackage REST API + */ + +/** + * @group restapi + * @group navigation + */ +class Gutenberg_REST_Navigation_Fallback_Controller_Test extends WP_Test_REST_Controller_Testcase { + + protected static $admin_user; + protected static $editor_user; + + public static function wpSetUpBeforeClass( $factory ) { + self::$admin_user = $factory->user->create( array( 'role' => 'administrator' ) ); + + self::$editor_user = $factory->user->create( array( 'role' => 'editor' ) ); + } + + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_user ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller::register_routes + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp-block-editor/v1/navigation-fallback', $routes, 'Fallback route should be registered.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_should_not_return_menus_for_users_without_permissions() { + + wp_set_current_user( self::$editor_user ); + + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 403, $response->get_status(), 'Response should indicate user does not have permission.' ); + + $this->assertEquals( 'rest_cannot_create', $data['code'], 'Response should indicate user cannot create.' ); + + $this->assertEquals( 'Sorry, you are not allowed to create Navigation Menus as this user.', $data['message'], 'Response should indicate failed request status.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_get_item() { + + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status(), 'Status should indicate successful request.' ); + + $this->assertIsArray( $data, 'Response should be of correct type.' ); + + $this->assertArrayHasKey( 'id', $data, 'Response should contain expected fields.' ); + + $this->assertEquals( 'wp_navigation', get_post_type( $data['id'] ), '"id" field should represent a post of type "wp_navigation"' ); + + // Check that only a single Navigation fallback was created. + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'Only a single Navigation menu should be present in the database.' ); + + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status(), 'Status should indicate successful request.' ); + + $this->assertArrayHasKey( 'schema', $data, '"schema" key should exist in response.' ); + + $schema = $data['schema']; + + $this->assertEquals( 'object', $schema['type'], 'The schema type should match the expected type.' ); + + $this->assertArrayHasKey( 'id', $schema['properties'], 'Schema should have an "id" property.' ); + $this->assertEquals( 'integer', $schema['properties']['id']['type'], 'Schema "id" property should be an integer.' ); + $this->assertTrue( $schema['properties']['id']['readonly'], 'Schema "id" property should be readonly.' ); + } + + /** + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_adds_links() { + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $navigation_post_id = $data['id']; + + $links = $response->get_links(); + + $this->assertNotEmpty( $links, 'Response should contain links.' ); + + $this->assertArrayHasKey( 'self', $links, 'Response should contain a "self" link.' ); + + $this->assertStringContainsString( 'wp/v2/navigation/' . $navigation_post_id, $links['self'][0]['href'], 'Self link should reference the correct Navigation Menu post resource url.' ); + + $this->assertTrue( $links['self'][0]['attributes']['embeddable'], 'Self link should be embeddable.' ); + } + + private function get_navigations_in_database() { + $navs_in_db = new WP_Query( + array( + 'post_type' => 'wp_navigation', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $navs_in_db->posts ? $navs_in_db->posts : array(); + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Covered by the core test. + } + + /** + * Tests that the correct filters are applied to the context parameter. + * + * By default, the REST response for the Posts Controller will not return all fields + * when the context is set to 'embed'. Assert that correct additional fields are added + * to the embedded Navigation Post, when the navigation fallback endpoint + * is called with the `_embed` param. + * + * @covers wp_add_fields_to_navigation_fallback_embedded_links + */ + public function test_embedded_navigation_post_contains_required_fields() { + // First we'll use the navigation fallback to get a link to the navigation endpoint. + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $links = $response->get_links(); + + // Extract the navigation endpoint URL from the response. + $embedded_navigation_href = $links['self'][0]['href']; + preg_match( '/\?rest_route=(.*)/', $embedded_navigation_href, $matches ); + $navigation_endpoint = $matches[1]; + + // Fetch the "linked" navigation post from the endpoint, with the context parameter set to 'embed' to simulate fetching embedded links. + $request = new WP_REST_Request( 'GET', $navigation_endpoint ); + $request->set_param( 'context', 'embed' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + // Verify that the additional status field is present. + $this->assertArrayHasKey( 'status', $data, 'Response title should contain a "status" field.' ); + + // Verify that the additional content fields are present. + $this->assertArrayHasKey( 'content', $data, 'Response should contain a "content" field.' ); + $this->assertArrayHasKey( 'raw', $data['content'], 'Response content should contain a "raw" field.' ); + $this->assertArrayHasKey( 'rendered', $data['content'], 'Response content should contain a "rendered" field.' ); + $this->assertArrayHasKey( 'block_version', $data['content'], 'Response should contain a "block_version" field.' ); + + // Verify that the additional title.raw field is present. + $this->assertArrayHasKey( 'raw', $data['title'], 'Response title should contain a "raw" key.' ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not implement create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not implement update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not implement delete_item(). + } +} diff --git a/phpunit/class-gutenberg-rest-templates-controller-test.php b/phpunit/class-gutenberg-rest-templates-controller-test.php index e86f9db5d848e1..0992399464c5c8 100644 --- a/phpunit/class-gutenberg-rest-templates-controller-test.php +++ b/phpunit/class-gutenberg-rest-templates-controller-test.php @@ -5,10 +5,22 @@ class Gutenberg_REST_Templates_Controller_Test extends WP_Test_REST_Controller_T * @var int */ protected static $admin_id; + private static $post; + + protected function find_and_normalize_template_by_id( $templates, $id ) { + foreach ( $templates as $template ) { + if ( $template['id'] === $id ) { + unset( $template['content'] ); + unset( $template['_links'] ); + return $template; + } + } + + return null; + } public function set_up() { parent::set_up(); - switch_theme( 'emptytheme' ); } /** @@ -17,11 +29,28 @@ public function set_up() { * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. */ public static function wpSetupBeforeClass( $factory ) { + switch_theme( 'emptytheme' ); self::$admin_id = $factory->user->create( array( 'role' => 'administrator', ) ); + + // Set up template post. + $args = array( + 'post_type' => 'wp_template', + 'post_name' => 'my_template', + 'post_title' => 'My Template', + 'post_content' => 'Content', + 'post_excerpt' => 'Description of my template.', + 'tax_input' => array( + 'wp_theme' => array( + get_stylesheet(), + ), + ), + ); + self::$post = self::factory()->post->create_and_get( $args ); + wp_set_post_terms( self::$post->ID, get_stylesheet(), 'wp_theme' ); } public function test_register_routes() { @@ -63,42 +92,177 @@ public function test_get_template_fallback() { } /** - * @doesNotPerformAssertions + * @covers WP_REST_Templates_Controller::get_item_schema */ - public function test_context_param() {} + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + $this->assertCount( 15, $properties ); + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'slug', $properties ); + $this->assertArrayHasKey( 'theme', $properties ); + $this->assertArrayHasKey( 'type', $properties ); + $this->assertArrayHasKey( 'source', $properties ); + $this->assertArrayHasKey( 'origin', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'status', $properties ); + $this->assertArrayHasKey( 'wp_id', $properties ); + $this->assertArrayHasKey( 'has_theme_file', $properties ); + $this->assertArrayHasKey( 'is_custom', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + $this->assertArrayHasKey( 'modified', $properties ); + } /** - * @doesNotPerformAssertions + * @covers WP_REST_Templates_Controller::get_item */ - public function test_get_items() {} + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/emptytheme//my_template' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + unset( $data['content'] ); + unset( $data['_links'] ); + + $this->assertSame( + array( + 'id' => 'emptytheme//my_template', + 'theme' => 'emptytheme', + 'slug' => 'my_template', + 'source' => 'custom', + 'origin' => null, + 'type' => 'wp_template', + 'description' => 'Description of my template.', + 'title' => array( + 'raw' => 'My Template', + 'rendered' => 'My Template', + ), + 'status' => 'publish', + 'wp_id' => self::$post->ID, + 'has_theme_file' => false, + 'is_custom' => true, + 'author' => 0, + 'modified' => mysql_to_rfc3339( self::$post->post_modified ), + ), + $data + ); + } /** - * @doesNotPerformAssertions + * @covers WP_REST_Templates_Controller::get_items */ - public function test_get_item() {} + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/templates' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( + array( + 'id' => 'emptytheme//my_template', + 'theme' => 'emptytheme', + 'slug' => 'my_template', + 'source' => 'custom', + 'origin' => null, + 'type' => 'wp_template', + 'description' => 'Description of my template.', + 'title' => array( + 'raw' => 'My Template', + 'rendered' => 'My Template', + ), + 'status' => 'publish', + 'wp_id' => self::$post->ID, + 'has_theme_file' => false, + 'is_custom' => true, + 'author' => 0, + 'modified' => mysql_to_rfc3339( self::$post->post_modified ), + ), + $this->find_and_normalize_template_by_id( $data, 'emptytheme//my_template' ) + ); + } /** - * @doesNotPerformAssertions + * @covers WP_REST_Templates_Controller::update_item */ - public function test_create_item() {} + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/templates/emptytheme//my_template' ); + $request->set_body_params( + array( + 'title' => 'My new Index Title', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'My new Index Title', $data['title']['raw'] ); + $this->assertSame( 'custom', $data['source'] ); + $this->assertIsString( $data['modified'] ); + } /** - * @doesNotPerformAssertions + * @covers WP_REST_Templates_Controller::create_item */ - public function test_update_item() {} + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/templates' ); + $request->set_body_params( + array( + 'slug' => 'my_custom_template', + 'description' => 'Just a description', + 'title' => 'My Template', + 'content' => 'Content', + 'author' => self::$admin_id, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + unset( $data['_links'] ); + unset( $data['wp_id'] ); + + $this->assertSame( + array( + 'id' => 'emptytheme//my_custom_template', + 'theme' => 'emptytheme', + 'content' => array( + 'raw' => 'Content', + ), + 'slug' => 'my_custom_template', + 'source' => 'custom', + 'origin' => null, + 'type' => 'wp_template', + 'description' => 'Just a description', + 'title' => array( + 'raw' => 'My Template', + 'rendered' => 'My Template', + ), + 'status' => 'publish', + 'has_theme_file' => false, + 'is_custom' => true, + 'author' => self::$admin_id, + 'modified' => $data['modified'], + ), + $data + ); + } /** * @doesNotPerformAssertions */ - public function test_delete_item() {} + public function test_context_param() {} /** * @doesNotPerformAssertions */ - public function test_prepare_item() {} + public function test_delete_item() {} /** * @doesNotPerformAssertions */ - public function test_get_item_schema() {} + public function test_prepare_item() {} } diff --git a/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php b/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php new file mode 100644 index 00000000000000..37162ee5a1f9e9 --- /dev/null +++ b/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php @@ -0,0 +1,179 @@ +<?php + +class WP_REST_Global_Styles_Controller_Gutenberg_Test extends WP_Test_REST_Controller_Testcase { + /** + * @var int + */ + protected static $admin_id; + + /** + * @var int + */ + protected static $global_styles_id; + + public function set_up() { + parent::set_up(); + switch_theme( 'emptytheme' ); + } + + /** + * Create fake data before our tests run. + * + * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. + */ + public static function wpSetupBeforeClass( $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + // This creates the global styles for the current theme. + self::$global_styles_id = wp_insert_post( + array( + 'post_content' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', + 'post_status' => 'publish', + 'post_title' => __( 'Custom Styles', 'default' ), + 'post_type' => 'wp_global_styles', + 'post_name' => 'wp-global-styles-emptytheme', + 'tax_input' => array( + 'wp_theme' => 'emptytheme', + ), + ), + true + ); + } + + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + // '/wp/v2/global-styles/(?P<id>[\/\s%\w\.\(\)\[\]\@_\-]+)', + '/wp/v2/global-styles/(?P<id>[\/\w-]+)', + $routes, + 'Single global style based on the given ID route does not exist' + ); + $this->assertArrayHasKey( + '/wp/v2/global-styles/themes/(?P<stylesheet>[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', + $routes, + 'Theme global styles route does not exist' + ); + $this->assertArrayHasKey( + '/wp/v2/global-styles/themes/(?P<stylesheet>[\/\s%\w\.\(\)\[\]\@_\-]+)/variations', + $routes, + 'Theme global styles variations route does not exist' + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Controller does not use get_context_param(). + } + + public function test_get_theme_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/themes/emptytheme/variations' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $expected = array( + array( + 'version' => 2, + 'settings' => array( + 'color' => array( + 'palette' => array( + 'theme' => array( + array( + 'slug' => 'foreground', + 'color' => '#3F67C6', + 'name' => 'Foreground', + ), + ), + ), + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/post-title' => array( + 'typography' => array( + 'fontWeight' => '700', + ), + ), + ), + ), + 'title' => 'variation', + ), + ); + $this->assertSameSetsWithIndex( $data, $expected ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Controller does not implement get_items(). + } + + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + unset( $data['_links'] ); + + $this->assertEquals( + array( + 'id' => self::$global_styles_id, + 'title' => array( + 'raw' => 'Custom Styles', + 'rendered' => 'Custom Styles', + ), + 'settings' => new stdClass(), + 'styles' => new stdClass(), + 'behaviors' => new stdClass(), + ), + $data + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not implement create_item(). + } + + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/global-styles/' . self::$global_styles_id ); + $request->set_body_params( + array( + 'title' => 'My new global styles title', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'My new global styles title', $data['title']['raw'] ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not implement delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Controller does not implement prepare_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Covered by the core. + } +} diff --git a/phpunit/class-wp-rest-navigation-fallback-controller-test.php b/phpunit/class-wp-rest-navigation-fallback-controller-test.php deleted file mode 100644 index 1cdedb568a5cd7..00000000000000 --- a/phpunit/class-wp-rest-navigation-fallback-controller-test.php +++ /dev/null @@ -1,194 +0,0 @@ -<?php -/** - * Unit tests covering WP_REST_Navigation_Fallback_Controller functionality. - * - * Note: that these tests are designed to provide high level coverage only. The majority of the tests - * are made directly against the WP_Navigation_Fallback_Gutenberg class as this: - * - * - is where the bulk of the logic is. - * - is also consumed by the Navigation block's server side rendering. - * - * @package WordPress - * @subpackage REST API - */ - -/** - * @group restapi - * @group navigation - */ -class WP_REST_Navigation_Fallback_Controller_Test extends WP_Test_REST_Controller_Testcase { - - protected static $admin_user; - protected static $editor_user; - - public static function wpSetUpBeforeClass( $factory ) { - self::$admin_user = $factory->user->create( array( 'role' => 'administrator' ) ); - - self::$editor_user = $factory->user->create( array( 'role' => 'editor' ) ); - } - - public function set_up() { - parent::set_up(); - - wp_set_current_user( self::$admin_user ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::register_routes - * - * @since 6.3.0 Added Navigation Fallbacks endpoint. - */ - public function test_register_routes() { - $routes = rest_get_server()->get_routes(); - - $this->assertArrayHasKey( '/wp-block-editor/v1/navigation-fallback', $routes, 'Fallback route should be registered.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller - * - * @since 6.3.0 Added Navigation Fallbacks endpoint. - */ - public function test_should_not_return_menus_for_users_without_permissions() { - - wp_set_current_user( self::$editor_user ); - - $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertEquals( 403, $response->get_status(), 'Response should indicate user does not have permission.' ); - - $this->assertEquals( 'rest_cannot_create', $data['code'], 'Response should indicate user cannot create.' ); - - $this->assertEquals( 'Sorry, you are not allowed to create Navigation Menus as this user.', $data['message'], 'Response should indicate failed request status.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller - * - * @since 6.3.0 Added Navigation Fallbacks endpoint. - */ - public function test_get_item() { - - $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertEquals( 200, $response->get_status(), 'Status should indicate successful request.' ); - - $this->assertIsArray( $data, 'Response should be of correct type.' ); - - $this->assertArrayHasKey( 'id', $data, 'Response should contain expected fields.' ); - - $this->assertEquals( 'wp_navigation', get_post_type( $data['id'] ), '"id" field should represent a post of type "wp_navigation"' ); - - // Check that only a single Navigation fallback was created. - $navs_in_db = $this->get_navigations_in_database(); - - $this->assertCount( 1, $navs_in_db, 'Only a single Navigation menu should be present in the database.' ); - - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller - * - * @since 6.3.0 Added Navigation Fallbacks endpoint. - */ - public function test_get_item_schema() { - $request = new WP_REST_Request( 'OPTIONS', '/wp-block-editor/v1/navigation-fallback' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertEquals( 200, $response->get_status(), 'Status should indicate successful request.' ); - - $this->assertArrayHasKey( 'schema', $data, '"schema" key should exist in response.' ); - - $schema = $data['schema']; - - $this->assertEquals( 'object', $schema['type'], 'The schema type should match the expected type.' ); - - $this->assertArrayHasKey( 'id', $schema['properties'], 'Schema should have an "id" property.' ); - $this->assertEquals( 'integer', $schema['properties']['id']['type'], 'Schema "id" property should be an integer.' ); - $this->assertTrue( $schema['properties']['id']['readonly'], 'Schema "id" property should be readonly.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller - * - * @since 6.3.0 Added Navigation Fallbacks endpoint. - */ - public function test_adds_links() { - $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $navigation_post_id = $data['id']; - - $links = $response->get_links(); - - $this->assertNotEmpty( $links, 'Response should contain links.' ); - - $this->assertArrayHasKey( 'self', $links, 'Response should contain a "self" link.' ); - - $this->assertStringContainsString( 'wp/v2/navigation/' . $navigation_post_id, $links['self'][0]['href'], 'Self link should reference the correct Navigation Menu post resource url.' ); - - $this->assertTrue( $links['self'][0]['attributes']['embeddable'], 'Self link should be embeddable.' ); - } - - private function get_navigations_in_database() { - $navs_in_db = new WP_Query( - array( - 'post_type' => 'wp_navigation', - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'date', - 'order' => 'DESC', - ) - ); - - return $navs_in_db->posts ? $navs_in_db->posts : array(); - } - - /** - * @doesNotPerformAssertions - */ - public function test_prepare_item() { - // Covered by the core test. - } - - /** - * @doesNotPerformAssertions - */ - public function test_context_param() { - // Covered by the core test. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_items() { - // Covered by the core test. - } - - /** - * @doesNotPerformAssertions - */ - public function test_create_item() { - // Controller does not implement create_item(). - } - - /** - * @doesNotPerformAssertions - */ - public function test_update_item() { - // Controller does not implement update_item(). - } - - /** - * @doesNotPerformAssertions - */ - public function test_delete_item() { - // Controller does not implement delete_item(). - } -} diff --git a/phpunit/class-wp-rest-pattern-directory-controller-test.php b/phpunit/class-wp-rest-pattern-directory-controller-test.php index 5f9561a1d5f27c..fd8e5246116f98 100644 --- a/phpunit/class-wp-rest-pattern-directory-controller-test.php +++ b/phpunit/class-wp-rest-pattern-directory-controller-test.php @@ -53,7 +53,7 @@ public static function wpSetUpBeforeClass( $factory ) { self::$http_request_urls = array(); - static::$controller = new Gutenberg_REST_Pattern_Directory_Controller_6_3(); + static::$controller = new Gutenberg_REST_Pattern_Directory_Controller_6_2(); } public static function wpTearDownAfterClass() { @@ -78,39 +78,8 @@ public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/pattern-directory/patterns', $routes ); - $this->assertArrayHasKey( '/wp/v2/pattern-directory/categories', $routes ); } - /** - * @covers WP_REST_Pattern_Directory_Controller::prepare_pattern_category_for_response - * - * @since 6.2.0 - */ - public function test_prepare_pattern_category_for_response() { - $raw_categories = array( - (object) array( - 'id' => 3, - 'name' => 'Columns', - 'slug' => 'columns', - 'description' => 'A description', - ), - ); - - $prepared_category = static::$controller->prepare_response_for_collection( - static::$controller->prepare_pattern_category_for_response( $raw_categories[0], new WP_REST_Request() ) - ); - - $this->assertSame( - array( - 'id' => 3, - 'name' => 'Columns', - 'slug' => 'columns', - ), - $prepared_category - ); - } - - /** * Tests if the provided query args are passed through to the wp.org API. * @@ -185,7 +154,7 @@ public function data_get_items_query_args() { private static function capture_http_urls() { add_filter( 'pre_http_request', - function ( $preempt, $args, $url ) { + static function ( $preempt, $args, $url ) { if ( 'api.wordpress.org' !== wp_parse_url( $url, PHP_URL_HOST ) ) { return $preempt; } @@ -317,5 +286,4 @@ public function test_delete_item() { public function test_get_item_schema() { // The controller's schema is hardcoded, so tests would not be meaningful. } - } diff --git a/phpunit/class-wp-theme-json-resolver-test.php b/phpunit/class-wp-theme-json-resolver-test.php index 4cc34bf97b32c7..1588aa4d603265 100644 --- a/phpunit/class-wp-theme-json-resolver-test.php +++ b/phpunit/class-wp-theme-json-resolver-test.php @@ -396,7 +396,7 @@ public function test_get_merged_data_returns_origin( $origin, $core_palette, $co register_block_type( 'my/block-with-styles', array( - 'api_version' => 2, + 'api_version' => 3, 'attributes' => array( 'borderColor' => array( 'type' => 'string', diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 7d63f62a2c1f0e..11ec35e0925bfe 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -37,19 +37,11 @@ public static function set_up_before_class() { static::$user_id = self::factory()->user->create(); } - /** - * @dataProvider data_get_layout_definitions - * - * @param array $layout_definitions Layout definitions as stored in core theme.json. - */ - public function test_get_stylesheet_generates_layout_styles( $layout_definitions ) { + public function test_get_stylesheet_generates_layout_styles() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( - 'layout' => array( - 'definitions' => $layout_definitions, - ), 'spacing' => array( 'blockGap' => true, ), @@ -65,24 +57,16 @@ public function test_get_stylesheet_generates_layout_styles( $layout_definitions // Results also include root site blocks styles. $this->assertEquals( - 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1em; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child:first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child:last-child { margin-block-end: 0; }body { --wp--style--block-gap: 1em; }:where(body .is-layout-flow) > *{margin-block-start: 0;margin-block-end: 0;}:where(body .is-layout-flow) > * + *{margin-block-start: 1em;margin-block-end: 0;}:where(body .is-layout-flex) {gap: 1em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}', + 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1em; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child:first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child:last-child { margin-block-end: 0; }body { --wp--style--block-gap: 1em; }:where(body .is-layout-flow) > :first-child:first-child{margin-block-start: 0;}:where(body .is-layout-flow) > :last-child:last-child{margin-block-end: 0;}:where(body .is-layout-flow) > *{margin-block-start: 1em;margin-block-end: 0;}:where(body .is-layout-constrained) > :first-child:first-child{margin-block-start: 0;}:where(body .is-layout-constrained) > :last-child:last-child{margin-block-end: 0;}:where(body .is-layout-constrained) > *{margin-block-start: 1em;margin-block-end: 0;}:where(body .is-layout-flex) {gap: 1em;}:where(body .is-layout-grid) {gap: 1em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}', $theme_json->get_stylesheet( array( 'styles' ) ) ); } - /** - * @dataProvider data_get_layout_definitions - * - * @param array $layout_definitions Layout definitions as stored in core theme.json. - */ - public function test_get_stylesheet_generates_valid_block_gap_values_and_skips_null_or_false_values( $layout_definitions ) { + public function test_get_stylesheet_generates_valid_block_gap_values_and_skips_null_or_false_values() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( - 'layout' => array( - 'definitions' => $layout_definitions, - ), 'spacing' => array( 'blockGap' => true, ), @@ -119,24 +103,17 @@ public function test_get_stylesheet_generates_valid_block_gap_values_and_skips_n ); $this->assertEquals( - 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1rem; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child:first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child:last-child { margin-block-end: 0; }body { --wp--style--block-gap: 1rem; }:where(body .is-layout-flow) > *{margin-block-start: 0;margin-block-end: 0;}:where(body .is-layout-flow) > * + *{margin-block-start: 1rem;margin-block-end: 0;}:where(body .is-layout-flex) {gap: 1rem;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}.wp-block-post-content{color: gray;}.wp-block-social-links-is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-social-links-is-layout-flow > * + *{margin-block-start: 0;margin-block-end: 0;}.wp-block-social-links-is-layout-flex{gap: 0;}.wp-block-buttons-is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-buttons-is-layout-flow > * + *{margin-block-start: 0;margin-block-end: 0;}.wp-block-buttons-is-layout-flex{gap: 0;}', + 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1rem; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child:first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child:last-child { margin-block-end: 0; }body { --wp--style--block-gap: 1rem; }:where(body .is-layout-flow) > :first-child:first-child{margin-block-start: 0;}:where(body .is-layout-flow) > :last-child:last-child{margin-block-end: 0;}:where(body .is-layout-flow) > *{margin-block-start: 1rem;margin-block-end: 0;}:where(body .is-layout-constrained) > :first-child:first-child{margin-block-start: 0;}:where(body .is-layout-constrained) > :last-child:last-child{margin-block-end: 0;}:where(body .is-layout-constrained) > *{margin-block-start: 1rem;margin-block-end: 0;}:where(body .is-layout-flex) {gap: 1rem;}:where(body .is-layout-grid) {gap: 1rem;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}' . + '.wp-block-post-content{color: gray;}.wp-block-social-links-is-layout-flow > :first-child:first-child{margin-block-start: 0;}.wp-block-social-links-is-layout-flow > :last-child:last-child{margin-block-end: 0;}.wp-block-social-links-is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-social-links-is-layout-constrained > :first-child:first-child{margin-block-start: 0;}.wp-block-social-links-is-layout-constrained > :last-child:last-child{margin-block-end: 0;}.wp-block-social-links-is-layout-constrained > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-social-links-is-layout-flex{gap: 0;}.wp-block-social-links-is-layout-grid{gap: 0;}.wp-block-buttons-is-layout-flow > :first-child:first-child{margin-block-start: 0;}.wp-block-buttons-is-layout-flow > :last-child:last-child{margin-block-end: 0;}.wp-block-buttons-is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-buttons-is-layout-constrained > :first-child:first-child{margin-block-start: 0;}.wp-block-buttons-is-layout-constrained > :last-child:last-child{margin-block-end: 0;}.wp-block-buttons-is-layout-constrained > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-buttons-is-layout-flex{gap: 0;}.wp-block-buttons-is-layout-grid{gap: 0;}', $theme_json->get_stylesheet() ); } - /** - * @dataProvider data_get_layout_definitions - * - * @param array $layout_definitions Layout definitions as stored in core theme.json. - */ - public function test_get_stylesheet_generates_layout_styles_with_spacing_presets( $layout_definitions ) { + public function test_get_stylesheet_generates_layout_styles_with_spacing_presets() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( - 'layout' => array( - 'definitions' => $layout_definitions, - ), 'spacing' => array( 'blockGap' => true, ), @@ -152,24 +129,16 @@ public function test_get_stylesheet_generates_layout_styles_with_spacing_presets // Results also include root site blocks styles. $this->assertEquals( - 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: var(--wp--preset--spacing--60); margin-block-end: 0; }:where(.wp-site-blocks) > :first-child:first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child:last-child { margin-block-end: 0; }body { --wp--style--block-gap: var(--wp--preset--spacing--60); }:where(body .is-layout-flow) > *{margin-block-start: 0;margin-block-end: 0;}:where(body .is-layout-flow) > * + *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}:where(body .is-layout-flex) {gap: var(--wp--preset--spacing--60);}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}', + 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: var(--wp--preset--spacing--60); margin-block-end: 0; }:where(.wp-site-blocks) > :first-child:first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child:last-child { margin-block-end: 0; }body { --wp--style--block-gap: var(--wp--preset--spacing--60); }:where(body .is-layout-flow) > :first-child:first-child{margin-block-start: 0;}:where(body .is-layout-flow) > :last-child:last-child{margin-block-end: 0;}:where(body .is-layout-flow) > *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}:where(body .is-layout-constrained) > :first-child:first-child{margin-block-start: 0;}:where(body .is-layout-constrained) > :last-child:last-child{margin-block-end: 0;}:where(body .is-layout-constrained) > *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}:where(body .is-layout-flex) {gap: var(--wp--preset--spacing--60);}:where(body .is-layout-grid) {gap: var(--wp--preset--spacing--60);}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}', $theme_json->get_stylesheet( array( 'styles' ) ) ); } - /** - * @dataProvider data_get_layout_definitions - * - * @param array $layout_definitions Layout definitions as stored in core theme.json. - */ - public function test_get_stylesheet_generates_fallback_gap_layout_styles( $layout_definitions ) { + public function test_get_stylesheet_generates_fallback_gap_layout_styles() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( - 'layout' => array( - 'definitions' => $layout_definitions, - ), 'spacing' => array( 'blockGap' => null, ), @@ -186,24 +155,16 @@ public function test_get_stylesheet_generates_fallback_gap_layout_styles( $layou // Results also include root site blocks styles. $this->assertEquals( - 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}', + 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}', $stylesheet ); } - /** - * @dataProvider data_get_layout_definitions - * - * @param array $layout_definitions Layout definitions as stored in core theme.json. - */ - public function test_get_stylesheet_generates_base_fallback_gap_layout_styles( $layout_definitions ) { + public function test_get_stylesheet_generates_base_fallback_gap_layout_styles() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( - 'layout' => array( - 'definitions' => $layout_definitions, - ), 'spacing' => array( 'blockGap' => null, ), @@ -215,25 +176,17 @@ public function test_get_stylesheet_generates_base_fallback_gap_layout_styles( $ // Note the `base-layout-styles` includes a fallback gap for the Columns block for backwards compatibility. $this->assertEquals( - ':where(.is-layout-flex){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}:where(.wp-block-columns.is-layout-flex){gap: 2em;}', + ':where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}:where(.wp-block-columns.is-layout-flex){gap: 2em;}:where(.wp-block-columns.is-layout-grid){gap: 2em;}:where(.wp-block-post-template.is-layout-flex){gap: 1.25em;}:where(.wp-block-post-template.is-layout-grid){gap: 1.25em;}', $stylesheet ); } - /** - * @dataProvider data_get_layout_definitions - * - * @param array $layout_definitions Layout definitions as stored in core theme.json. - */ - public function test_get_stylesheet_skips_layout_styles( $layout_definitions ) { + public function test_get_stylesheet_skips_layout_styles() { add_theme_support( 'disable-layout-styles' ); $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( - 'layout' => array( - 'definitions' => $layout_definitions, - ), 'spacing' => array( 'blockGap' => null, ), @@ -251,89 +204,6 @@ public function test_get_stylesheet_skips_layout_styles( $layout_definitions ) { ); } - /** - * Data provider. - * - * @return array - */ - public function data_get_layout_definitions() { - return array( - 'layout definitions' => array( - array( - 'default' => array( - 'name' => 'default', - 'slug' => 'flow', - 'className' => 'is-layout-flow', - 'baseStyles' => array( - array( - 'selector' => ' > .alignleft', - 'rules' => array( - 'float' => 'left', - 'margin-inline-start' => '0', - 'margin-inline-end' => '2em', - ), - ), - array( - 'selector' => ' > .alignright', - 'rules' => array( - 'float' => 'right', - 'margin-inline-start' => '2em', - 'margin-inline-end' => '0', - ), - ), - array( - 'selector' => ' > .aligncenter', - 'rules' => array( - 'margin-left' => 'auto !important', - 'margin-right' => 'auto !important', - ), - ), - ), - 'spacingStyles' => array( - array( - 'selector' => ' > *', - 'rules' => array( - 'margin-block-start' => '0', - 'margin-block-end' => '0', - ), - ), - array( - 'selector' => ' > * + *', - 'rules' => array( - 'margin-block-start' => null, - 'margin-block-end' => '0', - ), - ), - ), - ), - 'flex' => array( - 'name' => 'flex', - 'slug' => 'flex', - 'className' => 'is-layout-flex', - 'displayMode' => 'flex', - 'baseStyles' => array( - array( - 'selector' => '', - 'rules' => array( - 'flex-wrap' => 'wrap', - 'align-items' => 'center', - ), - ), - ), - 'spacingStyles' => array( - array( - 'selector' => '', - 'rules' => array( - 'gap' => null, - ), - ), - ), - ), - ), - ), - ); - } - public function test_get_stylesheet() { $theme_json = new WP_Theme_JSON_Gutenberg( array( @@ -460,7 +330,7 @@ public function test_get_stylesheet() { ); $variables = 'body{--wp--preset--color--grey: grey;--wp--preset--font-family--small: 14px;--wp--preset--font-family--big: 41px;}.wp-block-group{--wp--custom--base-font: 16;--wp--custom--line-height--small: 1.2;--wp--custom--line-height--medium: 1.4;--wp--custom--line-height--large: 1.8;}'; - $styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{color: var(--wp--preset--color--grey);}a:where(:not(.wp-element-button)){background-color: #333;color: #111;}.wp-block-group{border-radius: 10px;min-height: 50vh;padding: 24px;}.wp-block-group a:where(:not(.wp-element-button)){color: #111;}.wp-block-heading{color: #123456;}.wp-block-heading a:where(:not(.wp-element-button)){background-color: #333;color: #111;font-size: 60px;}.wp-block-post-date{color: #123456;}.wp-block-post-date a:where(:not(.wp-element-button)){background-color: #777;color: #555;}.wp-block-post-excerpt{column-count: 2;}.wp-block-image{margin-bottom: 30px;}.wp-block-image img, .wp-block-image .wp-block-image__crop-area, .wp-block-image .components-placeholder{border-top-left-radius: 10px;border-bottom-right-radius: 1em;}'; + $styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}body{color: var(--wp--preset--color--grey);}a:where(:not(.wp-element-button)){background-color: #333;color: #111;}.wp-block-group{border-radius: 10px;min-height: 50vh;padding: 24px;}.wp-block-group a:where(:not(.wp-element-button)){color: #111;}.wp-block-heading{color: #123456;}.wp-block-heading a:where(:not(.wp-element-button)){background-color: #333;color: #111;font-size: 60px;}.wp-block-post-date{color: #123456;}.wp-block-post-date a:where(:not(.wp-element-button)){background-color: #777;color: #555;}.wp-block-post-excerpt{column-count: 2;}.wp-block-image{margin-bottom: 30px;}.wp-block-image img, .wp-block-image .wp-block-image__crop-area, .wp-block-image .components-placeholder{border-top-left-radius: 10px;border-bottom-right-radius: 1em;}'; $presets = '.has-grey-color{color: var(--wp--preset--color--grey) !important;}.has-grey-background-color{background-color: var(--wp--preset--color--grey) !important;}.has-grey-border-color{border-color: var(--wp--preset--color--grey) !important;}.has-small-font-family{font-family: var(--wp--preset--font-family--small) !important;}.has-big-font-family{font-family: var(--wp--preset--font-family--big) !important;}'; $all = $variables . $styles . $presets; @@ -533,7 +403,7 @@ public function test_get_shadow_styles_for_blocks() { ) ); - $global_styles = 'body{--wp--preset--shadow--natural: 5px 5px 0 0 black;}body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $global_styles = 'body{--wp--preset--shadow--natural: 5px 5px 0 0 black;}body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = 'a:where(:not(.wp-element-button)){box-shadow: var(--wp--preset--shadow--natural);}.wp-element-button, .wp-block-button__link{box-shadow: var(--wp--preset--shadow--natural);}p{box-shadow: var(--wp--preset--shadow--natural);}'; $styles = $global_styles . $element_styles; @@ -573,7 +443,7 @@ public function test_get_stylesheet_handles_whitelisted_element_pseudo_selectors ) ); - $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = 'a:where(:not(.wp-element-button)){background-color: red;color: green;}a:where(:not(.wp-element-button)):hover{background-color: green;color: red;font-size: 10em;text-transform: uppercase;}a:where(:not(.wp-element-button)):focus{background-color: black;color: yellow;}'; @@ -612,7 +482,7 @@ public function test_get_stylesheet_handles_only_pseudo_selector_rules_for_given ) ); - $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = 'a:where(:not(.wp-element-button)):hover{background-color: green;color: red;font-size: 10em;text-transform: uppercase;}a:where(:not(.wp-element-button)):focus{background-color: black;color: yellow;}'; @@ -651,7 +521,7 @@ public function test_get_stylesheet_ignores_pseudo_selectors_on_non_whitelisted_ ) ); - $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = 'h4{background-color: red;color: green;}'; @@ -690,7 +560,7 @@ public function test_get_stylesheet_ignores_non_whitelisted_pseudo_selectors() { ) ); - $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = 'a:where(:not(.wp-element-button)){background-color: red;color: green;}a:where(:not(.wp-element-button)):hover{background-color: green;color: red;}'; @@ -738,7 +608,7 @@ public function test_get_stylesheet_handles_priority_of_elements_vs_block_elemen ) ); - $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = '.wp-block-group a:where(:not(.wp-element-button)){background-color: red;color: green;}.wp-block-group a:where(:not(.wp-element-button)):hover{background-color: green;color: red;font-size: 10em;text-transform: uppercase;}.wp-block-group a:where(:not(.wp-element-button)):focus{background-color: black;color: yellow;}'; @@ -785,7 +655,7 @@ public function test_get_stylesheet_handles_whitelisted_block_level_element_pseu ) ); - $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $element_styles = 'a:where(:not(.wp-element-button)){background-color: red;color: green;}a:where(:not(.wp-element-button)):hover{background-color: green;color: red;}.wp-block-group a:where(:not(.wp-element-button)):hover{background-color: black;color: yellow;}'; @@ -848,7 +718,7 @@ public function test_get_stylesheet_with_deprecated_feature_level_selectors() { ) ); - $base_styles = 'body{--wp--preset--color--green: green;}body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body{--wp--preset--color--green: green;}body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $block_styles = '.wp-block-test, .wp-block-test__wrapper{color: green;}.wp-block-test .inner, .wp-block-test__wrapper .inner{border-radius: 9999px;padding: 20px;}.wp-block-test .sub-heading, .wp-block-test__wrapper .sub-heading{font-size: 3em;}'; $preset_styles = '.has-green-color{color: var(--wp--preset--color--green) !important;}.has-green-background-color{background-color: var(--wp--preset--color--green) !important;}.has-green-border-color{border-color: var(--wp--preset--color--green) !important;}'; $expected = $base_styles . $block_styles . $preset_styles; @@ -910,7 +780,7 @@ public function test_get_stylesheet_with_block_json_selectors() { ) ); - $base_styles = 'body{--wp--preset--color--green: green;}body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $base_styles = 'body{--wp--preset--color--green: green;}body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $block_styles = '.custom-root-selector{background-color: grey;padding: 20px;}.custom-root-selector img{border-radius: 9999px;}.custom-root-selector > figcaption{color: navy;font-size: 3em;}'; $preset_styles = '.has-green-color{color: var(--wp--preset--color--green) !important;}.has-green-background-color{background-color: var(--wp--preset--color--green) !important;}.has-green-border-color{border-color: var(--wp--preset--color--green) !important;}'; $expected = $base_styles . $block_styles . $preset_styles; @@ -1065,7 +935,10 @@ public function test_get_property_value_valid() { ) ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{background-color: #ffffff;color: #000000;}.wp-element-button, .wp-block-button__link{background-color: #000000;color: #ffffff;}'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; + $color_styles = 'body{background-color: #ffffff;color: #000000;}.wp-element-button, .wp-block-button__link{background-color: #000000;color: #ffffff;}'; + + $expected = $base_styles . $color_styles; $this->assertEquals( $expected, $theme_json->get_stylesheet() ); } @@ -1097,7 +970,10 @@ public function test_get_property_value_loop() { ) ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{background-color: #ffffff;}.wp-element-button, .wp-block-button__link{color: #ffffff;}'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; + $color_styles = 'body{background-color: #ffffff;}.wp-element-button, .wp-block-button__link{color: #ffffff;}'; + + $expected = $base_styles . $color_styles; $this->assertSame( $expected, $theme_json->get_stylesheet() ); } @@ -1128,7 +1004,10 @@ public function test_get_property_value_recursion() { ) ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{background-color: #ffffff;color: #ffffff;}.wp-element-button, .wp-block-button__link{color: #ffffff;}'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; + $color_styles = 'body{background-color: #ffffff;color: #ffffff;}.wp-element-button, .wp-block-button__link{color: #ffffff;}'; + + $expected = $base_styles . $color_styles; $this->assertEquals( $expected, $theme_json->get_stylesheet() ); } @@ -1151,7 +1030,10 @@ public function test_get_property_value_self() { ) ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{background-color: #ffffff;}'; + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; + $color_styles = 'body{background-color: #ffffff;}'; + + $expected = $base_styles . $color_styles; $this->assertEquals( $expected, $theme_json->get_stylesheet() ); } @@ -1509,7 +1391,7 @@ public function test_get_styles_for_block_with_padding_aware_alignments() { 'selector' => 'body', ); - $expected = 'body { margin: 0;}.wp-site-blocks { padding-top: var(--wp--style--root--padding-top); padding-bottom: var(--wp--style--root--padding-bottom); }.has-global-padding { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }.has-global-padding :where(.has-global-padding) { padding-right: 0; padding-left: 0; }.has-global-padding > .alignfull { margin-right: calc(var(--wp--style--root--padding-right) * -1); margin-left: calc(var(--wp--style--root--padding-left) * -1); }.has-global-padding :where(.has-global-padding) > .alignfull { margin-right: 0; margin-left: 0; }.has-global-padding > .alignfull:where(:not(.has-global-padding)) > :where([class*="wp-block-"]:not(.alignfull):not([class*="__"]),.wp-block:not(.alignfull),p,h1,h2,h3,h4,h5,h6,ul,ol) { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }.has-global-padding :where(.has-global-padding) > .alignfull:where(:not(.has-global-padding)) > :where([class*="wp-block-"]:not(.alignfull):not([class*="__"]),.wp-block:not(.alignfull),p,h1,h2,h3,h4,h5,h6,ul,ol) { padding-right: 0; padding-left: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{--wp--style--root--padding-top: 10px;--wp--style--root--padding-right: 12px;--wp--style--root--padding-bottom: 10px;--wp--style--root--padding-left: 12px;}'; + $expected = 'body { margin: 0;}.wp-site-blocks { padding-top: var(--wp--style--root--padding-top); padding-bottom: var(--wp--style--root--padding-bottom); }.has-global-padding { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }.has-global-padding :where(.has-global-padding) { padding-right: 0; padding-left: 0; }.has-global-padding > .alignfull { margin-right: calc(var(--wp--style--root--padding-right) * -1); margin-left: calc(var(--wp--style--root--padding-left) * -1); }.has-global-padding :where(.has-global-padding) > .alignfull { margin-right: 0; margin-left: 0; }.has-global-padding > .alignfull:where(:not(.has-global-padding):not(.is-layout-flex):not(.is-layout-grid)) > :where([class*="wp-block-"]:not(.alignfull):not([class*="__"]),.wp-block:not(.alignfull),p,h1,h2,h3,h4,h5,h6,ul,ol) { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }.has-global-padding :where(.has-global-padding) > .alignfull:where(:not(.has-global-padding)) > :where([class*="wp-block-"]:not(.alignfull):not([class*="__"]),.wp-block:not(.alignfull),p,h1,h2,h3,h4,h5,h6,ul,ol) { padding-right: 0; padding-left: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}body{--wp--style--root--padding-top: 10px;--wp--style--root--padding-right: 12px;--wp--style--root--padding-bottom: 10px;--wp--style--root--padding-left: 12px;}'; $root_rules = $theme_json->get_root_layout_rules( WP_Theme_JSON::ROOT_BLOCK_SELECTOR, $metadata ); $style_rules = $theme_json->get_styles_for_block( $metadata ); $this->assertEquals( $expected, $root_rules . $style_rules ); @@ -1539,7 +1421,7 @@ public function test_get_styles_for_block_without_padding_aware_alignments() { 'selector' => 'body', ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }body{padding-top: 10px;padding-right: 12px;padding-bottom: 10px;padding-left: 12px;}'; + $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}body{padding-top: 10px;padding-right: 12px;padding-bottom: 10px;padding-left: 12px;}'; $root_rules = $theme_json->get_root_layout_rules( WP_Theme_JSON::ROOT_BLOCK_SELECTOR, $metadata ); $style_rules = $theme_json->get_styles_for_block( $metadata ); $this->assertEquals( $expected, $root_rules . $style_rules ); @@ -1565,7 +1447,7 @@ public function test_get_styles_for_block_with_content_width() { 'selector' => 'body', ); - $expected = 'body { margin: 0;--wp--style--global--content-size: 800px;--wp--style--global--wide-size: 1000px;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $expected = 'body { margin: 0;--wp--style--global--content-size: 800px;--wp--style--global--wide-size: 1000px;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; $root_rules = $theme_json->get_root_layout_rules( WP_Theme_JSON::ROOT_BLOCK_SELECTOR, $metadata ); $style_rules = $theme_json->get_styles_for_block( $metadata ); $this->assertEquals( $expected, $root_rules . $style_rules ); @@ -1820,6 +1702,85 @@ public function data_get_styles_for_block_with_style_variations() { ); } + public function test_block_style_variations() { + wp_set_current_user( static::$administrator_id ); + + $expected = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'background' => 'blue', + ), + 'variations' => array( + 'outline' => array( + 'color' => array( + 'background' => 'purple', + ), + ), + ), + ), + ), + ), + ); + + $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( $expected ); + + $this->assertSameSetsWithIndex( $expected, $actual ); + } + + public function test_block_style_variations_with_invalid_properties() { + wp_set_current_user( static::$administrator_id ); + + $partially_invalid_variation = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'background' => 'blue', + ), + 'variations' => array( + 'outline' => array( + 'color' => array( + 'background' => 'purple', + ), + 'invalid' => array( + 'value' => 'should be stripped', + ), + ), + ), + ), + ), + ), + ); + + $expected = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'background' => 'blue', + ), + 'variations' => array( + 'outline' => array( + 'color' => array( + 'background' => 'purple', + ), + ), + ), + ), + ), + ), + ); + + $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( $partially_invalid_variation ); + + $this->assertSameSetsWithIndex( $expected, $actual ); + } + public function test_update_separator_declarations() { // If only background is defined, test that includes border-color to the style so it is applied on the front end. $theme_json = new WP_Theme_JSON_Gutenberg( @@ -1837,8 +1798,10 @@ public function test_update_separator_declarations() { ), 'default' ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.wp-block-separator{background-color: blue;color: blue;}'; - $stylesheet = $theme_json->get_stylesheet( array( 'styles' ) ); + + $base_styles = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}body .is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}body .is-layout-flex > *{margin: 0;}body .is-layout-grid{display: grid;}body .is-layout-grid > *{margin: 0;}'; + $expected = $base_styles . '.wp-block-separator{background-color: blue;color: blue;}'; + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ) ); $this->assertEquals( $expected, $stylesheet ); // If background and text are defined, do not include border-color, as text color is enough. @@ -1858,7 +1821,8 @@ public function test_update_separator_declarations() { ), 'default' ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.wp-block-separator{background-color: blue;color: red;}'; + + $expected = $base_styles . '.wp-block-separator{background-color: blue;color: red;}'; $stylesheet = $theme_json->get_stylesheet( array( 'styles' ) ); $this->assertEquals( $expected, $stylesheet ); @@ -1878,7 +1842,7 @@ public function test_update_separator_declarations() { ), 'default' ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.wp-block-separator{color: red;}'; + $expected = $base_styles . '.wp-block-separator{color: red;}'; $stylesheet = $theme_json->get_stylesheet( array( 'styles' ) ); $this->assertEquals( $expected, $stylesheet ); @@ -1902,7 +1866,7 @@ public function test_update_separator_declarations() { ), 'default' ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.wp-block-separator{background-color: blue;border-color: pink;color: red;}'; + $expected = $base_styles . '.wp-block-separator{background-color: blue;border-color: pink;color: red;}'; $stylesheet = $theme_json->get_stylesheet( array( 'styles' ) ); $this->assertEquals( $expected, $stylesheet ); @@ -1925,7 +1889,7 @@ public function test_update_separator_declarations() { ), 'default' ); - $expected = 'body { margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.wp-block-separator{background-color: blue;border-color: pink;}'; + $expected = $base_styles . '.wp-block-separator{background-color: blue;border-color: pink;}'; $stylesheet = $theme_json->get_stylesheet( array( 'styles' ) ); $this->assertEquals( $expected, $stylesheet ); @@ -2136,4 +2100,158 @@ public function test_internal_syntax_is_converted_to_css_variables() { $this->assertEquals( 'var(--wp--preset--color--s)', $styles['blocks']['core/quote']['variations']['plain']['color']['background'], 'Style variations: Assert the internal variables are convert to CSS custom variables.' ); } + + public function test_resolve_variables() { + $primary_color = '#9DFF20'; + $secondary_color = '#9DFF21'; + $contrast_color = '#000'; + $raw_color_value = '#efefef'; + $large_font = '18px'; + $small_font = '12px'; + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'color' => array( + 'palette' => array( + 'theme' => array( + array( + 'color' => $primary_color, + 'name' => 'Primary', + 'slug' => 'primary', + ), + array( + 'color' => $secondary_color, + 'name' => 'Secondary', + 'slug' => 'secondary', + ), + array( + 'color' => $contrast_color, + 'name' => 'Contrast', + 'slug' => 'contrast', + ), + ), + ), + ), + 'typography' => array( + 'fontSizes' => array( + array( + 'size' => $small_font, + 'name' => 'Font size small', + 'slug' => 'small', + ), + array( + 'size' => $large_font, + 'name' => 'Font size large', + 'slug' => 'large', + ), + ), + ), + ), + 'styles' => array( + 'color' => array( + 'background' => 'var(--wp--preset--color--primary)', + 'text' => $raw_color_value, + ), + 'elements' => array( + 'button' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--contrast)', + ), + 'typography' => array( + 'fontSize' => 'var(--wp--preset--font-size--small)', + ), + ), + ), + 'blocks' => array( + 'core/post-terms' => array( + 'typography' => array( 'fontSize' => 'var(--wp--preset--font-size--small)' ), + 'color' => array( 'background' => $raw_color_value ), + ), + 'core/more' => array( + 'typography' => array( 'fontSize' => 'var(--undefined--font-size--small)' ), + 'color' => array( 'background' => 'linear-gradient(90deg, var(--wp--preset--color--primary) 0%, var(--wp--preset--color--secondary) 35%, var(--wp--undefined--color--secondary) 100%)' ), + ), + 'core/comment-content' => array( + 'typography' => array( 'fontSize' => 'calc(var(--wp--preset--font-size--small, 12px) + 20px)' ), + 'color' => array( + 'text' => 'var(--wp--preset--color--primary, red)', + 'background' => 'var(--wp--preset--color--primary, var(--wp--preset--font-size--secondary))', + 'link' => 'var(--undefined--color--primary, var(--wp--preset--font-size--secondary))', + ), + ), + 'core/comments' => array( + 'color' => array( + 'text' => 'var(--undefined--color--primary, var(--wp--preset--font-size--small))', + 'background' => 'var(--wp--preset--color--primary, var(--undefined--color--primary))', + ), + ), + 'core/navigation' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'background' => 'var(--wp--preset--color--primary)', + 'text' => 'var(--wp--preset--color--secondary)', + ), + 'typography' => array( + 'fontSize' => 'var(--wp--preset--font-size--large)', + ), + ), + ), + ), + 'core/quote' => array( + 'typography' => array( 'fontSize' => 'var(--wp--preset--font-size--large)' ), + 'color' => array( 'background' => 'var(--wp--preset--color--primary)' ), + 'variations' => array( + 'plain' => array( + 'typography' => array( 'fontSize' => 'var(--wp--preset--font-size--small)' ), + 'color' => array( 'background' => 'var(--wp--preset--color--secondary)' ), + ), + ), + ), + ), + ), + ) + ); + + $styles = $theme_json::resolve_variables( $theme_json )->get_raw_data()['styles']; + + $this->assertEquals( $primary_color, $styles['color']['background'], 'Top level: Assert values are converted' ); + $this->assertEquals( $raw_color_value, $styles['color']['text'], 'Top level: Assert raw values stay intact' ); + + $this->assertEquals( $contrast_color, $styles['elements']['button']['color']['text'], 'Elements: color' ); + $this->assertEquals( $small_font, $styles['elements']['button']['typography']['fontSize'], 'Elements: font-size' ); + + $this->assertEquals( $large_font, $styles['blocks']['core/quote']['typography']['fontSize'], 'Blocks: font-size' ); + $this->assertEquals( $primary_color, $styles['blocks']['core/quote']['color']['background'], 'Blocks: color' ); + $this->assertEquals( $raw_color_value, $styles['blocks']['core/post-terms']['color']['background'], 'Blocks: Raw color value stays intact' ); + $this->assertEquals( $small_font, $styles['blocks']['core/post-terms']['typography']['fontSize'], 'Block core/post-terms: font-size' ); + $this->assertEquals( + "linear-gradient(90deg, $primary_color 0%, $secondary_color 35%, var(--wp--undefined--color--secondary) 100%)", + $styles['blocks']['core/more']['color']['background'], + 'Blocks: multiple colors and undefined color' + ); + $this->assertEquals( 'var(--undefined--font-size--small)', $styles['blocks']['core/more']['typography']['fontSize'], 'Blocks: undefined font-size ' ); + $this->assertEquals( "calc($small_font + 20px)", $styles['blocks']['core/comment-content']['typography']['fontSize'], 'Blocks: font-size in random place' ); + $this->assertEquals( $primary_color, $styles['blocks']['core/comment-content']['color']['text'], 'Blocks: text color with fallback' ); + $this->assertEquals( $primary_color, $styles['blocks']['core/comment-content']['color']['background'], 'Blocks: background color with var as fallback' ); + $this->assertEquals( $primary_color, $styles['blocks']['core/navigation']['elements']['link']['color']['background'], 'Block element: background color' ); + $this->assertEquals( $secondary_color, $styles['blocks']['core/navigation']['elements']['link']['color']['text'], 'Block element: text color' ); + $this->assertEquals( $large_font, $styles['blocks']['core/navigation']['elements']['link']['typography']['fontSize'], 'Block element: font-size' ); + + $this->assertEquals( + "var(--undefined--color--primary, $small_font)", + $styles['blocks']['core/comments']['color']['text'], + 'Blocks: text color with undefined var and fallback' + ); + $this->assertEquals( + $primary_color, + $styles['blocks']['core/comments']['color']['background'], + 'Blocks: background color with variable and undefined fallback' + ); + + $this->assertEquals( $small_font, $styles['blocks']['core/quote']['variations']['plain']['typography']['fontSize'], 'Block variations: font-size' ); + $this->assertEquals( $secondary_color, $styles['blocks']['core/quote']['variations']['plain']['color']['background'], 'Block variations: color' ); + } + } diff --git a/phpunit/data/themedir1/block-theme-child-with-fluid-layout/style.css b/phpunit/data/themedir1/block-theme-child-with-fluid-layout/style.css new file mode 100644 index 00000000000000..193b8f098af64b --- /dev/null +++ b/phpunit/data/themedir1/block-theme-child-with-fluid-layout/style.css @@ -0,0 +1,8 @@ +/* +Theme Name: Block Theme Child Theme With Fluid Layout +Theme URI: https://wordpress.org/ +Description: For testing purposes only. +Template: block-theme +Version: 1.0.0 +Text Domain: block-theme-child-with-fluid-layout +*/ diff --git a/phpunit/data/themedir1/block-theme-child-with-fluid-layout/theme.json b/phpunit/data/themedir1/block-theme-child-with-fluid-layout/theme.json new file mode 100644 index 00000000000000..6985da16c60636 --- /dev/null +++ b/phpunit/data/themedir1/block-theme-child-with-fluid-layout/theme.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "settings": { + "appearanceTools": true, + "layout": { + "wideSize": "clamp(1000px, 85vw, 2000px)" + }, + "typography": { + "fluid": true + } + } +} diff --git a/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json b/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json index 73864f2920039d..65ed480f20e166 100644 --- a/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json +++ b/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json @@ -7,7 +7,9 @@ }, "typography": { "fluid": { - "minFontSize": "16px" + "minFontSize": "16px", + "maxViewportWidth": "1200px", + "minViewportWidth": "640px" } } } diff --git a/phpunit/experimental/class-wp-navigation-fallback-gutenberg-test.php b/phpunit/experimental/class-wp-navigation-fallback-gutenberg-test.php deleted file mode 100644 index fe32e8a640c078..00000000000000 --- a/phpunit/experimental/class-wp-navigation-fallback-gutenberg-test.php +++ /dev/null @@ -1,338 +0,0 @@ -<?php -/** - * Tests WP_Navigation_Fallback_Gutenberg - * - * @package WordPress - */ - -/** - * Tests for the WP_Navigation_Fallback_Gutenberg class. - */ -class WP_Navigation_Fallback_Gutenberg_Test extends WP_UnitTestCase { - - protected static $admin_user; - protected static $editor_user; - - public static function wpSetUpBeforeClass( $factory ) { - self::$admin_user = $factory->user->create( array( 'role' => 'administrator' ) ); - - self::$editor_user = $factory->user->create( array( 'role' => 'editor' ) ); - } - - public function set_up() { - parent::set_up(); - - wp_set_current_user( self::$admin_user ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller - */ - public function test_it_exists() { - $this->assertTrue( class_exists( 'WP_Navigation_Fallback_Gutenberg' ), 'WP_Navigation_Fallback_Gutenberg class should exist.' ); - } - - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_return_a_default_fallback_navigation_menu_in_absence_of_other_fallbacks() { - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( 'wp_navigation', $data->post_type, 'Fallback menu type should be `wp_navigation`' ); - - $this->assertEquals( 'Navigation', $data->post_title, 'Fallback menu title should be the default fallback title' ); - - $this->assertEquals( 'navigation', $data->post_name, 'Fallback menu slug (post_name) should be the default slug' ); - - $this->assertEquals( '<!-- wp:page-list /-->', $data->post_content ); - - $navs_in_db = $this->get_navigations_in_database(); - - $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_return_a_default_fallback_navigation_menu_with_no_blocks_if_page_list_block_is_not_registered() { - - $original_page_list_block = WP_Block_Type_Registry::get_instance()->get_registered( 'core/page-list' ); - - unregister_block_type( 'core/page-list' ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertNotEquals( '<!-- wp:page-list /-->', $data->post_content, 'Navigation Menu should not contain a Page List block.' ); - - $this->assertEmpty( $data->post_content, 'Menu should be empty.' ); - - register_block_type( 'core/page-list', $original_page_list_block ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_handle_consecutive_invocations() { - // Invoke the method multiple times to ensure that it doesn't create a new fallback menu on each invocation. - WP_Navigation_Fallback_Gutenberg::get_fallback(); - WP_Navigation_Fallback_Gutenberg::get_fallback(); - - // Assert on the final invocation. - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( 'Navigation', $data->post_title, 'Fallback menu title should be the default title' ); - - $navs_in_db = $this->get_navigations_in_database(); - - $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_return_the_most_recently_created_navigation_menu() { - - self::factory()->post->create_and_get( - array( - 'post_type' => 'wp_navigation', - 'post_title' => 'Existing Navigation Menu 1', - 'post_content' => '<!-- wp:page-list /-->', - ) - ); - - $most_recently_published_nav = self::factory()->post->create_and_get( - array( - 'post_type' => 'wp_navigation', - 'post_title' => 'Existing Navigation Menu 2', - 'post_content' => '<!-- wp:navigation-link {"label":"Hello world","type":"post","id":1,"url":"/hello-world","kind":"post-type"} /-->', - ) - ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( $most_recently_published_nav->post_title, $data->post_title, 'Fallback menu title should be the same as the most recently created menu.' ); - - $this->assertEquals( $most_recently_published_nav->post_name, $data->post_name, 'Post name should be the same as the most recently created menu.' ); - - $this->assertEquals( $most_recently_published_nav->post_content, $data->post_content, 'Post content should be the same as the most recently created menu.' ); - - // Check that no new Navigation menu was created. - $navs_in_db = $this->get_navigations_in_database(); - - $this->assertCount( 2, $navs_in_db, 'Only the existing Navigation menus should be present in the database.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_return_fallback_navigation_from_existing_classic_menu_if_no_navigation_menus_exist() { - $menu_id = wp_create_nav_menu( 'Existing Classic Menu' ); - - wp_update_nav_menu_item( - $menu_id, - 0, - array( - 'menu-item-title' => 'Classic Menu Item 1', - 'menu-item-url' => '/classic-menu-item-1', - 'menu-item-status' => 'publish', - ) - ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( 'Existing Classic Menu', $data->post_title, 'Fallback menu title should be the same as the classic menu.' ); - - // Assert that the fallback contains a navigation-link block. - $this->assertStringContainsString( '<!-- wp:navigation-link', $data->post_content, 'The fallback Navigation Menu should contain a `core/navigation-link` block.' ); - - // Assert that fallback post_content contains the expected menu item title. - $this->assertStringContainsString( '"label":"Classic Menu Item 1"', $data->post_content, 'The fallback Navigation Menu should contain menu item with a label matching the title of the menu item from the Classic Menu.' ); - - // Assert that fallback post_content contains the expected menu item url. - $this->assertStringContainsString( '"url":"/classic-menu-item-1"', $data->post_content, 'The fallback Navigation Menu should contain menu item with a url matching the slug of the menu item from the Classic Menu.' ); - - // Check that only a single Navigation fallback was created. - $navs_in_db = $this->get_navigations_in_database(); - $this->assertCount( 1, $navs_in_db, 'A single Navigation menu should be present in the database.' ); - - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_prioritise_fallback_to_classic_menu_in_primary_location() { - $pl_menu_id = wp_create_nav_menu( 'Classic Menu in Primary Location' ); - - wp_update_nav_menu_item( - $pl_menu_id, - 0, - array( - 'menu-item-title' => 'PL Classic Menu Item', - 'menu-item-url' => '/pl-classic-menu-item', - 'menu-item-status' => 'publish', - ) - ); - - $another_menu_id = wp_create_nav_menu( 'Another Classic Menu' ); - - wp_update_nav_menu_item( - $another_menu_id, - 0, - array( - 'menu-item-title' => 'Another Classic Menu Item', - 'menu-item-url' => '/another-classic-menu-item', - 'menu-item-status' => 'publish', - ) - ); - - $locations = get_nav_menu_locations(); - $locations['primary'] = $pl_menu_id; - $locations['header'] = $another_menu_id; - set_theme_mod( 'nav_menu_locations', $locations ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( 'Classic Menu in Primary Location', $data->post_title, 'Fallback menu title should match the menu in the "primary" location.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_fallback_to_classic_menu_with_primary_slug() { - - // Creates a classic menu with the slug "primary". - $primary_menu_id = wp_create_nav_menu( 'Primary' ); - - wp_update_nav_menu_item( - $primary_menu_id, - 0, - array( - 'menu-item-title' => 'Classic Menu Item', - 'menu-item-url' => '/classic-menu-item', - 'menu-item-status' => 'publish', - ) - ); - - $another_menu_id = wp_create_nav_menu( 'Another Classic Menu' ); - - wp_update_nav_menu_item( - $another_menu_id, - 0, - array( - 'menu-item-title' => 'Another Classic Menu Item', - 'menu-item-url' => '/another-classic-menu-item', - 'menu-item-status' => 'publish', - ) - ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( 'Primary', $data->post_title, 'Fallback menu title should match the menu with the slug "primary".' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_fallback_to_most_recently_created_classic_menu() { - - // Creates a classic menu with the slug "primary". - $primary_menu_id = wp_create_nav_menu( 'Older Classic Menu' ); - - wp_update_nav_menu_item( - $primary_menu_id, - 0, - array( - 'menu-item-title' => 'Classic Menu Item', - 'menu-item-url' => '/classic-menu-item', - 'menu-item-status' => 'publish', - ) - ); - - $most_recent_menu_id = wp_create_nav_menu( 'Most Recent Classic Menu' ); - - wp_update_nav_menu_item( - $most_recent_menu_id, - 0, - array( - 'menu-item-title' => 'Another Classic Menu Item', - 'menu-item-url' => '/another-classic-menu-item', - 'menu-item-status' => 'publish', - ) - ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( 'Most Recent Classic Menu', $data->post_title, 'Fallback menu title should match the menu that was created most recently.' ); - } - - /** - * @covers WP_REST_Navigation_Fallback_Controller::get_fallback - */ - public function test_should_not_create_fallback_from_classic_menu_if_a_navigation_menu_already_exists() { - $menu_id = wp_create_nav_menu( 'Existing Classic Menu' ); - - wp_update_nav_menu_item( - $menu_id, - 0, - array( - 'menu-item-title' => 'Classic Menu Item 1', - 'menu-item-url' => '/classic-menu-item-1', - 'menu-item-status' => 'publish', - ) - ); - - $existing_navigation_menu = self::factory()->post->create_and_get( - array( - 'post_type' => 'wp_navigation', - 'post_title' => 'Existing Navigation Menu 1', - 'post_content' => '<!-- wp:page-list /-->', - ) - ); - - $data = WP_Navigation_Fallback_Gutenberg::get_fallback(); - - $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); - - $this->assertEquals( $existing_navigation_menu->post_title, $data->post_title, 'Fallback menu title should be the same as the existing Navigation menu.' ); - - $this->assertNotEquals( 'Existing Classic Menu', $data->post_title, 'Fallback menu title should not be the same as the Classic Menu.' ); - - // Check that only a single Navigation fallback was created. - $navs_in_db = $this->get_navigations_in_database(); - - $this->assertCount( 1, $navs_in_db, 'Only the existing Navigation menus should be present in the database.' ); - - } - - private function get_navigations_in_database() { - $navs_in_db = new WP_Query( - array( - 'post_type' => 'wp_navigation', - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'date', - 'order' => 'DESC', - ) - ); - - return $navs_in_db->posts ? $navs_in_db->posts : array(); - } - -} diff --git a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php new file mode 100644 index 00000000000000..2b01cb6251c210 --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php @@ -0,0 +1,132 @@ +<?php +/** + * `WP_Directive_Processor` class test. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * @group interactivity-api + * @covers WP_Directive_Processor + */ +class WP_Directive_Processor_Test extends WP_UnitTestCase { + const HTML = '<div>outside</div><section><div><img>inside</div></section>'; + + public function test_next_balanced_closer_stays_on_void_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'img' ); + $result = $tags->next_balanced_closer(); + $this->assertSame( 'IMG', $tags->get_tag() ); + $this->assertFalse( $result ); + } + + public function test_next_balanced_closer_proceeds_to_correct_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->next_balanced_closer(); + $this->assertSame( 'SECTION', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + } + + public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'div' ); + $tags->next_tag( 'div' ); + $tags->next_balanced_closer(); + $this->assertSame( 'DIV', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + } + + public function test_get_inner_html_returns_correct_result() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $this->assertSame( '<div><img>inside</div>', $tags->get_inner_html() ); + } + + public function test_set_inner_html_on_void_element_has_no_effect() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'img' ); + $content = $tags->set_inner_html( 'This is the new img content' ); + $this->assertFalse( $content ); + $this->assertSame( self::HTML, $tags->get_updated_html() ); + } + + public function test_set_inner_html_sets_content_correctly() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '<div>outside</div><section>This is the new section content.</section>', $tags->get_updated_html() ); + } + + public function test_set_inner_html_updates_bookmarks_correctly() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'div' ); + $tags->set_bookmark( 'start' ); + $tags->next_tag( 'img' ); + $this->assertSame( 'IMG', $tags->get_tag() ); + $tags->set_bookmark( 'after' ); + $tags->seek( 'start' ); + + $tags->set_inner_html( 'This is the new div content.' ); + $this->assertSame( '<div>This is the new div content.</div><section><div><img>inside</div></section>', $tags->get_updated_html() ); + $tags->seek( 'after' ); + $this->assertSame( 'IMG', $tags->get_tag() ); + } + + public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $tags->set_inner_html( 'This is the even newer section content.' ); + $this->assertSame( '<div>outside</div><section>This is the even newer section content.</section>', $tags->get_updated_html() ); + } + + public function test_set_inner_html_followed_by_set_attribute_works() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $tags->set_attribute( 'id', 'thesection' ); + $this->assertSame( '<div>outside</div><section id="thesection">This is the new section content.</section>', $tags->get_updated_html() ); + } + + public function test_set_inner_html_preceded_by_set_attribute_works() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_attribute( 'id', 'thesection' ); + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '<div>outside</div><section id="thesection">This is the new section content.</section>', $tags->get_updated_html() ); + } + + /** + * TODO: Review this, how that the code is in Gutenberg. + */ + public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() { + $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." ); + + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_bookmark( 'start' ); + $tags->next_tag( 'img' ); + $tags->set_bookmark( 'replaced' ); + $tags->seek( 'start' ); + + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '<div>outside</div><section>This is the new section content.</section>', $tags->get_updated_html() ); + + $this->expectExceptionMessage( 'Invalid bookmark name' ); + $successful_seek = $tags->seek( 'replaced' ); + $this->assertFalse( $successful_seek ); + } +} diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php new file mode 100644 index 00000000000000..22205289b20bee --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php @@ -0,0 +1,186 @@ +<?php +/** + * `WP_Interactivity_Store` class test. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * Tests for the `WP_Interactivity_Store` class. + * + * @group interactivity-api + * @covers WP_Interactivity_Store + */ +class WP_Interactivity_Store_Test extends WP_UnitTestCase { + public function set_up() { + // Clear the state before each test. + WP_Interactivity_Store::reset(); + } + + public function test_store_should_be_empty() { + $this->assertEmpty( WP_Interactivity_Store::get_data() ); + } + + public function test_store_can_be_merged() { + $data = array( + 'state' => array( + 'core' => array( + 'a' => 1, + 'b' => 2, + 'nested' => array( + 'c' => 3, + ), + ), + ), + ); + WP_Interactivity_Store::merge_data( $data ); + $this->assertSame( $data, WP_Interactivity_Store::get_data() ); + } + + public function test_store_can_be_extended() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_existing_props_should_be_overwritten() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_existing_indexed_arrays_should_be_replaced() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => array( 1, 2 ), + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_should_be_correctly_rendered() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'b' => 2, + ), + ), + ) + ); + ob_start(); + WP_Interactivity_Store::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '<script id="wp-interactivity-store-data" type="application/json">{"state":{"core":{"a":1,"b":2}}}</script>', + $rendered + ); + } + + public function test_store_should_also_escape_tags_and_amps() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'amps' => 'http://site.test/?foo=1&baz=2&bar=3', + 'tags' => 'Do not do this: <!-- <script>', + ), + ) + ); + ob_start(); + WP_Interactivity_Store::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '<script id="wp-interactivity-store-data" type="application/json">{"state":{"amps":"http:\/\/site.test\/?foo=1\u0026baz=2\u0026bar=3","tags":"Do not do this: \u003C!-- \u003Cscript\u003E"}}</script>', + $rendered + ); + } +} diff --git a/phpunit/experimental/interactivity-api/directive-processing-test.php b/phpunit/experimental/interactivity-api/directive-processing-test.php new file mode 100644 index 00000000000000..4da0a4a85ac3c0 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directive-processing-test.php @@ -0,0 +1,170 @@ +<?php +/** + * Directive processing test. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +class Helper_Class { + // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + function process_foo_test( $tags, $context ) { + } + + function increment( $store ) { + return $store['state']['count'] + $store['context']['count']; + } + + static function static_increment( $store ) { + return $store['state']['count'] + $store['context']['count']; + } +} + +function gutenberg_test_process_directives_helper_increment( $store ) { + return $store['state']['count'] + $store['context']['count']; +} + +/** + * Tests for the gutenberg_interactivity_process_directives function. + * + * @group interactivity-api + * @covers gutenberg_interactivity_process_directives + */ +class Tests_Process_Directives extends WP_UnitTestCase { + public function test_correctly_call_attribute_directive_processor_on_closing_tag() { + + // PHPUnit cannot stub functions, only classes. + $test_helper = $this->createMock( Helper_Class::class ); + + $test_helper->expects( $this->exactly( 2 ) ) + ->method( 'process_foo_test' ) + ->with( + $this->callback( + function( $p ) { + return 'DIV' === $p->get_tag() && ( + // Either this is a closing tag... + $p->is_tag_closer() || + // ...or it is an open tag, and has the directive attribute set. + ( ! $p->is_tag_closer() && 'abc' === $p->get_attribute( 'foo-test' ) ) + ); + } + ) + ); + + $directives = array( + 'foo-test' => array( $test_helper, 'process_foo_test' ), + ); + + $markup = '<div>Example: <div foo-test="abc"><img><span>This is a test></span><div>Here is a nested div</div></div></div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + } + + public function test_directives_with_double_hyphen_processed_correctly() { + $test_helper = $this->createMock( Helper_Class::class ); + $test_helper->expects( $this->atLeastOnce() ) + ->method( 'process_foo_test' ); + + $directives = array( + 'foo-test' => array( $test_helper, 'process_foo_test' ), + ); + + $markup = '<div foo-test--value="abc"></div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + } +} + +/** + * Tests for the gutenberg_interactivity_evaluate_reference function. + * + * @group interactivity-api + * @covers gutenberg_interactivity_evaluate_reference + */ +class Tests_Utils_Evaluate extends WP_UnitTestCase { + public function test_evaluate_function_should_access_state() { + // Init a simple store. + wp_store( + array( + 'state' => array( + 'core' => array( + 'number' => 1, + 'bool' => true, + 'nested' => array( + 'string' => 'hi', + ), + ), + ), + ) + ); + $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.core.number' ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.core.bool' ) ); + $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.core.nested.string' ) ); + $this->assertFalse( gutenberg_interactivity_evaluate_reference( '!state.core.bool' ) ); + } + + public function test_evaluate_function_should_access_passed_context() { + $context = array( + 'local' => array( + 'number' => 2, + 'bool' => false, + 'nested' => array( + 'string' => 'bye', + ), + ), + ); + $this->assertSame( 2, gutenberg_interactivity_evaluate_reference( 'context.local.number', $context ) ); + $this->assertFalse( gutenberg_interactivity_evaluate_reference( 'context.local.bool', $context ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( '!context.local.bool', $context ) ); + $this->assertSame( 'bye', gutenberg_interactivity_evaluate_reference( 'context.local.nested.string', $context ) ); + // Previously defined state is also accessible. + $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.core.number' ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.core.bool' ) ); + $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.core.nested.string' ) ); + } + + public function test_evaluate_function_should_return_null_for_unresolved_paths() { + $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist' ) ); + } + + public function test_evaluate_function_should_execute_anonymous_functions() { + $context = new WP_Directive_Context( array( 'count' => 2 ) ); + $helper = new Helper_Class; + + wp_store( + array( + 'state' => array( + 'count' => 3, + ), + 'selectors' => array( + 'anonymous_function' => function( $store ) { + return $store['state']['count'] + $store['context']['count']; + }, + // Other types of callables should not be executed. + 'function_name' => 'gutenberg_test_process_directives_helper_increment', + 'class_method' => array( $helper, 'increment' ), + 'class_static_method' => 'Helper_Class::static_increment', + 'class_static_method_as_array' => array( 'Helper_Class', 'static_increment' ), + ), + ) + ); + + $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.anonymous_function', $context->get_context() ) ); + $this->assertSame( + 'gutenberg_test_process_directives_helper_increment', + gutenberg_interactivity_evaluate_reference( 'selectors.function_name', $context->get_context() ) + ); + $this->assertSame( + array( $helper, 'increment' ), + gutenberg_interactivity_evaluate_reference( 'selectors.class_method', $context->get_context() ) + ); + $this->assertSame( + 'Helper_Class::static_increment', + gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method', $context->get_context() ) + ); + $this->assertSame( + array( 'Helper_Class', 'static_increment' ), + gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method_as_array', $context->get_context() ) + ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php new file mode 100644 index 00000000000000..bfb4c428cd9466 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php @@ -0,0 +1,46 @@ +<?php +/** + * Tests for the wp-bind directive. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * Tests for the wp-bind directive. + * + * @group interactivity-api + * @covers gutenberg_interactivity_process_wp_bind + */ +class Tests_Directives_WpBind extends WP_UnitTestCase { + public function test_directive_sets_attribute() { + $markup = '<img data-wp-bind--src="context.myblock.imageSource" />'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_bind( $tags, $context ); + + $this->assertSame( + '<img src="./wordpress.png" data-wp-bind--src="context.myblock.imageSource" />', + $tags->get_updated_html() + ); + $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); + } + + public function test_directive_ignores_empty_bound_attribute() { + $markup = '<img data-wp-bind.="context.myblock.imageSource" />'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_bind( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertNull( $tags->get_attribute( 'src' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php new file mode 100644 index 00000000000000..419546c6d9ef8b --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-class-test.php @@ -0,0 +1,98 @@ +<?php +/** + * Tests for the wp-class directive. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * Tests for the wp-class directive. + * + * @group interactivity-api + * @covers gutenberg_interactivity_process_wp_class + */ +class Tests_Directives_WpClass extends WP_UnitTestCase { + public function test_directive_adds_class() { + $markup = '<div data-wp-class--red="context.myblock.isRed" class="blue">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '<div data-wp-class--red="context.myblock.isRed" class="blue red">Test</div>', + $tags->get_updated_html() + ); + $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_removes_class() { + $markup = '<div data-wp-class--blue="context.myblock.isBlue" class="red blue">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '<div data-wp-class--blue="context.myblock.isBlue" class="red">Test</div>', + $tags->get_updated_html() + ); + $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_removes_empty_class_attribute() { + $markup = '<div data-wp-class--blue="context.myblock.isBlue" class="blue">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal. + '<div data-wp-class--blue="context.myblock.isBlue" >Test</div>', + $tags->get_updated_html() + ); + $this->assertNull( $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_does_not_remove_non_existant_class() { + $markup = '<div data-wp-class--blue="context.myblock.isBlue" class="green red">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '<div data-wp-class--blue="context.myblock.isBlue" class="green red">Test</div>', + $tags->get_updated_html() + ); + $this->assertSame( 'green red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_ignores_empty_class_name() { + $markup = '<div data-wp-class.="context.myblock.isRed" class="blue">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php new file mode 100644 index 00000000000000..90ffb7dd9bf296 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-context-test.php @@ -0,0 +1,77 @@ +<?php +/** + * Tests for the wp-context directive. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * Tests for the wp-context directive. + * + * @group interactivity-api + * @covers gutenberg_interactivity_process_wp_context + */ +class Tests_Directives_Attributes_WpContext extends WP_UnitTestCase { + public function test_directive_merges_context_correctly_upon_wp_context_attribute_on_opening_tag() { + $context = new WP_Directive_Context( + array( + 'myblock' => array( 'open' => false ), + 'otherblock' => array( 'somekey' => 'somevalue' ), + ) + ); + + $markup = '<div data-wp-context=\'{ "myblock": { "open": true } }\'>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( + 'myblock' => array( 'open' => true ), + 'otherblock' => array( 'somekey' => 'somevalue' ), + ), + $context->get_context() + ); + } + + public function test_directive_resets_context_correctly_upon_closing_tag() { + $context = new WP_Directive_Context( + array( 'my-key' => 'original-value' ) + ); + + $context->set_context( + array( 'my-key' => 'new-value' ) + ); + + $markup = '</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag( array( 'tag_closers' => 'visit' ) ); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( 'my-key' => 'original-value' ), + $context->get_context() + ); + } + + public function test_directive_doesnt_throw_on_malformed_context_objects() { + $context = new WP_Directive_Context( + array( 'my-key' => 'some-value' ) + ); + + $markup = '<div data-wp-context=\'{ "wrong_json_object: }\'>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( 'my-key' => 'some-value' ), + $context->get_context() + ); + } + +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php new file mode 100644 index 00000000000000..8942559b2fe89f --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-style-test.php @@ -0,0 +1,46 @@ +<?php +/** + * Tests for the wp-style directive. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * Tests for the wp-style directive. + * + * @group interactivity-api + * @covers gutenberg_interactivity_process_wp_style + */ +class Tests_Directives_WpStyle extends WP_UnitTestCase { + public function test_directive_adds_style() { + $markup = '<div data-wp-style--color="context.myblock.color" style="background: blue;">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_style( $tags, $context ); + + $this->assertSame( + '<div data-wp-style--color="context.myblock.color" style="background: blue;color: green;">Test</div>', + $tags->get_updated_html() + ); + $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); + } + + public function test_directive_ignores_empty_style() { + $markup = '<div data-wp-style.="context.myblock.color" style="background: blue;">Test</div>'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_style( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php new file mode 100644 index 00000000000000..81d2d0f370a64b --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-text-test.php @@ -0,0 +1,45 @@ +<?php +/** + * Tests for the wp-text directive. + * + * @package Gutenberg + * @subpackage Interactivity API + */ + +/** + * Tests for the wp-text directive. + * + * @group interactivity-api + * @covers gutenberg_interactivity_process_wp_text + */ +class Tests_Directives_WpText extends WP_UnitTestCase { + public function test_directive_sets_inner_html_based_on_attribute_value_and_escapes_html() { + $markup = '<div data-wp-text="context.myblock.someText"></div>'; + + $tags = new WP_Directive_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag <br> produces a line break.' ) ) ); + $context = clone $context_before; + gutenberg_interactivity_process_wp_text( $tags, $context ); + + $expected_markup = '<div data-wp-text="context.myblock.someText">The HTML tag &lt;br&gt; produces a line break.</div>'; + $this->assertSame( $expected_markup, $tags->get_updated_html() ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); + } + + public function test_directive_overwrites_inner_html_based_on_attribute_value() { + $markup = '<div data-wp-text="context.myblock.someText">Lorem ipsum dolor sit.</div>'; + + $tags = new WP_Directive_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) ); + $context = clone $context_before; + gutenberg_interactivity_process_wp_text( $tags, $context ); + + $expected_markup = '<div data-wp-text="context.myblock.someText">Honi soit qui mal y pense.</div>'; + $this->assertSame( $expected_markup, $tags->get_updated_html() ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); + } +} diff --git a/phpunit/fixtures/block.json b/phpunit/fixtures/block.json index 41904ae8119623..2ab91788dc0d43 100644 --- a/phpunit/fixtures/block.json +++ b/phpunit/fixtures/block.json @@ -1,5 +1,5 @@ { - "apiVersion": 2, + "apiVersion": 3, "name": "my-plugin/notice", "title": "Notice", "category": "common", diff --git a/phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php b/phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php deleted file mode 100644 index 6c5e3bad800b8e..00000000000000 --- a/phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php +++ /dev/null @@ -1,46 +0,0 @@ -<?php -/** - * Test case for the Fonts API's BC Layer tests. - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; -require_once __DIR__ . '/bc-layer-tests-dataset.php'; - -/** - * Abstracts the common tasks for the Font API's BC Layer tests. - */ -abstract class Fonts_BcLayer_TestCase extends WP_Fonts_TestCase { - use BC_Layer_Tests_Datasets; - - /** - * Original WP_Webfonts instance, before the tests. - * - * @var WP_Fonts - */ - private $old_wp_webfonts; - - public function set_up() { - parent::set_up(); - - $this->old_wp_webfonts = isset( $GLOBALS['wp_webfonts'] ) ? $GLOBALS['wp_webfonts'] : null; - $GLOBALS['wp_webfonts'] = null; - } - - public function tear_down() { - $GLOBALS['wp_webfonts'] = $this->old_wp_webfonts; - - parent::tear_down(); - } - - protected function set_up_webfonts_mock( $method ) { - $mock = $this->setup_object_mock( $method, WP_Webfonts::class ); - - // Set the global. - $GLOBALS['wp_webfonts'] = $mock; - - return $mock; - } -} diff --git a/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php b/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php deleted file mode 100644 index e623ecf19f7d13..00000000000000 --- a/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php +++ /dev/null @@ -1,35 +0,0 @@ -<?php -/** - * Integration tests for Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../fonts-bc-layer-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure - */ -class Tests_Fonts_GutenbergFontsApiBcLayer_IsDeprecatedStructure extends Fonts_BcLayer_TestCase { - - /** - * @dataProvider data_deprecated_structure - * - * @param array $fonts Fonts to test. - */ - public function test_should_detect_deprecated_structure( array $fonts ) { - $this->assertTrue( Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure( $fonts ) ); - } - - /** - * @dataProvider data_not_deprecated_structure - * - * @param array $fonts Fonts to test. - */ - public function test_should_not_detect_deprecated_structure( array $fonts ) { - $this->assertFalse( Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure( $fonts ) ); - } -} diff --git a/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php b/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php deleted file mode 100644 index c0e2c77d9ca1c2..00000000000000 --- a/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php +++ /dev/null @@ -1,38 +0,0 @@ -<?php -/** - * Integration tests for Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../fonts-bc-layer-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure - */ -class Tests_Fonts_GutenbergFontsApiBcLayer_MigrateDeprecatedStructure extends Fonts_BcLayer_TestCase { - - /** - * @dataProvider data_deprecated_structure - * - * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure - * - * @param array $fonts Fonts to test. - * @param array $expected Expected results. - */ - public function test_should_migrate_dprecated_structure_and_throw_deprecation( array $fonts, array $expected ) { - $this->assertSameSets( $expected['migration'], Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $fonts ) ); - } - - /** - * @dataProvider data_not_deprecated_structure - * - * @param array $fonts Fonts to test. - */ - public function test_should_return_fonts_and_not_throw_deprecation( array $fonts ) { - $this->assertSameSets( $fonts, Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $fonts ) ); - } -} diff --git a/phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php b/phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php deleted file mode 100644 index 4a64068fee8c86..00000000000000 --- a/phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php +++ /dev/null @@ -1,62 +0,0 @@ -<?php -/** - * Integration tests for wp_register_webfonts(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/fonts-bc-layer-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers ::wp_register_webfonts - */ -class Tests_Fonts_WpRegisterWebfonts extends Fonts_BcLayer_TestCase { - - /** - * @dataProvider data_deprecated_structure - * - * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure - * @expectedDeprecated wp_register_webfonts - * - * @param array $fonts Fonts to test. - */ - public function test_should_throw_deprecations( array $fonts ) { - wp_register_webfonts( $fonts ); - } - - /** - * @dataProvider data_deprecated_structure - * - * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure - * @expectedDeprecated wp_register_webfonts - * - * @param array $fonts Fonts to test. - * @param array $expected Expected results. - */ - public function test_should_register_with_deprecated_structure( array $fonts, array $expected ) { - $actual = wp_register_webfonts( $fonts ); - $this->assertSame( $expected['wp_register_webfonts'], $actual, 'Font family handle(s) should be returned' ); - $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Fonts should match registered queue' ); - } - - /** - * @dataProvider data_deprecated_structure_with_invalid_font_family - * - * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure - * @expectedDeprecated wp_register_webfonts - * - * @param array $fonts Fonts to test. - * @param string $expected_message Expected notice message. - */ - public function test_should_not_register_with_undefined_font_family( array $fonts, $expected_message ) { - $this->expectNotice(); - $this->expectNoticeMessage( $expected_message ); - - $actual = wp_register_webfonts( $fonts ); - $this->assertSame( array(), $actual, 'Return value should be an empty array' ); - $this->assertEmpty( $this->get_registered_handles(), 'No fonts should have registered' ); - } -} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php deleted file mode 100644 index d6c1e10b691e4a..00000000000000 --- a/phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php -/** - * Integration tests for WP_Webfonts::get_all_webfonts(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers WP_Webfonts::get_all_webfonts - */ -class Tests_Fonts_WpWebfonts_GetAllWebfonts extends Fonts_BcLayer_TestCase { - use BC_Layer_Tests_Datasets; - - /** - * @dataProvider data_should_return_registered_webfonts - * - * @expectedDeprecated wp_webfonts - * @expectedDeprecated WP_Webfonts::get_all_webfonts - * - * @param array $fonts Fonts to register. - * @param array $expected Expected result. - */ - public function test_should_return_registered_webfonts( array $fonts, array $expected ) { - wp_register_fonts( $fonts ); - - $this->assertSame( $expected, wp_webfonts()->get_all_webfonts() ); - } -} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php deleted file mode 100644 index df17cb69b621c4..00000000000000 --- a/phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php +++ /dev/null @@ -1,134 +0,0 @@ -<?php -/** - * Integration tests for WP_Webfonts::get_font_slug(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../fonts-bc-layer-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers WP_Webfonts::get_font_slug - */ -class Tests_Fonts_WpWebfonts_GetFontSlug extends Fonts_BcLayer_TestCase { - - /** - * @dataProvider data_should_get_font_slug - * - * @expectedDeprecated WP_Webfonts::get_font_slug - * - * @param array|string $to_convert Value to test. - * @param string $expected Expected result. - */ - public function test_should_get_font_slug( $to_convert, $expected ) { - $this->assertSame( $expected, WP_Webfonts::get_font_slug( $to_convert ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_get_font_slug() { - return array( - 'font family: single word' => array( - 'to_convert' => 'Merriweather', - 'expected' => 'merriweather', - ), - 'variation: single word font-family' => array( - 'to_convert' => array( - 'font-family' => 'Merriweather', - 'font-style' => 'normal', - 'font-weight' => '400', - ), - 'expected' => 'merriweather', - ), - 'font family: multiword' => array( - 'to_convert' => 'Source Sans Pro', - 'expected' => 'source-sans-pro', - ), - 'variation: multiword font-family' => array( - 'to_convert' => array( - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - ), - 'expected' => 'source-serif-pro', - ), - 'font family: delimited by hyphens' => array( - 'to_convert' => 'source-serif-pro', - 'expected' => 'source-serif-pro', - ), - 'variation: font-family delimited by hyphens' => array( - 'to_convert' => array( - 'font-family' => 'source-serif-pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - ), - 'expected' => 'source-serif-pro', - ), - 'font family: delimited by underscore' => array( - 'to_convert' => 'source_serif_pro', - 'expected' => 'source_serif_pro', - ), - 'variation: font family delimited by underscore' => array( - 'to_convert' => array( - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-family' => 'Source_Serif_Pro', - ), - 'expected' => 'source_serif_pro', - ), - 'font family: delimited by hyphens and underscore' => array( - 'to_convert' => 'my-custom_font_family', - 'expected' => 'my-custom_font_family', - ), - 'variation: font family delimited by hyphens and underscore' => array( - 'to_convert' => array( - 'font-weight' => '700', - 'font-family' => 'my-custom_font_family', - 'font-style' => 'italic', - ), - 'expected' => 'my-custom_font_family', - ), - 'font family: delimited mixture' => array( - 'to_convert' => 'My custom_font-family', - 'expected' => 'my-custom_font-family', - ), - 'variation: font family delimited mixture' => array( - 'to_convert' => array( - 'font-style' => 'italic', - 'font-family' => 'My custom_font-family', - 'font-weight' => '700', - ), - 'expected' => 'my-custom_font-family', - ), - ); - } - - /** - * @dataProvider data_should_not_get_font_slug - * - * @expectedDeprecated WP_Webfonts::get_font_slug - * - * @param array|string $to_convert Value to test. - */ - public function test_should_not_get_font_slug( $to_convert ) { - $this->assertFalse( WP_Webfonts::get_font_slug( $to_convert ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_not_get_font_slug() { - return array( - 'Empty string' => array( '' ), - 'Empty array' => array( array() ), - ); - } -} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php deleted file mode 100644 index 3b41d9d37b4147..00000000000000 --- a/phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php +++ /dev/null @@ -1,32 +0,0 @@ -<?php -/** - * Integration tests for WP_Webfonts::get_registered_webfonts(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../fonts-bc-layer-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers WP_Webfonts::get_registered_webfonts - */ -class Tests_Fonts_WpWebfonts_GetRegisteredWebfonts extends Fonts_BcLayer_TestCase { - - /** - * @dataProvider data_should_return_registered_webfonts - * - * @expectedDeprecated wp_webfonts - * @expectedDeprecated WP_Webfonts::get_registered_webfonts - * - * @param array $fonts Fonts to register. - * @param array $expected Expected result. - */ - public function test_should_return_registered_webfonts( array $fonts, array $expected ) { - wp_register_fonts( $fonts ); - - $this->assertSame( $expected, wp_webfonts()->get_registered_webfonts() ); - } -} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php deleted file mode 100644 index faaaf0e7ef6b79..00000000000000 --- a/phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php +++ /dev/null @@ -1,113 +0,0 @@ -<?php -/** - * Integration tests for WP_Webfonts::register_webfont(). - * - * @package Gutenberg - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../fonts-bc-layer-testcase.php'; - -/** - * @group fontsapi - * @group fontsapi-bclayer - * @covers WP_Webfonts::register_webfont - */ -class Tests_Fonts_WpWebfonts_RegisterWebfont extends Fonts_BcLayer_TestCase { - - /** - * @expectedDeprecated wp_webfonts - * @expectedDeprecated WP_Webfonts::register_webfont - */ - public function test_should_bail_out() { - $webfont = array(); - $this->assertFalse( wp_webfonts()->register_webfont( $webfont ) ); - } - - /** - * @dataProvider data_should_register_webfont - * - * @expectedDeprecated wp_webfonts - * @expectedDeprecated WP_Webfonts::register_webfont - * - * @param array $input Font to register. - * @param string|false $expected Expected result. - */ - public function test_should_register_webfont( array $input, $expected ) { - $this->assertSame( $expected['register_webfont'], wp_webfonts()->register_webfont( ...$input ), 'Font-family handle should be returned' ); - $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Font should be registered' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_register_webfont() { - return array( - 'No font family or variation handles' => array( - 'input' => array( - array( - 'font-family' => 'Merriweather', - 'font-style' => 'italic', - 'font-weight' => '400', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - ), - ), - 'expected' => array( - 'register_webfont' => 'merriweather', - 'get_registered' => array( 'merriweather', 'merriweather-400-italic' ), - ), - ), - 'Has font family handle but no variation handles' => array( - 'input' => array( - array( - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - ), - 'source-serif-pro', - ), - 'expected' => array( - 'register_webfont' => 'source-serif-pro', - 'get_registered' => array( 'source-serif-pro', 'source-serif-pro-200-900-normal' ), - ), - ), - 'No font family handle but has variation handle' => array( - 'input' => array( - array( - 'font-family' => 'Merriweather', - 'font-style' => 'italic', - 'font-weight' => '400', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - ), - '', - 'merriweather-italic-400', - ), - 'expected' => array( - 'register_webfont' => 'merriweather', - 'get_registered' => array( 'merriweather', 'merriweather-italic-400' ), - ), - ), - 'Has font family and variation handles' => array( - 'input' => array( - array( - 'font-family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '200 900', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - ), - 'source-serif-pro', - 'source-serif-pro-variable-italic', - ), - 'expected' => array( - 'register_webfont' => 'source-serif-pro', - 'get_registered' => array( 'source-serif-pro', 'source-serif-pro-variable-italic' ), - ), - ), - ); - } -} diff --git a/phpunit/fonts-api/gutenbergAddRegisteredFontsToThemeJson-test.php b/phpunit/fonts-api/gutenbergAddRegisteredFontsToThemeJson-test.php deleted file mode 100644 index 61ada8a403ea10..00000000000000 --- a/phpunit/fonts-api/gutenbergAddRegisteredFontsToThemeJson-test.php +++ /dev/null @@ -1,252 +0,0 @@ -<?php - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * Test gutenberg_add_registered_fonts_to_theme_json(). - * - * @package WordPress - * @subpackage Fonts API - * - * @since X.X.X - * @group fontsapi - * @covers ::gutenberg_add_registered_fonts_to_theme_json - */ -class Tests_Fonts_GutenbergAddRegisteredFontsToThemeJson extends WP_Fonts_TestCase { - const FONTS_THEME = 'fonts-block-theme'; - - /** - * Cache of test themes' `theme.json` contents. - * - * @var array - */ - private static $theme_json_data = array(); - - public static function set_up_before_class() { - self::$requires_switch_theme_fixtures = true; - - parent::set_up_before_class(); - - $themes = array( - 'block-theme', - 'fonts-block-theme', - ); - foreach ( $themes as $theme ) { - $file = self::$theme_root . "/{$theme}/theme.json"; - self::$theme_json_data[ $theme ] = json_decode( file_get_contents( $file ), true ); - } - } - - /** - * @dataProvider data_themes - * - * @param string $theme Theme to use. - */ - public function test_should_return_instance( $theme ) { - switch_theme( $theme ); - - $data = new WP_Theme_JSON_Gutenberg( self::$theme_json_data[ $theme ] ); - $actual = gutenberg_add_registered_fonts_to_theme_json( $data ); - - $this->assertInstanceOf( WP_Theme_JSON_Gutenberg::class, $actual, 'Instance of WP_Theme_JSON_Gutenberg should be returned' ); - } - - /** - * @dataProvider data_themes - * - * @param string $theme Theme to use. - */ - public function test_should_bail_out_when_no_registered_fonts( $theme ) { - switch_theme( $theme ); - - $data = new WP_Theme_JSON_Gutenberg( self::$theme_json_data[ $theme ] ); - $actual = gutenberg_add_registered_fonts_to_theme_json( $data ); - - $this->assertEmpty( wp_fonts()->get_registered_font_families(), 'No fonts should be registered in Fonts API' ); - $this->assertSame( $data, $actual, 'Same instance of WP_Theme_JSON_Gutenberg should be returned' ); - } - - /** - * Data Provider. - * - * @return array - */ - public function data_themes() { - return array( - 'no fonts defined' => array( 'block-theme' ), - 'no fonts registered' => array( static::FONTS_THEME ), - ); - } - - /** - * @dataProvider data_should_add_non_theme_json_fonts - * - * @param string $theme Theme to use. - * @param array $fonts Fonts to register. - * @param array $expected Expected fonts to be added. - */ - public function test_should_add_non_theme_json_fonts( $theme, $fonts, $expected ) { - switch_theme( static::FONTS_THEME ); - - // Register the fonts. - wp_register_fonts( $fonts ); - - $data = new WP_Theme_JSON_Gutenberg( self::$theme_json_data[ $theme ] ); - $actual = gutenberg_add_registered_fonts_to_theme_json( $data ); - - $this->assertNotSame( $data, $actual, 'New instance of WP_Theme_JSON_Gutenberg should be returned' ); - $actual_raw_data = $actual->get_raw_data(); - - $this->assertArrayHasKey( 'typography', $actual_raw_data['settings'] ); - $this->assertArrayHasKey( 'fontFamilies', $actual_raw_data['settings']['typography'] ); - $this->assertArrayHasKey( 'theme', $actual_raw_data['settings']['typography']['fontFamilies'] ); - - $this->assertContains( - $expected, - $actual_raw_data['settings']['typography']['fontFamilies']['theme'], - 'Fonts should be added after running gutenberg_add_registered_fonts_to_theme_json()' - ); - } - - /** - * Data Provider. - * - * @return array - */ - public function data_should_add_non_theme_json_fonts() { - $lato = array( - 'Lato' => array( - array( - 'font-family' => 'Lato', - 'font-style' => 'normal', - 'font-weight' => '400', - 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular.woff2', - ), - array( - 'font-family' => 'Lato', - 'font-style' => 'italic', - 'font-weight' => '400', - 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular-Italic.woff2', - ), - ), - ); - - $expected_lato = array( - 'fontFamily' => 'Lato', - 'name' => 'Lato', - 'slug' => 'lato', - 'fontFace' => array( - 'lato-400-normal' => array( - 'origin' => 'gutenberg_wp_fonts_api', - 'provider' => 'local', - 'fontFamily' => 'Lato', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'fontDisplay' => 'fallback', - 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular.woff2', - ), - 'lato-400-italic' => array( - 'origin' => 'gutenberg_wp_fonts_api', - 'provider' => 'local', - 'fontFamily' => 'Lato', - 'fontStyle' => 'italic', - 'fontWeight' => '400', - 'fontDisplay' => 'fallback', - 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular-Italic.woff2', - ), - ), - ); - - return array( - 'theme with no fonts defined' => array( - 'theme' => 'block-theme', - 'fonts' => $lato, - 'expected' => $expected_lato, - ), - 'theme with fonts: new fonts not in theme' => array( - 'theme' => static::FONTS_THEME, - 'fonts' => $lato, - 'expected' => $expected_lato, - ), - - /* - * @TODO Add these tests fixing https://github.com/WordPress/gutenberg/issues/50047. - * - 'theme with fonts: new variations registered' => array( - 'theme' => static::FONTS_THEME, - 'fonts' => array( - 'DM Sans' => array( - 'dm-sans-500-normal' => array( - 'font-family' => 'DM Sans', - 'font-style' => 'normal', - 'font-weight' => '500', - 'src' => 'https://example.com/tests/assets/fonts/dm-sans/DMSans-Medium.woff2', - ), - 'dm-sans-500-italic' => array( - 'font-family' => 'DM Sans', - 'font-style' => 'italic', - 'font-weight' => '500', - 'src' => 'https://example.com/tests/assets/fonts/dm-sans/DMSans-Medium.woff2', - ), - ), - ), - 'expected' => array( - 'fontFace' => array( - array( - 'fontFamily' => 'DM Sans', - 'fontStretch' => 'normal', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Regular.woff2' ), - ), - array( - 'fontFamily' => 'DM Sans', - 'fontStretch' => 'normal', - 'fontStyle' => 'italic', - 'fontWeight' => '400', - 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Regular-Italic.woff2' ), - ), - 'dm-sans-500-normal' => array( - 'origin' => 'gutenberg_wp_fonts_api', - 'provider' => 'local', - 'fontFamily' => 'DM Sans', - 'fontStretch' => 'normal', - 'fontStyle' => 'normal', - 'fontWeight' => '500', - 'fontDisplay' => 'fallback', - 'src' => array( get_stylesheet_directory_uri() . 'assets/fonts/dm-sans/DMSans-Medium.woff2' ), - ), - 'dm-sans-500-italic' => array( - 'origin' => 'gutenberg_wp_fonts_api', - 'provider' => 'local', - 'fontFamily' => 'DM Sans', - 'fontStretch' => 'normal', - 'fontStyle' => 'italic', - 'fontWeight' => '500', - 'fontDisplay' => 'fallback', - 'src' => array( get_stylesheet_directory_uri() . 'assets/fonts/dm-sans/DMSans-Medium-Italic.woff2' ), - ), - array( - 'fontFamily' => 'DM Sans', - 'fontStretch' => 'normal', - 'fontStyle' => 'normal', - 'fontWeight' => '700', - 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Bold.woff2' ), - ), - array( - 'fontFamily' => 'DM Sans', - 'fontStretch' => 'normal', - 'fontStyle' => 'italic', - 'fontWeight' => '700', - 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Bold-Italic.woff2' ), - ), - ), - 'fontFamily' => '"DM Sans", sans-serif', - 'name' => 'DM Sans', - 'slug' => 'dm-sans', - ), - ), - */ - ); - } -} diff --git a/phpunit/fonts-api/gutenbergRegisterFontsFromThemeJson-test.php b/phpunit/fonts-api/gutenbergRegisterFontsFromThemeJson-test.php deleted file mode 100644 index d9c7fbaee5fff1..00000000000000 --- a/phpunit/fonts-api/gutenbergRegisterFontsFromThemeJson-test.php +++ /dev/null @@ -1,297 +0,0 @@ -<?php - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * Test gutenberg_register_fonts_from_theme_json(). - * - * @package WordPress - * @subpackage Fonts API - * - * @since X.X.X - * @group fontsapi - * @covers ::gutenberg_register_fonts_from_theme_json - */ -class Tests_Fonts_GutenbergRegisterFontsFromThemeJson extends WP_Fonts_TestCase { - const FONTS_THEME = 'fonts-block-theme'; - const FONT_FAMILIES = array( - 'fonts-block-theme' => array( - // From theme.json. - 'dm-sans', - 'source-serif-pro', - // From style variation. - 'open-sans', - ), - ); - - public static function set_up_before_class() { - self::$requires_switch_theme_fixtures = true; - - parent::set_up_before_class(); - } - - public function test_should_bails_out_when_no_fonts_defined() { - switch_theme( 'block-theme' ); - - gutenberg_register_fonts_from_theme_json(); - $wp_fonts = wp_fonts(); - - $this->assertEmpty( $wp_fonts->get_registered() ); - $this->assertEmpty( $wp_fonts->get_enqueued() ); - } - - public function test_should_register_and_enqueue_style_variation_fonts() { - switch_theme( static::FONTS_THEME ); - - gutenberg_register_fonts_from_theme_json(); - $wp_fonts = wp_fonts(); - - $this->assertContains( 'open-sans', $wp_fonts->get_registered_font_families(), 'Font families should be registered' ); - $this->assertContains( 'open-sans', $wp_fonts->get_enqueued(), 'Font families should be enqueued' ); - } - - /** - * Tests all font families are registered and enqueued. "All" means all font families from - * the theme's theme.json and within the style variations. - */ - public function test_should_register_and_enqueue_all_defined_font_families() { - switch_theme( static::FONTS_THEME ); - - gutenberg_register_fonts_from_theme_json(); - $wp_fonts = wp_fonts(); - - $expected = static::FONT_FAMILIES[ static::FONTS_THEME ]; - $this->assertSameSetsWithIndex( $expected, $wp_fonts->get_registered_font_families(), 'Font families should be registered' ); - $this->assertSameSetsWithIndex( $expected, $wp_fonts->get_enqueued(), 'Font families should be enqueued' ); - } - - /** - * Test ensures duplicate fonts and variations in the style variations - * are not re-registered. - * - * The Dm Sans fonts are duplicated in the theme's /styles/variations-duplicate-fonts.json. - */ - public function test_should_not_reregister_duplicate_fonts_from_style_variations() { - switch_theme( static::FONTS_THEME ); - - gutenberg_register_fonts_from_theme_json(); - $wp_fonts = wp_fonts(); - - // Font families are not duplicated. - $this->assertSameSetsWithIndex( - static::FONT_FAMILIES[ static::FONTS_THEME ], - $wp_fonts->get_registered_font_families(), - 'Font families should not be duplicated' - ); - - // Font variations are not duplicated. - $this->assertSameSets( - array( - // From theme.json. - 'dm-sans', - 'dm-sans-400-normal', - 'dm-sans-400-italic', - 'dm-sans-700-normal', - 'dm-sans-700-italic', - 'source-serif-pro', - 'source-serif-pro-200-900-normal', - 'source-serif-pro-200-900-italic', - // From style variation. - 'open-sans', - 'open-sans-400-normal', - 'open-sans-400-italic', - 'dm-sans-500-normal', - 'dm-sans-500-italic', - ), - $wp_fonts->get_registered(), - 'Font families and their variations should not be duplicated' - ); - } - - /** - * @dataProvider data_should_replace_src_file_placeholder - * - * @param string $handle Variation's handle. - * @param string $expected Expected src. - */ - public function test_should_replace_src_file_placeholder( $handle, $expected ) { - switch_theme( static::FONTS_THEME ); - - gutenberg_register_fonts_from_theme_json(); - - $variation = wp_fonts()->registered[ $handle ]; - $actual = array_pop( $variation->src ); - $expected = get_stylesheet_directory_uri() . $expected; - - $this->assertStringNotContainsString( 'file:./', $actual, 'Font src should not contain the "file:./" placeholder' ); - $this->assertSame( $expected, $actual, 'Font src should be an URL to its file' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_replace_src_file_placeholder() { - return array( - // Theme's theme.json. - 'DM Sans: 400 normal' => array( - 'handle' => 'dm-sans-400-normal', - 'expected' => '/assets/fonts/dm-sans/DMSans-Regular.woff2', - ), - 'DM Sans: 400 italic' => array( - 'handle' => 'dm-sans-400-italic', - 'expected' => '/assets/fonts/dm-sans/DMSans-Regular-Italic.woff2', - ), - 'DM Sans: 700 normal' => array( - 'handle' => 'dm-sans-700-normal', - 'expected' => '/assets/fonts/dm-sans/DMSans-Bold.woff2', - ), - 'DM Sans: 700 italic' => array( - 'handle' => 'dm-sans-700-italic', - 'expected' => '/assets/fonts/dm-sans/DMSans-Bold-Italic.woff2', - ), - 'Source Serif Pro: 200-900 normal' => array( - 'handle' => 'source-serif-pro-200-900-normal', - 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - ), - 'Source Serif Pro: 200-900 italic' => array( - 'handle' => 'source-serif-pro-200-900-italic', - 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - ), - - // Style Variation: variation-with-new-font-family.json. - 'Style Variation: new font-family' => array( - 'handle' => 'open-sans-400-normal', - 'expected' => '/assets/fonts/open-sans/OpenSans-VariableFont_wdth,wght.tff', - ), - 'Style Variation: new font-family italic variation' => array( - 'handle' => 'open-sans-400-italic', - 'expected' => '/assets/fonts/open-sans/OpenSans-Italic-VariableFont_wdth,wght.tff', - ), - - // Style Variation: variation-with-new-variation.json. - 'Style Variation: new medium variation' => array( - 'handle' => 'dm-sans-500-normal', - 'expected' => '/assets/fonts/dm-sans/DMSans-Medium.woff2', - ), - 'Style Variation: new medium italic variation' => array( - 'handle' => 'dm-sans-500-italic', - 'expected' => '/assets/fonts/dm-sans/DMSans-Medium-Italic.woff2', - ), - ); - } - - public function test_should_convert_font_face_properties_into_kebab_case() { - switch_theme( static::FONTS_THEME ); - - gutenberg_register_fonts_from_theme_json(); - - // Testing only one variation since this theme's fonts use the same properties. - $variation = wp_fonts()->registered['dm-sans-400-normal']; - $actual_properties = $variation->extra['font-properties']; - - $this->assertArrayHasKey( 'font-family', $actual_properties, 'fontFamily should have been converted into font-family' ); - $this->assertArrayNotHasKey( 'fontFamily', $actual_properties, 'fontFamily should not exist.' ); - $this->assertArrayHasKey( 'font-stretch', $actual_properties, 'fontStretch should have been converted into font-stretch' ); - $this->assertArrayNotHasKey( 'fontStretch', $actual_properties, 'fontStretch should not exist' ); - $this->assertArrayHasKey( 'font-style', $actual_properties, 'fontStyle should have been converted into font-style' ); - $this->assertArrayNotHasKey( 'fontStyle', $actual_properties, 'fontStyle should not exist.' ); - $this->assertArrayHasKey( 'font-weight', $actual_properties, 'fontWeight should have been converted into font-weight' ); - $this->assertArrayNotHasKey( 'fontWeight', $actual_properties, 'fontWeight should not exist' ); - } - - /** - * Tests that gutenberg_register_fonts_from_theme_json() skips fonts that are already registered - * in the Fonts API. How does it do that? Using the 'origin' property when checking each variation. - * This property is added when WP_Theme_JSON_Resolver_Gutenberg::get_merged_data() runs. - * - * To simulate this scenario, a font is registered first, but not enqueued. Then after running, - * it checks if the gutenberg_register_fonts_from_theme_json() enqueued the font. If no, then - * it was skipped as expected. - */ - public function test_should_skip_registered_fonts() { - switch_theme( static::FONTS_THEME ); - - // Register Lato font. - wp_register_fonts( - array( - 'Lato' => array( - array( - 'font-family' => 'Lato', - 'font-style' => 'normal', - 'font-weight' => '400', - 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular.woff2', - ), - array( - 'font-family' => 'Lato', - 'font-style' => 'italic', - 'font-weight' => '400', - 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular-Italic.woff2', - ), - ), - ) - ); - - // Pre-check to ensure no fonts are enqueued. - $this->assertEmpty( wp_fonts()->get_enqueued(), 'No fonts should be enqueued before running gutenberg_register_fonts_from_theme_json()' ); - - /* - * When this function runs, it invokes WP_Theme_JSON_Resolver_Gutenberg::get_merged_data(), - * which will include the Lato fonts with a 'origin' property set in each variation. - */ - gutenberg_register_fonts_from_theme_json(); - - $actual_enqueued_fonts = wp_fonts()->get_enqueued(); - - $this->assertNotContains( 'lato', $actual_enqueued_fonts, 'Lato font-family should not be enqueued' ); - $this->assertSameSets( static::FONT_FAMILIES[ static::FONTS_THEME ], $actual_enqueued_fonts, 'Only the theme font families should be enqueued' ); - } - - public function test_should_skip_when_font_face_not_defined() { - switch_theme( static::FONTS_THEME ); - $expected_font_family = 'source-serif-pro'; - - /** - * Callback that removes the 'fontFace' of the expected font family from the theme's theme.json data. - * This callback is invoked at the start of gutenberg_register_fonts_from_theme_json() before processing - * within that function. How? It's in the call stack of WP_Theme_JSON_Resolver_Gutenberg::get_merged_data(). - * - * @param WP_Theme_JSON_Data_Gutenberg| WP_Theme_JSON_Data $theme_json_data Instance of the Data object. - * @return WP_Theme_JSON_Data_Gutenberg| WP_Theme_JSON_Data Modified instance. - * @throws ReflectionException - */ - $remove_expected_font_family = static function( $theme_json_data ) use ( $expected_font_family ) { - // Need to get the underlying data array which is in WP_Theme_JSON_Gutenberg | WP_Theme_JSON object. - $property = new ReflectionProperty( $theme_json_data, 'theme_json' ); - $property->setAccessible( true ); - $theme_json_object = $property->getValue( $theme_json_data ); - - $property = new ReflectionProperty( $theme_json_object, 'theme_json' ); - $property->setAccessible( true ); - $data = $property->getValue( $theme_json_object ); - - // Loop through the fonts to find the expected font-family to modify. - foreach ( $data['settings']['typography']['fontFamilies']['theme'] as $index => $definitions ) { - if ( $expected_font_family !== $definitions['slug'] ) { - continue; - } - - // Remove the 'fontFace' element, which removes the font's variations. - unset( $data['settings']['typography']['fontFamilies']['theme'][ $index ]['fontFace'] ); - break; - } - - $theme_json_data->update_with( $data ); - - return $theme_json_data; - }; - add_filter( 'wp_theme_json_data_theme', $remove_expected_font_family ); - - gutenberg_register_fonts_from_theme_json(); - - remove_filter( 'wp_theme_json_data_theme', $remove_expected_font_family ); - - $this->assertNotContains( $expected_font_family, wp_fonts()->get_registered_font_families() ); - } -} diff --git a/phpunit/fonts-api/wp-fonts-testcase.php b/phpunit/fonts-api/wp-fonts-testcase.php deleted file mode 100644 index f7f7fb04b50625..00000000000000 --- a/phpunit/fonts-api/wp-fonts-testcase.php +++ /dev/null @@ -1,391 +0,0 @@ -<?php -/** - * Test case for the Fonts API tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-tests-dataset.php'; - -/** - * Abstracts the common tasks for the API's tests. - */ -abstract class WP_Fonts_TestCase extends WP_UnitTestCase { - use WP_Fonts_Tests_Datasets; - - /** - * Original WP_Fonts instance, before the tests. - * - * @var WP_Fonts - */ - private $old_wp_fonts; - - /** - * Current error reporting level (before a test changes it). - * - * @var null|int - */ - protected $error_reporting_level = null; - - /** - * Reflection data store for non-public property access. - * - * @var ReflectionProperty[] - */ - protected $property = array(); - - /** - * Indicates the test class uses `switch_theme()` and requires - * set_up and tear_down fixtures to set and reset hooks and memory. - * - * If a test class switches themes, set this property to `true`. - * - * @var bool - */ - protected static $requires_switch_theme_fixtures = false; - - /** - * Theme root directory. - * - * @var string - */ - protected static $theme_root; - - /** - * Original theme directory. - * - * @var string - */ - protected $orig_theme_dir; - - /** - * Administrator ID. - * - * @var int - */ - protected static $administrator_id = 0; - - public static function set_up_before_class() { - parent::set_up_before_class(); - - if ( self::$requires_switch_theme_fixtures ) { - self::$theme_root = realpath( __DIR__ . '/../data/themedir1' ); - } - } - - public static function tear_down_after_class() { - // Reset static flags. - self::$requires_switch_theme_fixtures = false; - - parent::tear_down_after_class(); - } - - public function set_up() { - parent::set_up(); - - $this->old_wp_fonts = $GLOBALS['wp_fonts']; - $GLOBALS['wp_fonts'] = null; - - if ( self::$requires_switch_theme_fixtures ) { - $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; - - // /themes is necessary as theme.php functions assume /themes is the root if there is only one root. - $GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes', self::$theme_root ); - - // Set up the new root. - add_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); - add_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); - add_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); - - // Clear caches. - wp_clean_themes_cache(); - unset( $GLOBALS['wp_themes'] ); - } - } - - public function tear_down() { - $this->property = array(); - $GLOBALS['wp_fonts'] = $this->old_wp_fonts; - - // Reset the error reporting when modified within a test. - if ( is_int( $this->error_reporting_level ) ) { - error_reporting( $this->error_reporting_level ); - $this->error_reporting_level = null; - } - - if ( self::$requires_switch_theme_fixtures ) { - // Clean up the filters to modify the theme root. - remove_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); - remove_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); - remove_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); - - WP_Theme_JSON_Resolver::clean_cached_data(); - if ( class_exists( 'WP_Theme_JSON_Resolver_Gutenberg' ) ) { - WP_Theme_JSON_Resolver_Gutenberg::clean_cached_data(); - } - } - - parent::tear_down(); - } - - public function clean_up_global_scope() { - parent::clean_up_global_scope(); - - if ( self::$requires_switch_theme_fixtures ) { - $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; - wp_clean_themes_cache(); - - if ( function_exists( 'wp_clean_theme_json_cache' ) ) { - wp_clean_theme_json_cache(); - } - - if ( function_exists( '_gutenberg_clean_theme_json_caches' ) ) { - _gutenberg_clean_theme_json_caches(); - } - - unset( $GLOBALS['wp_themes'] ); - } - } - - public function filter_set_theme_root() { - return self::$theme_root; - } - - protected function set_up_mock( $method ) { - $mock = $this->setup_object_mock( $method, WP_Fonts::class ); - - // Set the global. - $GLOBALS['wp_fonts'] = $mock; - - return $mock; - } - - protected function setup_object_mock( $method, $class ) { - if ( is_string( $method ) ) { - $method = array( $method ); - } - - return $this->getMockBuilder( $class )->setMethods( $method )->getMock(); - } - - protected function get_registered_handles() { - return array_keys( $this->get_registered() ); - } - - protected function get_registered() { - return wp_fonts()->registered; - } - - protected function get_variations( $font_family, $wp_fonts = null ) { - if ( ! ( $wp_fonts instanceof WP_Fonts ) ) { - $wp_fonts = wp_fonts(); - } - - return $wp_fonts->registered[ $font_family ]->deps; - } - - protected function get_enqueued_handles() { - return wp_fonts()->queue; - } - - protected function get_queued_before_register( $wp_fonts = null ) { - return $this->get_property_value( 'queued_before_register', WP_Dependencies::class, $wp_fonts ); - } - - protected function get_reflection_property( $property_name, $class = 'WP_Fonts' ) { - $property = new ReflectionProperty( $class, $property_name ); - $property->setAccessible( true ); - - return $property; - } - - protected function get_property_value( $property_name, $class, $wp_fonts = null ) { - $property = $this->get_reflection_property( $property_name, $class ); - - if ( ! $wp_fonts ) { - $wp_fonts = wp_fonts(); - } - - return $property->getValue( $wp_fonts ); - } - - protected function setup_property( $class, $property_name ) { - $key = $this->get_property_key( $class, $property_name ); - - if ( ! isset( $this->property[ $key ] ) ) { - $this->property[ $key ] = new ReflectionProperty( $class, 'providers' ); - $this->property[ $key ]->setAccessible( true ); - } - - return $this->property[ $key ]; - } - - protected function get_property_key( $class, $property_name ) { - return $class . '::$' . $property_name; - } - - /** - * Opens the accessibility to access the given private or protected method. - * - * @param string $method_name Name of the method to open. - * @return ReflectionMethod Instance of the method, ie to invoke it in the test. - */ - protected function get_reflection_method( $method_name ) { - $method = new ReflectionMethod( WP_Fonts::class, $method_name ); - $method->setAccessible( true ); - - return $method; - } - - /** - * Sets up multiple font family and variation mocks. - * - * @param array $inputs Array of array( font-family => variations ) to setup. - * @param WP_Fonts $wp_fonts Instance of WP_Fonts. - * @return stdClass[] Array of registered mocks. - */ - protected function setup_registration_mocks( array $inputs, WP_Fonts $wp_fonts ) { - $mocks = array(); - - $build_mock = function ( $font_family, $is_font_family = false ) use ( &$mocks, $wp_fonts ) { - $mock = new stdClass(); - $mock->deps = array(); - $mock->extra = array( 'is_font_family' => $is_font_family ); - if ( $is_font_family ) { - $mock->extra['font-family'] = $font_family; - } - - $handle = $is_font_family ? WP_Fonts_Utils::convert_font_family_into_handle( $font_family ) : $font_family; - - // Add to each queue. - $mocks[ $handle ] = $mock; - $wp_fonts->registered[ $handle ] = $mock; - - return $mock; - }; - - foreach ( $inputs as $font_family => $variations ) { - $font_mock = $build_mock( $font_family, true ); - - foreach ( $variations as $variation_handle => $variation ) { - if ( ! is_string( $variation_handle ) ) { - $variation_handle = $variation; - } - $variation_mock = $build_mock( $variation_handle ); - $variation_mock->extra['font-properties'] = $variation; - $font_mock->deps[] = $variation_handle; - } - } - - return $mocks; - } - - /** - * Register one or more font-family and its variations to set up a test. - * - * @param string $font_family Font family to test. - * @param array $variations Variations. - * @param WP_Fonts|null $wp_fonts Optional. Instance of the WP_Fonts. - */ - protected function setup_register( $font_family, $variations, $wp_fonts = null ) { - if ( ! ( $wp_fonts instanceof WP_Fonts ) ) { - $wp_fonts = wp_fonts(); - } - - $font_family_handle = $wp_fonts->add_font_family( $font_family ); - - foreach ( $variations as $variation_handle => $variation ) { - if ( ! is_string( $variation_handle ) ) { - $variation_handle = ''; - } - $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); - } - } - - /** - * Sets up the WP_Fonts::$provider property. - * - * @param WP_Fonts $wp_fonts Instance of WP_Fonts. - * @param string|array $provider Provider ID when string. Else provider definition with 'id' and 'class' keys. - * @param array $font_handles Optional. Font handles for this provider. - */ - protected function setup_provider_property_mock( WP_Fonts $wp_fonts, $provider, array $font_handles = array() ) { - if ( is_string( $provider ) ) { - $provider = $this->get_provider_definitions( $provider ); - } - - $property = $this->setup_property( WP_Fonts::class, 'providers' ); - $providers = $property->getValue( $wp_fonts ); - - if ( ! isset( $providers[ $provider['id'] ] ) ) { - $providers[ $provider['id'] ] = array( - 'class' => $provider['class'], - 'fonts' => $font_handles, - ); - } else { - $providers[ $provider['id'] ] = array_merge( $font_handles, $providers[ $provider['id'] ]['fonts'] ); - } - - $property->setValue( $wp_fonts, $providers ); - } - - /** - * Gets the variation handles for the provider from the given fonts. - * - * @since X.X.X - * - * @param array $fonts Fonts definitions keyed by font family. - * @param string $provider_id Provider ID. - * @return array|string[] Array of handles on success. Else empty array. - */ - protected function get_handles_for_provider( array $fonts, $provider_id ) { - $handles = array(); - - foreach ( $fonts as $variations ) { - foreach ( $variations as $variation_handle => $variation ) { - if ( $provider_id !== $variation['provider'] ) { - continue; - } - $handles[] = $variation_handle; - } - } - - return $handles; - } - - protected static function set_up_admin_user() { - self::$administrator_id = self::factory()->user->create( - array( - 'role' => 'administrator', - 'user_email' => 'administrator@example.com', - ) - ); - } - - /** - * Sets up the global styles. - * - * @param array $styles User-selected styles structure. - * @param array $theme Optional. Theme to switch to for the test. Default 'fonts-block-theme'. - */ - protected function set_up_global_styles( array $styles, $theme = 'fonts-block-theme' ) { - switch_theme( $theme ); - - if ( empty( $styles ) ) { - return; - } - - // Make sure there is data from the user origin. - wp_set_current_user( self::$administrator_id ); - $user_cpt = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( wp_get_theme(), true ); - $config = json_decode( $user_cpt['post_content'], true ); - - // Add the test styles. - $config['styles'] = $styles; - - // Update the global styles and settings post. - $user_cpt['post_content'] = wp_json_encode( $config ); - wp_update_post( $user_cpt, true, false ); - } -} diff --git a/phpunit/fonts-api/wpDeregisterFontFamily-test.php b/phpunit/fonts-api/wpDeregisterFontFamily-test.php deleted file mode 100644 index 6ef73879f20320..00000000000000 --- a/phpunit/fonts-api/wpDeregisterFontFamily-test.php +++ /dev/null @@ -1,74 +0,0 @@ -<?php -/** - * Unit and integration tests for wp_deregister_font_family(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group remove_fonts - * @covers ::wp_deregister_font_family - * @covers WP_Fonts::remove_font_family - */ -class Tests_Webfonts_WpDeregisterFontFamily extends WP_Fonts_TestCase { - - /** - * Unit test for registering a font-family that mocks WP_Fonts. - * - * @dataProvider data_font_family_handles - * - * @param string $font_family_handle Font family handle to test. - */ - public function test_unit_should_deregister( $font_family_handle ) { - $mock = $this->set_up_mock( 'remove_font_family' ); - $mock->expects( $this->once() ) - ->method( 'remove_font_family' ) - ->with( - $this->identicalTo( $font_family_handle ) - ); - - wp_deregister_font_family( $font_family_handle ); - } - - /** - * Integration test for enqueuing before registering a font family and all of its variations. - * - * @dataProvider data_font_family_handles - * - * @param string $font_family Font family to test. - */ - public function test_should_deregister_before_registration( $font_family ) { - wp_deregister_font_family( $font_family ); - - $this->assertIsArray( $this->get_registered(), 'Registration queue should be an array' ); - $this->assertEmpty( $this->get_registered(), 'Registration queue should be empty after deregistering' ); - } - - /** - * Integration test for deregistering a font family and all of its variations. - * - * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations - * - * @param string $font_family Font family to test. - * @param array $inputs Font family(ies) and variations to pre-register. - * @param array $registered_handles Expected handles after registering. - * @param array $expected Array of expected handles. - */ - public function test_deregister_after_registration( $font_family, array $inputs, array $registered_handles, array $expected ) { - foreach ( $inputs as $handle => $variations ) { - $this->setup_register( $handle, $variations ); - } - // Test the before state, just to make sure. - $this->assertSame( $registered_handles, $this->get_registered_handles(), 'Font family and variations should be registered before deregistering' ); - - wp_deregister_font_family( $font_family ); - - // Test after deregistering. - $this->assertIsArray( $this->get_registered_handles(), 'Registration queue should be an array' ); - $this->assertSame( $expected, $this->get_registered_handles(), 'Registration queue should match after deregistering' ); - } -} diff --git a/phpunit/fonts-api/wpDeregisterFontVariation-test.php b/phpunit/fonts-api/wpDeregisterFontVariation-test.php deleted file mode 100644 index 5cf20301c12702..00000000000000 --- a/phpunit/fonts-api/wpDeregisterFontVariation-test.php +++ /dev/null @@ -1,298 +0,0 @@ -<?php -/** - * Unit and integration tests for wp_deregister_font_variation(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group remove_fonts - * @covers ::wp_deregister_font_variation - * @covers WP_Webfonts::remove_variation - */ -class Tests_Fonts_WpDeregisterFontVariation extends WP_Fonts_TestCase { - private $wp_fonts; - private $fonts_to_register = array(); - - public function set_up() { - parent::set_up(); - $this->wp_fonts = wp_fonts(); - $this->fonts_to_register = $this->get_registered_local_fonts(); - } - - /** - * Sets up the unit test by mocking the WP_Dependencies object using stdClass and - * registering each font family directly to the WP_Webfonts::$registered property - * and its variations to the mocked $deps property. - */ - private function setup_unit_test() { - $this->setup_registration_mocks( $this->fonts_to_register, $this->wp_fonts ); - } - - /** - * Sets up the integration test by properly registering each font family and its variations - * by using the WP_Webfonts::add() and WP_Webfonts::add_variation() methods. - */ - private function setup_integration_test() { - foreach ( $this->fonts_to_register as $font_family_handle => $variations ) { - $this->setup_register( $font_family_handle, $variations, $this->wp_fonts ); - } - } - - /** - * Testing the test setup to ensure it works. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - */ - public function test_mocked_setup( $font_family_handle, $variation_handle ) { - $this->setup_unit_test(); - - $this->assertArrayHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be in the registered queue before removal' ); - $this->assertContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should be in its font family deps before removal' ); - } - - /** - * Unit test for deregistering a font-family's variation using mock of WP_Webfonts. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family to test. - * @param string $variation_handle Variation's handle to test. - */ - public function test_should_deregister_when_mocked( $font_family_handle, $variation_handle ) { - $mock = $this->set_up_mock( 'remove_variation' ); - $mock->expects( $this->once() ) - ->method( 'remove_variation' ) - ->with( - $this->identicalTo( $font_family_handle, $variation_handle ) - ); - - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - } - - /** - * Unit test. - * - * @dataProvider data_should_do_nothing - * - * @param string $font_family Font family name. - * @param string $font_family_handle Font family handle. - * @param string $variation_handle Variation handle to remove. - */ - public function test_unit_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { - // Set up the test. - unset( $this->fonts_to_register[ $font_family ] ); - $this->setup_unit_test(); - $registered_queue = $this->wp_fonts->registered; - - // Run the tests. - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertSame( $registered_queue, $this->wp_fonts->registered, 'Registered queue should not have changed' ); - } - - /** - * Integration test. - * - * @dataProvider data_should_do_nothing - * - * @param string $font_family Font family name. - * @param string $font_family_handle Font family handle. - * @param string $variation_handle Variation handle to remove. - */ - public function test_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { - // Set up the test. - unset( $this->fonts_to_register[ $font_family ] ); - $this->setup_integration_test(); - $registered_queue = $this->wp_fonts->get_registered(); - - // Run the tests. - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertSameSets( $registered_queue, $this->wp_fonts->get_registered(), 'Registered queue should not have changed' ); - } - - /** - * Data provider for testing removal of variations. - * - * @return array - */ - public function data_should_do_nothing() { - return array( - 'Font with 1 variation' => array( - 'font_family' => 'merriweather', - 'font_family_handle' => 'merriweather', - 'variation_handle' => 'merriweather-200-900-normal', - ), - 'Font with multiple variations' => array( - 'font_family' => 'Source Serif Pro', - 'font_family_handle' => 'source-serif-pro', - 'variation_handle' => 'Source Serif Pro-300-normal', - ), - ); - } - - /** - * Unit test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_unit_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_unit_test(); - $this->setup_remove_variation_from_registered( $variation_handle ); - - // Run the tests. - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Integration test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_integration_test(); - $this->setup_remove_variation_from_registered( $variation_handle ); - - // Run the tests. - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Unit test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_unit_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_unit_test(); - $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); - - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Integration test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_integration_test(); - $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); - - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Unit test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_unit_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_unit_test(); - - $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); - - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Integration test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_integration_test(); - - $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); - - wp_deregister_font_variation( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Remove the variation handle from the font family's deps. - * - * @param string $font_family_handle Font family. - * @param string $variation_handle The variation handle to remove. - */ - private function setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ) { - foreach ( $this->wp_fonts->registered[ $font_family_handle ]->deps as $index => $vhandle ) { - if ( $variation_handle !== $vhandle ) { - continue; - } - unset( $this->wp_fonts->registered[ $font_family_handle ]->deps[ $index ] ); - break; - } - } - - /** - * Removes the variation from the WP_Webfonts::$registered queue. - * - * @param string $variation_handle The variation handle to remove. - */ - private function setup_remove_variation_from_registered( $variation_handle ) { - unset( $this->wp_fonts->registered[ $variation_handle ] ); - } -} diff --git a/phpunit/fonts-api/wpEnqueueFontVariations-test.php b/phpunit/fonts-api/wpEnqueueFontVariations-test.php deleted file mode 100644 index 46aa4fd1499d17..00000000000000 --- a/phpunit/fonts-api/wpEnqueueFontVariations-test.php +++ /dev/null @@ -1,82 +0,0 @@ -<?php -/** - * Unit and integration tests for wp_enqueue_font_variations(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers ::wp_enqueue_font_variations - * @covers WP_Webfonts::enqueue - */ -class Tests_Fonts_WpEnqueueFontVariations extends WP_Fonts_TestCase { - - /** - * Unit test for registering one or more specific variations that mocks WP_Webfonts. - * - * @dataProvider data_variation_handles - * - * @param string|string[] $handles Variation handles to test. - */ - public function test_unit_should_enqueue( $handles ) { - $mock = $this->set_up_mock( 'enqueue' ); - $mock->expects( $this->once() ) - ->method( 'enqueue' ) - ->with( - $this->identicalTo( $handles ) - ); - - wp_enqueue_font_variations( $handles ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_variation_handles() { - return array( - '1 variation handle' => array( 'merriweather-200-900-normal' ), - 'multiple same font family handles' => array( array( 'Source Serif Pro-300-normal', 'Source Serif Pro-900-italic' ) ), - 'handles from different font families' => array( array( 'merriweather-200-900-normal', 'Source Serif Pro-900-italic' ) ), - ); - } - - /** - * Integration test for enqueuing one or more specific variations. - * - * @dataProvider data_enqueue_variations - * - * @param string|string[] $handles Variation handles to test. - * @param array $expected Expected queue. - */ - public function test_should_enqueue_after_registration( $handles, array $expected ) { - foreach ( $this->get_data_registry() as $handle => $variations ) { - $this->setup_register( $handle, $variations ); - } - - wp_enqueue_font_variations( $handles ); - $this->assertEmpty( $this->get_queued_before_register(), '"queued_before_register" queue should be empty' ); - $this->assertSame( $expected, $this->get_enqueued_handles(), 'Queue should contain the given handles' ); - } - - /** - * Integration test for enqueuing before registering one or more specific variations. - * - * @dataProvider data_enqueue_variations - * - * @param string|string[] $handles Variation handles to test. - * @param array $not_used Not used. - * @param array $expected Expected "queued_before_register" queue. - */ - public function test_should_enqueue_before_registration( $handles, array $not_used, array $expected ) { - wp_enqueue_font_variations( $handles ); - - $this->assertSame( $expected, $this->get_queued_before_register(), '"queued_before_register" queue should contain the given handles' ); - $this->assertEmpty( $this->get_enqueued_handles(), 'Queue should be empty' ); - } -} diff --git a/phpunit/fonts-api/wpEnqueueFonts-test.php b/phpunit/fonts-api/wpEnqueueFonts-test.php deleted file mode 100644 index 43c1fd63a76104..00000000000000 --- a/phpunit/fonts-api/wpEnqueueFonts-test.php +++ /dev/null @@ -1,122 +0,0 @@ -<?php -/** - * Unit and integration tests for wp_enqueue_fonts(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers ::wp_enqueue_fonts - * @covers WP_Fonts::enqueue - */ -class Tests_Fonts_WpEnqueueFonts extends WP_Fonts_TestCase { - - /** - * Unit test for registering a font-family that mocks WP_Fonts. - * - * @dataProvider data_should_enqueue - * - * @param string[] $font_families Font families to test. - * @param string[] $expected_handles Expected handles passed to WP_Fonts::enqueue(). - */ - public function test_unit_should_enqueue( $font_families, $expected_handles ) { - $mock = $this->set_up_mock( 'enqueue' ); - $mock->expects( $this->once() ) - ->method( 'enqueue' ) - ->with( - $this->identicalTo( $expected_handles ) - ); - - wp_enqueue_fonts( $font_families ); - } - - /** - * Integration test for enqueuing a font family and all of its variations. - * - * @dataProvider data_should_enqueue - * - * @param string[] $font_families Font families to test. - * @param string[] $expected_handles Expected handles passed to WP_Fonts::enqueue(). - */ - public function test_should_enqueue_after_registration( $font_families, $expected_handles ) { - // Register the font-families. - foreach ( $this->get_data_registry() as $handle => $variations ) { - $this->setup_register( $handle, $variations ); - } - - wp_enqueue_fonts( $font_families ); - - $this->assertEmpty( $this->get_queued_before_register(), '"queued_before_register" queue should be empty' ); - $this->assertSame( $expected_handles, $this->get_enqueued_handles(), 'Queue should contain the given font family(ies)' ); - } - - /** - * Integration test for enqueuing before registering a font family and all of its variations. - * - * @dataProvider data_should_enqueue - * - * @param string[] $font_families Font families to test. - * @param string[] $expected_handles Expected handles passed to WP_Fonts::enqueue(). - */ - public function test_should_enqueue_before_registration( $font_families, $expected_handles ) { - wp_enqueue_fonts( $font_families ); - - // Set up what "queued_before_register" queue should be. - $expected = array(); - foreach ( $expected_handles as $handle ) { - $expected[ $handle ] = null; - } - $this->assertSame( $expected, $this->get_queued_before_register(), '"queued_before_register" queue should contain the given font family(ies)' ); - $this->assertEmpty( $this->get_enqueued_handles(), 'Queue should be empty' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_enqueue() { - return array( - '1: single word handle' => array( - 'font_families' => array( 'lato' ), - 'expected_handles' => array( 'lato' ), - ), - '1: multiple word handle' => array( - 'font_families' => array( 'source-serif-pro' ), - 'expected_handles' => array( 'source-serif-pro' ), - ), - '1: single word name' => array( - 'font_families' => array( 'Merriweather' ), - 'expected_handles' => array( 'merriweather' ), - ), - '1: multiple word name' => array( - 'font_families' => array( 'My Font' ), - 'expected_handles' => array( 'my-font' ), - ), - '>1: single word handle' => array( - 'font_families' => array( 'lato', 'merriweather' ), - 'expected_handles' => array( 'lato', 'merriweather' ), - ), - '>1: multiple word handle' => array( - 'font_families' => array( 'source-serif-pro', 'my-font' ), - 'expected_handles' => array( 'source-serif-pro', 'my-font' ), - ), - '>1: single word name' => array( - 'font_families' => array( 'Lato', 'Merriweather' ), - 'expected_handles' => array( 'lato', 'merriweather' ), - ), - '>1: multiple word name' => array( - 'font_families' => array( 'My Font', 'Source Serif Pro' ), - 'expected_handles' => array( 'my-font', 'source-serif-pro' ), - ), - '>1: mixture of word handles and names' => array( - 'font_families' => array( 'Source Serif Pro', 'Merriweather', 'my-font', 'Lato' ), - 'expected_handles' => array( 'source-serif-pro', 'merriweather', 'my-font', 'lato' ), - ), - ); - } -} diff --git a/phpunit/fonts-api/wpFonts-test.php b/phpunit/fonts-api/wpFonts-test.php deleted file mode 100644 index 9a50dcb0fafc30..00000000000000 --- a/phpunit/fonts-api/wpFonts-test.php +++ /dev/null @@ -1,38 +0,0 @@ -<?php -/** - * Unit and integration tests for wp_fonts(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers ::wp_fonts - */ -class Tests_Fonts_WpFonts extends WP_Fonts_TestCase { - - public function test_returns_instance() { - $this->assertInstanceOf( WP_Fonts::class, wp_fonts() ); - } - - public function test_global_set() { - global $wp_fonts; - $this->assertNull( $wp_fonts ); - $instance = wp_fonts(); - $this->assertInstanceOf( WP_Fonts::class, $wp_fonts ); - $this->assertSame( $instance, $wp_fonts ); - } - - public function test_local_provider_is_automatically_registered() { - $expected = array( - 'local' => array( - 'class' => 'WP_Fonts_Provider_Local', - 'fonts' => array(), - ), - ); - $this->assertSame( $expected, wp_fonts()->get_providers() ); - } -} diff --git a/phpunit/fonts-api/wpFonts/add-test.php b/phpunit/fonts-api/wpFonts/add-test.php deleted file mode 100644 index 8d88b6834d0cf6..00000000000000 --- a/phpunit/fonts-api/wpFonts/add-test.php +++ /dev/null @@ -1,45 +0,0 @@ -<?php -/** - * WP_Fonts::add() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::add - */ -class Tests_Fonts_WpFonts_Add extends WP_Fonts_TestCase { - - /** - * @dataProvider data_handles - * - * @param string $handle Handle to register. - */ - public function test_add( $handle ) { - $wp_fonts = new WP_Fonts(); - - $this->assertTrue( $wp_fonts->add( $handle, false ), 'Registering a handle should return true' ); - $this->assertCount( 1, $wp_fonts->registered ); - $this->assertArrayHasKey( $handle, $wp_fonts->registered, 'Font family handle should be in the registry after registration' ); - - } - - /** - * Data provider. - * - * @return array - */ - public function data_handles() { - return array( - 'name: multiple' => array( 'Source Serif Pro' ), - 'handle: multiple' => array( 'source-serif-pro' ), - 'name: single' => array( 'Merriweather' ), - 'handle: single' => array( 'merriweather' ), - 'handle: variation' => array( 'my-custom-font-200-900-normal' ), - ); - } -} diff --git a/phpunit/fonts-api/wpFonts/addFontFamily-test.php b/phpunit/fonts-api/wpFonts/addFontFamily-test.php deleted file mode 100644 index 85676b47c35e5e..00000000000000 --- a/phpunit/fonts-api/wpFonts/addFontFamily-test.php +++ /dev/null @@ -1,66 +0,0 @@ -<?php -/** - * WP_Fonts::add_font_family() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::add_font_family - */ -class Tests_Fonts_WpFonts_AddFontFamily extends WP_Fonts_TestCase { - - /** - * @dataProvider data_handles - * - * @param string $font_family Font family to register. - * @param string $expected Expected handle. - */ - public function test_should_register( $font_family, $expected ) { - $wp_fonts = new WP_Fonts(); - $font_family_handle = $wp_fonts->add_font_family( $font_family ); - - $this->assertSame( $expected, $font_family_handle, 'Registering a font-family should return its handle' ); - $this->assertCount( 1, $wp_fonts->registered ); - $this->assertArrayHasKey( $font_family_handle, $wp_fonts->registered, 'Font family handle should be in the registry after registration' ); - - } - - /** - * Data provider. - * - * @return array - */ - public function data_handles() { - return array( - 'name: multiple' => array( - 'font_family' => 'Source Serif Pro', - 'expected' => 'source-serif-pro', - ), - 'handle: multiple' => array( - 'font_family' => 'source-serif-pro', - 'expected' => 'source-serif-pro', - ), - 'name: single' => array( - 'font_family' => 'Merriweather', - 'expected' => 'merriweather', - ), - 'handle: single' => array( - 'font_family' => 'merriweather', - 'expected' => 'merriweather', - ), - 'handle: variation' => array( - 'font_family' => 'my-custom-font-200-900-normal', - 'expected' => 'my-custom-font-200-900-normal', - ), - 'name: multiple font-families' => array( - 'font_family' => 'Source Serif Pro, Merriweather', - 'expected' => 'source-serif-pro', - ), - ); - } -} diff --git a/phpunit/fonts-api/wpFonts/addVariation-test.php b/phpunit/fonts-api/wpFonts/addVariation-test.php deleted file mode 100644 index 0f86c07ab0eaa8..00000000000000 --- a/phpunit/fonts-api/wpFonts/addVariation-test.php +++ /dev/null @@ -1,149 +0,0 @@ -<?php -/** - * WP_Fonts::add_variation() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::add_variation - */ -class Tests_Fonts_WpFonts_AddVariation extends WP_Fonts_TestCase { - - /** - * @dataProvider data_valid_variation - * - * @param string|bool $expected Expected results. - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - * @param string $variation_handle Optional. The variation's handle. - */ - public function test_should_register_variation_when_font_family_is_registered( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->add( $font_family_handle, false ); - - $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); - $this->assertSame( $expected, $variation_handle, 'Registering a variation should return its handle' ); - $this->assertArrayHasKey( $variation_handle, $wp_fonts->registered, 'Variation handle should be in the registry after registration' ); - $this->assertSame( array( $expected ), $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should be registered to font family' ); - } - - /** - * @dataProvider data_valid_variation - * - * @param string|bool $expected Expected results. - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - * @param string $variation_handle Optional. The variation's handle. - */ - public function test_should_not_reregister_font_family( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->add( $font_family_handle, false ); - - $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); - - // Font family should appear only once in the registered queue. - $expected = array( $font_family_handle, $variation_handle ); - $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Font family should not be re-registered after registering a variation' ); - } - - /** - * @dataProvider data_valid_variation - * - * @param string|bool $expected Expected results. - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - * @param string $variation_handle Optional. The variation's handle. - */ - public function test_should_not_reregister_variation( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->add( $font_family_handle, false ); - - // Set up the test. - $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); - - // Run the test. - $variant_handle_on_reregister = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); - $this->assertSame( $expected, $variant_handle_on_reregister, 'Variation should be registered to font family' ); - $this->assertSame( $variation_handle, $variant_handle_on_reregister, 'Variation should return the previously registered variant handle' ); - $this->assertSame( array( $variation_handle ), $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should only be registered once' ); - - $this->assertCount( 2, $wp_fonts->registered ); - $this->assertArrayHasKey( $variation_handle, $wp_fonts->registered, 'Variation handle should be in the registry after registration' ); - } - - /** - * @dataProvider data_valid_variation - * - * @param string|bool $expected Expected results. - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - * @param string $variation_handle Optional. The variation's handle. - */ - public function test_should_register_font_family_and_variation( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { - $wp_fonts = new WP_Fonts(); - - $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); - $this->assertSame( $expected, $variation_handle, 'Variation should return its registered handle' ); - - // Extra checks to ensure both are registered. - $this->assertCount( 2, $wp_fonts->registered ); - $this->assertArrayHasKey( $font_family_handle, $wp_fonts->registered, 'Font family handle should be in the registry after registration' ); - $this->assertArrayHasKey( $variation_handle, $wp_fonts->registered, 'Variation handle should be in the registry after registration' ); - $this->assertSame( array( $variation_handle ), $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should be registered to the font family' ); - } - - /** - * @dataProvider data_font_family_handle_undefined - * - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - */ - public function test_should_not_register_font_family_or_variant( $font_family_handle, array $variation ) { - $this->expectNotice(); - $this->expectNoticeMessage( 'Font family handle must be a non-empty string.' ); - - $wp_fonts = new WP_Fonts(); - $wp_fonts->add_variation( $font_family_handle, $variation ); - - $this->assertEmpty( $wp_fonts->registered, 'Registered queue should be empty' ); - $this->assertEmpty( $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should not be registered to the font family' ); - } - - /** - * @dataProvider data_font_family_undefined_in_variation - * @dataProviders data_unable_determine_variation_handle - * - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - * @param string $expected_message Expected notice message. - */ - public function test_should_not_register_variation_when_font_family_not_defined( $font_family_handle, array $variation, $expected_message ) { - $this->expectNotice(); - $this->expectNoticeMessage( $expected_message ); - - $wp_fonts = new WP_Fonts(); - $this->assertNull( $wp_fonts->add_variation( $font_family_handle, $variation ) ); - } - - /** - * @dataProvider data_unable_determine_variation_handle - * - * @param string $font_family_handle The font family's handle for this variation. - * @param array $variation An array of variation properties to add. - */ - public function test_should_register_font_family_when_variant_fails_to_register( $font_family_handle, array $variation ) { - $this->expectNotice(); - $this->expectNoticeMessage( 'Variant handle could not be determined as font-weight and/or font-style are require' ); - - $wp_fonts = new WP_Fonts(); - $wp_fonts->add_variation( $font_family_handle, $variation ); - - $this->assertCount( 1, $wp_fonts->registered ); - $this->assertArrayHasKey( $font_family_handle, $wp_fonts->registered ); - } -} diff --git a/phpunit/fonts-api/wpFonts/dequeue-test.php b/phpunit/fonts-api/wpFonts/dequeue-test.php deleted file mode 100644 index e6ea2223657baa..00000000000000 --- a/phpunit/fonts-api/wpFonts/dequeue-test.php +++ /dev/null @@ -1,72 +0,0 @@ -<?php -/** - * WP_Fonts::dequeue() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::dequeue - */ -class Tests_Fonts_WpFonts_Dequeue extends WP_Fonts_TestCase { - - /** - * @dataProvider data_enqueue - * @dataProvider data_enqueue_variations - * - * @param string|string[] $handles Handles to test. - */ - public function test_should_do_nothing_when_handles_not_queued( $handles ) { - $wp_fonts = new WP_Fonts(); - - $wp_fonts->dequeue( $handles ); - $this->assertEmpty( $this->get_queued_before_register( $wp_fonts ), 'Prequeue should be empty' ); - $this->assertEmpty( $wp_fonts->queue, 'Queue should be empty' ); - } - - /** - * Integration test for dequeuing from queue. It first registers and then enqueues before dequeuing. - * - * @dataProvider data_enqueue - * @dataProvider data_enqueue_variations - * - * @param string|string[] $handles Handles to test. - */ - public function test_should_dequeue_from_queue( $handles ) { - $wp_fonts = new WP_Fonts(); - - // Register and enqueue. - foreach ( $this->get_data_registry() as $handle => $variations ) { - $this->setup_register( $handle, $variations, $wp_fonts ); - } - $wp_fonts->enqueue( $handles ); - - // To make sure the handles are in the queue before dequeuing. - $this->assertNotEmpty( $wp_fonts->queue, 'Queue not be empty before dequeueing' ); - - // Run the test. - $wp_fonts->dequeue( $handles ); - $this->assertEmpty( $wp_fonts->queue, 'Queue should be empty after dequeueing' ); - } - - /** - * Integration test for dequeuing from prequeue. It enqueues first. - * - * @dataProvider data_enqueue - * @dataProvider data_enqueue_variations - * - * @param string|string[] $handles Handles to test. - */ - public function test_should_dequeue_from_prequeue( $handles ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->enqueue( $handles ); - $this->assertNotEmpty( $this->get_queued_before_register( $wp_fonts ), 'Prequeue not be empty before dequeueing' ); - - $wp_fonts->dequeue( $handles ); - $this->assertEmpty( $this->get_queued_before_register( $wp_fonts ), 'Prequeue should be empty after dequeueing' ); - } -} diff --git a/phpunit/fonts-api/wpFonts/doItem-test.php b/phpunit/fonts-api/wpFonts/doItem-test.php deleted file mode 100644 index 602c8d74e1bae1..00000000000000 --- a/phpunit/fonts-api/wpFonts/doItem-test.php +++ /dev/null @@ -1,336 +0,0 @@ -<?php -/** - * WP_Fonts::do_item() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group printfonts - * @covers WP_Fonts::do_item - */ -class Tests_Fonts_WpFonts_DoItem extends WP_Fonts_TestCase { - private $wp_fonts; - - public function set_up() { - parent::set_up(); - $this->wp_fonts = new WP_Fonts; - } - - public function test_should_return_false_when_provider_not_registered() { - $this->assertFalse( $this->wp_fonts->do_item( 'provider_not_registered' ) ); - } - - /** - * @dataProvider data_provider_definitions - * - * @param array $provider Provider to mock. - */ - public function test_should_return_false_when_no_fonts_enqueued_for_provider( array $provider ) { - $this->setup_provider_property_mock( $this->wp_fonts, $provider ); - $this->assertFalse( $this->wp_fonts->do_item( $provider['id'] ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_provider_definitions() { - $providers = $this->get_provider_definitions(); - - return array( - 'mock' => array( $providers['mock'] ), - 'local' => array( $providers['local'] ), - ); - } - - /** - * Test the test set up to ensure the `Tests_Fonts_WpFonts_DoItem_::setup_provider_property_mock()` - * method works as expected. - */ - public function test_mocking_providers_property() { - $font_handles = array( 'font1', 'font2', 'font3' ); - $expected = array( - 'mock' => array( - 'class' => Mock_Provider::class, - 'fonts' => $font_handles, - ), - ); - - $this->setup_provider_property_mock( $this->wp_fonts, $this->get_provider_definitions( 'mock' ), $font_handles ); - $actual = $this->property['WP_Fonts::$providers']->getValue( $this->wp_fonts ); - $this->assertSame( $expected, $actual ); - } - - /** - * Test the private method WP_Fonts::get_enqueued_fonts_for_provider(). - * - * Why? This test validates the right fonts are returned for use within - * WP_Fonts::do_item(). - * - * @dataProvider data_get_enqueued_fonts_for_provider - * - * @param array $font_handles Array of handles for the provider. - * @param array $to_do Handles to set for the WP_Fonts::$to_do property. - * @param array $expected Expected result. - */ - public function test_get_enqueued_fonts_for_provider( $font_handles, $to_do, $expected ) { - // Set up the `to_do` property. - $this->wp_fonts->to_do = $to_do; - - // Open the method's visibility for testing. - $get_enqueued_fonts_for_provider = $this->get_reflection_method( 'get_enqueued_fonts_for_provider' ); - - // Mock the WP_Fonts::$property to set up the test. - $this->setup_provider_property_mock( $this->wp_fonts, $this->get_provider_definitions( 'mock' ), $font_handles ); - - $actual = $get_enqueued_fonts_for_provider->invoke( $this->wp_fonts, 'mock' ); - $this->assertSameSets( $expected, $actual ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_get_enqueued_fonts_for_provider() { - return array( - 'to_do queue is empty' => array( - 'font_handles ' => array( 'font1', 'font2', 'font3' ), - 'to_do' => array(), - 'expected' => array(), - ), - 'fonts not in to_do queue' => array( - 'font_handles ' => array( 'font1', 'font2', 'font3' ), - 'to_do' => array( 'font12', 'font13' ), - 'expected' => array(), - ), - '2 of the provider fonts in to_do queue' => array( - 'font_handles ' => array( 'font11', 'font12', 'font13' ), - 'to_do' => array( 'font11', 'font13' ), - 'expected' => array( 'font11', 'font13' ), - ), - 'do all of the provider fonts' => array( - 'font_handles ' => array( 'font21', 'font22', 'font23' ), - 'to_do' => array( 'font21', 'font22', 'font23' ), - 'expected' => array( 'font21', 'font22', 'font23' ), - ), - ); - } - - /** - * Test the private method WP_Fonts::get_font_properties_for_provider(). - * - * Why? This test validates the right font properties are returned for use within - * WP_Fonts::do_item(). - * - * @dataProvider data_get_font_properties_for_provider - * - * @param array $font_handles Web fonts for testing. - * @param array $expected Expected result. - */ - public function test_get_font_properties_for_provider( $font_handles, $expected ) { - // Set up the fonts for WP_Dependencies:get_data(). - $fonts = $this->get_registered_fonts(); - // Set all variations to 'mock' provider. - - // Mock the WP_Fonts::$property to set up the test. - $this->setup_provider_property_mock( $this->wp_fonts, $this->get_provider_definitions( 'mock' ), $font_handles ); - $this->setup_registration_mocks( $fonts, $this->wp_fonts ); - - // Open the method's visibility for testing. - $method = $this->get_reflection_method( 'get_font_properties_for_provider' ); - - $actual = $method->invoke( $this->wp_fonts, $font_handles ); - $this->assertSame( $expected, $actual ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_get_font_properties_for_provider() { - $fonts = $this->get_registered_fonts(); - - return array( - 'handles not registered' => array( - 'font_handles' => array( 'font-not-registered1', 'font-not-registered2', 'font-not-registered3' ), - 'expected' => array(), - ), - 'registered and non-registered handles' => array( - 'font_handles' => array( 'Source Serif Pro-300-normal', 'not-registered-handle', 'Source Serif Pro-900-italic' ), - 'expected' => array( - 'Source Serif Pro-300-normal' => $fonts['Source Serif Pro']['Source Serif Pro-300-normal'], - 'Source Serif Pro-900-italic' => $fonts['Source Serif Pro']['Source Serif Pro-900-italic'], - ), - ), - 'font-family handles, ie no "font-properties" extra data' => array( - 'font_handles' => array( 'font1', 'font2', 'merriweather' ), - 'expected' => array(), - ), - ); - } - - /** - * @dataProvider data_print_enqueued_fonts - * - * @param array $provider Define provider. - * @param array $fonts Fonts to register and enqueue. - * @param array $expected Expected results. - */ - public function test_should_trigger_provider_when_mocked( array $provider, array $fonts, array $expected ) { - $this->setup_print_deps( $provider, $fonts ); - - $provider_mock = $this->setup_object_mock( array( 'set_fonts', 'print_styles' ), $provider['class'] ); - - // Test the provider's methods are invoked. - $provider_mock->expects( $this->once() )->method( 'set_fonts' )->with( $this->identicalTo( $expected['set_fonts'] ) ); - $provider_mock->expects( $this->once() )->method( 'print_styles' ); - - // Set up the WP_Fonts::$provider_instances property. - $provider_instances = $this->get_reflection_property( 'provider_instances' ); - $provider_instances->setValue( $this->wp_fonts, array( $provider['id'] => $provider_mock ) ); - - // Test the method successfully processes the provider. - $this->expectOutputString( '' ); - $this->assertTrue( $this->wp_fonts->do_item( $provider['id'] ), 'WP_Fonts::do_item() should return true' ); - } - - /** - * Integration test. - * - * @dataProvider data_print_enqueued_fonts - * - * @param array $provider Define provider. - * @param array $fonts Fonts to register and enqueue. - * @param array $expected Expected results. - */ - public function test_should_print( array $provider, array $fonts, array $expected ) { - $this->setup_print_deps( $provider, $fonts ); - - // Test the method successfully processes the provider. - $this->expectOutputString( $expected['printed_output'] ); - $this->assertTrue( $this->wp_fonts->do_item( $provider['id'] ), 'WP_Fonts::do_item() should return true' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_print_enqueued_fonts() { - $mock = $this->get_registered_mock_fonts(); - $local = $this->get_registered_local_fonts(); - $font_faces = $this->get_registered_fonts_css(); - - return array( - 'mock' => array( - 'provider' => $this->get_provider_definitions( 'mock' ), - 'fonts' => $mock, - 'expected' => array( - 'set_fonts' => array_merge( $mock['font1'], $mock['font2'], $mock['font3'] ), - 'printed_output' => sprintf( - '<mock id="wp-fonts-mock" attr="some-attr">%s; %s; %s; %s; %s; %s</mock>\n', - $font_faces['font1-300-normal'], - $font_faces['font1-300-italic'], - $font_faces['font1-900-normal'], - $font_faces['font2-200-900-normal'], - $font_faces['font2-200-900-italic'], - $font_faces['font3-bold-normal'] - ), - ), - ), - 'local' => array( - 'provider' => $this->get_provider_definitions( 'local' ), - 'fonts' => $local, - 'expected' => array( - 'set_fonts' => array_merge( $local['merriweather'], $local['Source Serif Pro'] ), - 'printed_output' => sprintf( - "<style id='wp-fonts-local' type='text/css'>\n%s%s%s\n</style>\n", - $font_faces['merriweather-200-900-normal'], - $font_faces['Source Serif Pro-300-normal'], - $font_faces['Source Serif Pro-900-italic'] - ), - ), - ), - ); - } - - /** - * Integration test. - * - * @dataProvider data_not_print_enqueued_fonts - * - * @param array $provider Define provider. - * @param array $fonts Fonts to register and enqueue. - * @param array $expected Not used. - * @param array $to_do_queue Value to set in the WP_Fonts::$to_do queue. - */ - public function test_should_not_print_when_to_do_queue_empty( array $provider, array $fonts, $expected, $to_do_queue ) { - $this->setup_print_deps( $provider, $fonts, $to_do_queue ); - - // Test the method successfully processes the provider. - $this->expectOutputString( '' ); - $this->assertFalse( $this->wp_fonts->do_item( $provider['id'] ), 'WP_Fonts::do_item() should return false' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_not_print_enqueued_fonts() { - $mock = $this->get_registered_mock_fonts(); - $local = $this->get_registered_local_fonts(); - - return array( - 'mock provider when to_do queue is empty' => array( - 'provider' => $this->get_provider_definitions( 'mock' ), - 'fonts' => $mock, - 'expected' => array(), - 'to_do_queue' => array(), - ), - 'local provider when to_do queue is empty' => array( - 'provider' => $this->get_provider_definitions( 'local' ), - 'fonts' => $local, - 'expected' => array(), - 'to_do_queue' => array(), - ), - 'fonts not in to_do queue' => array( - 'provider' => $this->get_provider_definitions( 'mock' ), - 'fonts' => $mock, - 'expected' => array(), - 'to_do_queue' => array(), - ), - ); - } - - /** - * Sets up the print dependencies. - * - * @param array $provider Provider id and class. - * @param array $fonts Fonts to register and enqueue. - * @param array|null $to_do_queue Set the WP_Fonts:$to_do queue. - */ - private function setup_print_deps( $provider, $fonts, $to_do_queue = null ) { - // Set up the fonts for WP_Dependencies:get_data(). - $mocks = $this->setup_registration_mocks( $fonts, $this->wp_fonts ); - $handles = array_keys( $mocks ); - $this->setup_provider_property_mock( $this->wp_fonts, $provider, $handles ); - - // Set up the `WP_Fonts::$to_do` and `WP_Fonts::$to_do_keyed_handles` properties. - if ( null === $to_do_queue ) { - $to_do_queue = $handles; - } - - $this->wp_fonts->to_do = $to_do_queue; - $to_do_keyed_handles = $this->get_reflection_property( 'to_do_keyed_handles' ); - $to_do_keyed_handles->setValue( $this->wp_fonts, array_flip( $to_do_queue ) ); - } -} diff --git a/phpunit/fonts-api/wpFonts/doItems-test.php b/phpunit/fonts-api/wpFonts/doItems-test.php deleted file mode 100644 index 20314f957014d9..00000000000000 --- a/phpunit/fonts-api/wpFonts/doItems-test.php +++ /dev/null @@ -1,196 +0,0 @@ -<?php -/** - * WP_Fonts::do_items() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; -require_once __DIR__ . '/../../fixtures/mock-provider.php'; - -/** - * @group fontsapi - * @group printfonts - * @covers WP_Fonts::do_items - */ -class Tests_Fonts_WpFonts_DoItems extends WP_Fonts_TestCase { - private $wp_fonts; - - public function set_up() { - parent::set_up(); - $this->wp_fonts = new WP_Fonts; - } - - public function test_should_not_process_when_no_providers_registered() { - $this->setup_deps( array( 'enqueued' => 'font1' ) ); - - $done = $this->wp_fonts->do_items(); - - $this->assertSame( array(), $done, 'WP_Fonts::do_items() should return an empty array' ); - $this->assertSame( array(), $this->wp_fonts->to_do, 'WP_Fonts::$to_do should be an empty array' ); - } - - /** - * @dataProvider data_invalid_handles - * - * @param mixed $handles Handles to test. - */ - public function test_should_throw_notice_when_invalid_handles( $handles ) { - $this->expectNotice(); - $this->expectNoticeMessage( 'Handles must be a non-empty string or array of non-empty strings' ); - - $this->wp_fonts->do_items( $handles ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_invalid_handles() { - return array( - 'null' => array( null ), - 'empty array' => array( array() ), - 'empty string' => array( '' ), - 'array of empty strings' => array( array( '', '' ) ), - 'array of mixed falsey values' => array( array( '', false, null, array() ) ), - ); - } - - public function test_should_throw_notice_when_provider_class_not_found() { - $this->expectNotice(); - $this->expectNoticeMessage( 'Class "Provider_Does_Not_Exist" not found for "doesnotexist" font provider' ); - - $setup = array( - 'provider' => array( - 'doesnotexist' => array( - 'id' => 'doesnotexist', - 'class' => 'Provider_Does_Not_Exist', - ), - ), - 'provider_handles' => array( 'doesnotexist' => array( 'font1' ) ), - 'registered' => array( - 'doesnotexist' => array( - 'font1' => array( - 'font1-300-normal' => array( - 'provider' => 'doesnotexist', - 'font-weight' => '300', - 'font-style' => 'normal', - 'font-display' => 'fallback', - ), - ), - ), - ), - 'enqueued' => array( 'font1', 'font1-300-normal' ), - ); - $this->setup_deps( $setup ); - - $this->wp_fonts->do_items(); - } - - /** - * @dataProvider data_print_enqueued - * - * @param array $setup Test set up information for provider, fonts, and enqueued. - * @param array $expected_done Expected array of printed handles. - * @param string $expected_output Expected printed output. - */ - public function test_should_print_mocked_enqueued( $setup, $expected_done, $expected_output ) { - $this->setup_deps( $setup ); - - $this->expectOutputString( $expected_output ); - $actual_done = $this->wp_fonts->do_items(); - $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); - } - - /** - * Integration test that registers providers and fonts and then enqueues before - * testing the printing functionality. - * - * @dataProvider data_print_enqueued - * - * @param array $setup Test set up information for provider, fonts, and enqueued. - * @param array $expected_done Expected array of printed handles. - * @param string $expected_output Expected printed output. - */ - public function test_should_print_enqueued( $setup, $expected_done, $expected_output ) { - $this->setup_integrated_deps( $setup ); - - $this->expectOutputString( $expected_output, 'Printed @font-face styles should match' ); - $actual_done = $this->wp_fonts->do_items(); - $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); - } - - /** - * Integration test to validate printing given handles. Rather than mocking internal functionality, - * it registers providers and fonts but does not enqueue. - * - * @dataProvider data_print_enqueued - * - * @param array $setup Test set up information for provider, fonts, and enqueued. - * @param array $expected_done Expected array of printed handles. - * @param string $expected_output Expected printed output. - */ - public function test_should_print_handles_when_not_enqueued( $setup, $expected_done, $expected_output ) { - $this->setup_integrated_deps( $setup, false ); - // Do not enqueue. Instead, pass the handles to WP_Fonts::do_items(). - $handles = $setup['enqueued']; - $this->assertEmpty( $this->wp_fonts->queue, 'No fonts should be enqueued' ); - - $this->expectOutputString( $expected_output ); - $actual_done = $this->wp_fonts->do_items( $handles ); - $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); - } - - /** - * Sets up the dependencies for the mocked test. - * - * @param array $setup Dependencies to set up. - */ - private function setup_deps( array $setup ) { - $setup = array_merge( - array( - 'provider' => array(), - 'provider_handles' => array(), - 'registered' => array(), - 'enqueued' => array(), - ), - $setup - ); - - if ( ! empty( $setup['provider'] ) ) { - foreach ( $setup['provider'] as $provider_id => $provider ) { - $this->setup_provider_property_mock( $this->wp_fonts, $provider, $setup['provider_handles'][ $provider_id ] ); - } - } - - if ( ! empty( $setup['registered'] ) ) { - $this->setup_registration_mocks( $setup['registered'], $this->wp_fonts ); - } - - if ( ! empty( $setup['enqueued'] ) ) { - $queue = $this->get_reflection_property( 'queue' ); - $queue->setValue( $this->wp_fonts, $setup['enqueued'] ); - } - } - - /** - * Sets up the dependencies for integration test. - * - * @param array $setup Dependencies to set up. - * @param bool $enqueue Whether to enqueue. Default true. - */ - private function setup_integrated_deps( array $setup, $enqueue = true ) { - foreach ( $setup['provider'] as $provider ) { - $this->wp_fonts->register_provider( $provider['id'], $provider['class'] ); - } - foreach ( $setup['registered'] as $handle => $variations ) { - $this->setup_register( $handle, $variations, $this->wp_fonts ); - } - - if ( $enqueue ) { - $this->wp_fonts->enqueue( $setup['enqueued'] ); - } - } -} diff --git a/phpunit/fonts-api/wpFonts/enqueue-test.php b/phpunit/fonts-api/wpFonts/enqueue-test.php deleted file mode 100644 index 34380febfef5df..00000000000000 --- a/phpunit/fonts-api/wpFonts/enqueue-test.php +++ /dev/null @@ -1,53 +0,0 @@ -<?php -/** - * WP_Fonts::enqueue() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::enqueue - */ -class Tests_Fonts_WpFonts_Enqueue extends WP_Fonts_TestCase { - - /** - * @dataProvider data_enqueue - * @dataProvider data_enqueue_variations - * - * @param string|string[] $handles Handles to test. - * @param array $not_used Not used. - * @param array $expected Expected "queued_before_register" queue. - */ - public function test_should_prequeue_when_not_registered( $handles, $not_used, $expected ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->enqueue( $handles ); - - $this->assertSame( $expected, $this->get_queued_before_register( $wp_fonts ), 'Handles should be added to before registered queue' ); - $this->assertEmpty( $wp_fonts->queue, 'Handles should not be added to the enqueue queue when not registered' ); - } - - /** - * Integration test for enqueuing (a) a font family and all of its variations or (b) specific variations. - * - * @dataProvider data_enqueue - * @dataProviders data_enqueue_variations - * - * @param string|string[] $handles Handles to test. - * @param array $expected Expected queue. - */ - public function test_should_enqueue_when_registered( $handles, array $expected ) { - $wp_fonts = new WP_Fonts(); - foreach ( $this->get_data_registry() as $font_family => $variations ) { - $this->setup_register( $font_family, $variations, $wp_fonts ); - } - - $wp_fonts->enqueue( $handles ); - - $this->assertEmpty( $this->get_queued_before_register( $wp_fonts ), '"queued_before_register" queue should be empty' ); - $this->assertSame( $expected, $wp_fonts->queue, 'Queue should contain the given handles' ); - } -} diff --git a/phpunit/fonts-api/wpFonts/getEnqueued-test.php b/phpunit/fonts-api/wpFonts/getEnqueued-test.php deleted file mode 100644 index 68a509f3c35ccb..00000000000000 --- a/phpunit/fonts-api/wpFonts/getEnqueued-test.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php -/** - * WP_Fonts::get_enqueued() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::get_enqueued - */ -class Tests_Fonts_WpFonts_GetEnqueued extends WP_Fonts_TestCase { - - public function test_should_return_empty_when_non_enqueued() { - $wp_fonts = new WP_Fonts(); - $this->assertEmpty( $wp_fonts->get_enqueued() ); - } - - /** - * Unit test for when font families are enqueued. - * - * @dataProvider data_enqueue - * - * @param string|string[] $not_used Not used. - * @param array $expected Expected queue. - */ - public function test_should_return_queue_when_property_has_font_families( $not_used, array $expected ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->queue = $expected; - - $this->assertSame( $expected, $wp_fonts->get_enqueued() ); - } - - /** - * Full integration test that registers and enqueues the queue - * is properly wired for "get_enqueued()". - * - * @dataProvider data_enqueue - * - * @param string|string[] $font_family Font family to test. - * @param array $expected Expected queue. - */ - public function test_should_return_queue_when_font_families_registered_and_enqueued( $font_family, array $expected ) { - $wp_fonts = new WP_Fonts(); - - // Register and enqueue. - foreach ( $this->get_data_registry() as $handle => $variations ) { - $this->setup_register( $handle, $variations, $wp_fonts ); - } - $wp_fonts->enqueue( $font_family ); - - $this->assertSame( $expected, $wp_fonts->get_enqueued() ); - } -} diff --git a/phpunit/fonts-api/wpFonts/getProviders-test.php b/phpunit/fonts-api/wpFonts/getProviders-test.php deleted file mode 100644 index ed73921a31aea4..00000000000000 --- a/phpunit/fonts-api/wpFonts/getProviders-test.php +++ /dev/null @@ -1,62 +0,0 @@ -<?php -/** - * WP_Fonts::get_providers() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; -require_once __DIR__ . '/../../fixtures/mock-provider.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::get_providers - */ -class Tests_Fonts_WpFonts_GetProviders extends WP_Fonts_TestCase { - private $wp_fonts; - private $providers_property; - - public function set_up() { - parent::set_up(); - $this->wp_fonts = new WP_Fonts(); - - $this->providers_property = new ReflectionProperty( WP_Fonts::class, 'providers' ); - $this->providers_property->setAccessible( true ); - } - - public function test_should_be_empty() { - $actual = $this->wp_fonts->get_providers(); - $this->assertIsArray( $actual, 'Should return an empty array' ); - $this->assertEmpty( $actual, 'Should return an empty array when no providers are registered' ); - } - - /** - * @dataProvider data_get_providers - * - * @param array $providers Array of providers to test. - * @param array $expected Expected results. - */ - public function test_get_providers( array $providers, array $expected ) { - $this->setup_providers( $providers ); - $this->assertSame( $expected, $this->wp_fonts->get_providers() ); - } - - /** - * Sets up the given providers and stores them in the `WP_Fonts::providers` property. - * - * @param array $providers Array of providers to set up. - */ - private function setup_providers( array $providers ) { - $data = array(); - - foreach ( $providers as $provider_id => $class ) { - $data[ $provider_id ] = array( - 'class' => $class, - 'fonts' => array(), - ); - } - - $this->providers_property->setValue( $this->wp_fonts, $data ); - } -} diff --git a/phpunit/fonts-api/wpFonts/getRegistered-test.php b/phpunit/fonts-api/wpFonts/getRegistered-test.php deleted file mode 100644 index 365a97733f3cfa..00000000000000 --- a/phpunit/fonts-api/wpFonts/getRegistered-test.php +++ /dev/null @@ -1,90 +0,0 @@ -<?php -/** - * WP_Fonts::get_registered() tests. - * - * @package WordPress - * @subpackage Fonts API - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::get_registered - */ -class Tests_Fonts_WpFonts_GetRegistered extends WP_Fonts_TestCase { - - public function test_should_return_empty_when_none_registered() { - $wp_fonts = new WP_Fonts(); - $this->assertEmpty( $wp_fonts->get_registered() ); - } - - /** - * Unit test for when font families are enqueued. - * - * @dataProvider data_get_registered - * - * @param array $inputs Font family(ies) and variations to register. - */ - public function test_should_return_queue_when_mocking_registered_property( array $inputs ) { - $wp_fonts = new WP_Fonts(); - $mocks = $this->setup_registration_mocks( $inputs, $wp_fonts ); - $expected = array_keys( $mocks ); - - $this->assertSame( $expected, $wp_fonts->get_registered() ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_get_registered() { - return array( - 'no variations' => array( - 'inputs' => array( - 'lato' => array(), - ), - ), - 'with 1 variation' => array( - 'inputs' => array( - 'Source Serif Pro' => array( 'variation-1' ), - ), - ), - 'with 2 variations' => array( - 'inputs' => array( - 'my-cool-font' => array( 'cool-1', 'cool-2' ), - ), - ), - 'when multiple font families registered' => array( - 'inputs' => array( - 'font-family-1' => array( 'variation-11', 'variation-12' ), - 'font-family-2' => array( 'variation-21', 'variation-22' ), - 'font-family-3' => array( 'variation-31', 'variation-32' ), - ), - ), - ); - } - - /** - * Full integration test that registers varying number of font families and variations - * to validate if "get_registered()" internals is property wired to the registered queue. - * - * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations - * - * @param string $font_family Not used. - * @param array $inputs Font family(ies) and variations to register. - * @param array $expected Expected results. - */ - public function test_should_return_queue_when_items_are_registered( $font_family, array $inputs, array $expected ) { - $wp_fonts = new WP_Fonts(); - - // Register before testing. - foreach ( $inputs as $handle => $variations ) { - $this->setup_register( $handle, $variations, $wp_fonts ); - } - - $this->assertSame( $expected, $wp_fonts->get_registered() ); - } -} diff --git a/phpunit/fonts-api/wpFonts/query-test.php b/phpunit/fonts-api/wpFonts/query-test.php deleted file mode 100644 index c64d88cf9fa867..00000000000000 --- a/phpunit/fonts-api/wpFonts/query-test.php +++ /dev/null @@ -1,151 +0,0 @@ -<?php -/** - * WP_Fonts::query() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::query - */ -class Tests_Fonts_WpFonts_Query extends WP_Fonts_TestCase { - private $wp_fonts; - - public function set_up() { - parent::set_up(); - - $this->wp_fonts = new WP_Fonts(); - } - - /** - * @dataProvider data_invalid_query - * @dataProvider data_valid_query - * - * @param string $query_handle Handle to test. - */ - public function test_should_fail_when_handles_not_registered( $query_handle ) { - $this->assertFalse( $this->wp_fonts->query( $query_handle, 'registered' ) ); - } - - /** - * @dataProvider data_invalid_query - * @dataProvider data_valid_query - * - * @param string $query_handle Handle to test. - */ - public function test_should_fail_when_handles_not_registered_or_enqueued( $query_handle ) { - $this->assertFalse( $this->wp_fonts->query( $query_handle, 'queue' ) ); - } - - /** - * @dataProvider data_valid_query - * - * @param string $query_handle Handle to test. - */ - public function test_registered_query_should_succeed_when_registered( $query_handle ) { - $this->setup_registry(); - - $actual = $this->wp_fonts->query( $query_handle, 'registered' ); - $this->assertInstanceOf( '_WP_Dependency', $actual, 'Query should return an instance of _WP_Dependency' ); - $this->assertSame( $query_handle, $actual->handle, 'Query object handle should match the given handle to query' ); - } - - /** - * @dataProvider data_valid_query - * - * @param string $query_handle Handle to test. - */ - public function test_enqueued_query_should_succeed_when_registered_and_enqueued( $query_handle ) { - $this->setup_registry(); - $this->wp_fonts->enqueue( $query_handle ); - - $this->assertTrue( $this->wp_fonts->query( $query_handle, 'enqueued' ) ); - } - - /** - * @dataProvider data_valid_query - * - * @param string $query_handle Handle to test. - */ - public function test_enqueued_query_should_fail_when_not_registered_but_enqueued( $query_handle ) { - $this->wp_fonts->enqueue( $query_handle ); - - $this->assertFalse( $this->wp_fonts->query( $query_handle, 'enqueued' ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_invalid_query() { - return array( - 'DM Sans' => array( 'DM Sans' ), - 'roboto' => array( 'roboto' ), - 'my-font' => array( 'my-font' ), - ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_valid_query() { - return array( - 'lato' => array( 'lato' ), - 'merriweather' => array( 'merriweather' ), - 'Source Serif Pro' => array( 'source-serif-pro' ), - ); - } - - public function test_done_query_should_fail_when_no_variations() { - $this->wp_fonts->register_provider( 'local', WP_Fonts_Provider_Local::class ); - $this->setup_registry(); - $this->wp_fonts->enqueue( 'lato' ); - - $this->wp_fonts->do_items( 'lato' ); - - $this->assertFalse( $this->wp_fonts->query( 'lato', 'done' ) ); - } - - /** - * @dataProvider data_done_query - * - * @param string $query_handle Handle to test. - */ - public function test_done_query_should_succeed_when_registered_and_enqueued( $query_handle ) { - $this->wp_fonts->register_provider( 'local', WP_Fonts_Provider_Local::class ); - $this->setup_registry(); - $this->wp_fonts->enqueue( $query_handle ); - - // Process the fonts while ignoring all the printed output. - $this->expectOutputRegex( '`.`' ); - $this->wp_fonts->do_items( $query_handle ); - $this->getActualOutput(); - - $this->assertTrue( $this->wp_fonts->query( $query_handle, 'done' ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_done_query() { - return array( - 'merriweather' => array( 'merriweather' ), - 'Source Serif Pro' => array( 'source-serif-pro' ), - ); - } - - private function setup_registry() { - foreach ( $this->get_registered_local_fonts() as $handle => $variations ) { - $this->setup_register( $handle, $variations, $this->wp_fonts ); - } - } -} diff --git a/phpunit/fonts-api/wpFonts/registerProvider-test.php b/phpunit/fonts-api/wpFonts/registerProvider-test.php deleted file mode 100644 index 03dc6fd6049b1a..00000000000000 --- a/phpunit/fonts-api/wpFonts/registerProvider-test.php +++ /dev/null @@ -1,116 +0,0 @@ -<?php -/** - * WP_Fonts::register_provider() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; -require_once __DIR__ . '/../../fixtures/mock-provider.php'; - -/** - * @group fontsapi - * @covers WP_Fonts::register_provider - */ -class Tests_Fonts_WpFonts_RegisterProvider extends WP_Fonts_TestCase { - - /** - * @dataProvider data_register_providers - * - * @param string $provider_id Provider ID. - * @param string $class Provider class name. - * @param array $expected Expected providers queue. - */ - public function test_should_register_provider( $provider_id, $class, $expected ) { - $wp_fonts = new WP_Fonts(); - $this->assertTrue( $wp_fonts->register_provider( $provider_id, $class ), 'WP_Fonts::register_provider() should return true' ); - $this->assertSame( $expected, $wp_fonts->get_providers(), 'Provider "' . $provider_id . '" should be registered in providers queue' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_register_providers() { - return array( - 'mock' => array( - 'provider_id' => 'mock', - 'class' => Mock_Provider::class, - 'expected' => array( - 'mock' => array( - 'class' => Mock_Provider::class, - 'fonts' => array(), - ), - ), - ), - 'local' => array( - 'provider_id' => 'local', - 'class' => WP_Fonts_Provider_Local::class, - 'expected' => array( - 'local' => array( - 'class' => WP_Fonts_Provider_Local::class, - 'fonts' => array(), - ), - ), - ), - ); - } - - public function test_should_register_multiple_providers() { - $wp_fonts = new WP_Fonts(); - $providers = $this->get_provider_definitions(); - foreach ( $providers as $provider ) { - $this->assertTrue( $wp_fonts->register_provider( $provider['id'], $provider['class'] ), 'WP_Fonts::register_provider() should return true for provider ' . $provider['id'] ); - } - - $expected = array( - 'mock' => array( - 'class' => $providers['mock']['class'], - 'fonts' => array(), - ), - 'local' => array( - 'class' => $providers['local']['class'], - 'fonts' => array(), - ), - ); - - $this->assertSame( $expected, $wp_fonts->get_providers(), 'Both local and mock providers should be registered' ); - } - - /** - * @dataProvider data_invalid_providers - * - * @param string $provider_id Provider ID. - * @param string $class Provider class name. - */ - public function test_should_not_register( $provider_id, $class ) { - $wp_fonts = new WP_Fonts(); - - $this->assertFalse( $wp_fonts->register_provider( $provider_id, $class ), 'WP_Fonts::register_provider() should return false' ); - $this->assertArrayNotHasKey( $provider_id, $wp_fonts->get_providers(), 'Both local and mock providers should be registered' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_invalid_providers() { - return array( - 'provider_id is empty' => array( - 'provider_id' => '', - 'class' => Mock_Provider::class, - ), - 'class is empty' => array( - 'provider_id' => 'local', - 'class' => '', - ), - 'class does not exist' => array( - 'provider_id' => 'doesnotexist', - 'class' => 'Provider_Does_Not_Exist', - ), - ); - } -} diff --git a/phpunit/fonts-api/wpFonts/remove-test.php b/phpunit/fonts-api/wpFonts/remove-test.php deleted file mode 100644 index 6da419a988e853..00000000000000 --- a/phpunit/fonts-api/wpFonts/remove-test.php +++ /dev/null @@ -1,118 +0,0 @@ -<?php -/** - * WP_Fonts::remove() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group remove_fonts - * @covers WP_Fonts::remove - */ -class Tests_Fonts_WpFonts_Remove extends WP_Fonts_TestCase { - - public function test_should_not_remove_when_none_registered() { - $wp_fonts = new WP_Fonts(); - - $wp_fonts->remove( array( 'handle-1', 'handle2' ) ); - - $this->assertEmpty( $wp_fonts->registered ); - } - - /** - * @dataProvider data_remove_when_registered - * - * @param array $handles Handles to remove. - * @param array $expected Expected handles are running test. - */ - public function test_should_remove_when_registered( array $handles, array $expected ) { - $wp_fonts = new WP_Fonts(); - $wp_fonts->registered = $this->generate_registered_queue(); - - $wp_fonts->remove( $handles ); - - $this->assertSameSets( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing handles' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_remove_when_registered() { - $all = array( - 'handle-1', - 'handle-2', - 'handle-3', - 'handle-4', - 'handle-5', - 'handle-6', - 'handle-7', - 'handle-8', - 'handle-9', - 'handle-10', - ); - - return array( - 'remove none' => array( - 'handles' => array(), - 'expected' => $all, - ), - 'remove handle-5' => array( - 'handles' => array( 'handle-5' ), - 'expected' => array( - 'handle-1', - 'handle-2', - 'handle-3', - 'handle-4', - 'handle-6', - 'handle-7', - 'handle-8', - 'handle-9', - 'handle-10', - ), - ), - 'remove 2 from start and end' => array( - 'handles' => array( 'handle-1', 'handle-2', 'handle-9', 'handle-10' ), - 'expected' => array( - 'handle-3', - 'handle-4', - 'handle-5', - 'handle-6', - 'handle-7', - 'handle-8', - ), - ), - 'remove all' => array( - 'handles' => $all, - 'expected' => array(), - ), - 'remove only registered' => array( - 'handles' => array( 'handle-1', 'handle-10', 'handle-abc', 'handle-5' ), - 'expected' => array( - 'handle-2', - 'handle-3', - 'handle-4', - 'handle-6', - 'handle-7', - 'handle-8', - 'handle-9', - ), - ), - ); - } - - private function generate_registered_queue() { - $queue = array(); - for ( $num = 1; $num <= 10; $num++ ) { - $handle = "handle-{$num}"; - $queue[ $handle ] = $num; - } - - return $queue; - } -} diff --git a/phpunit/fonts-api/wpFonts/removeFontFamily-test.php b/phpunit/fonts-api/wpFonts/removeFontFamily-test.php deleted file mode 100644 index b04882e04acdde..00000000000000 --- a/phpunit/fonts-api/wpFonts/removeFontFamily-test.php +++ /dev/null @@ -1,89 +0,0 @@ -<?php -/** - * WP_Fonts::remove_font_family() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group remove_fonts - * @covers WP_Fonts::remove_font_family - */ -class Tests_Fonts_WpFonts_RemoveFontFamily extends WP_Fonts_TestCase { - - /** - * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations - * - * @param string $font_family Font family to test. - * @param array $inputs Font family(ies) and variations to pre-register. - * @param array $registered_handles Expected handles after registering. - * @param array $expected Array of expected handles. - */ - public function test_should_dequeue_when_mocks_registered( $font_family, array $inputs, array $registered_handles, array $expected ) { - $wp_fonts = new WP_Fonts(); - $this->setup_registration_mocks( $inputs, $wp_fonts ); - // Test the before state, just to make sure. - $this->assertArrayHasKey( $font_family, $wp_fonts->registered, 'Registered queue should contain the font family before remove' ); - $this->assertSame( $registered_handles, array_keys( $wp_fonts->registered ), 'Font family and variations should be registered before remove' ); - - $wp_fonts->remove_font_family( $font_family ); - - $this->assertArrayNotHasKey( $font_family, $wp_fonts->registered, 'Registered queue should not contain the font family' ); - $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing font family' ); - } - - /** - * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations - * - * @param string $font_family Font family to test. - * @param array $inputs Font family(ies) and variations to pre-register. - * @param array $registered_handles Not used. - * @param array $expected Array of expected handles. - */ - public function test_should_bail_out_when_not_registered( $font_family, array $inputs, array $registered_handles, array $expected ) { - $wp_fonts = new WP_Fonts(); - unset( $inputs[ $font_family ] ); - $this->setup_registration_mocks( $inputs, $wp_fonts ); - - $wp_fonts->remove_font_family( $font_family ); - - $this->assertArrayNotHasKey( $font_family, $wp_fonts->registered, 'Registered queue should not contain the font family' ); - $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing font family' ); - } - - /** - * Integration test for removing a font family and all of its variation when font family is registered. - * - * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations - * - * @param string $font_family Font family to test. - * @param array $inputs Font family(ies) and variations to pre-register. - * @param array $registered_handles Expected handles after registering. - * @param array $expected Array of expected handles. - */ - public function test_should_deregister_when_registered( $font_family, array $inputs, array $registered_handles, array $expected ) { - $wp_fonts = new WP_Fonts(); - // Register all font families and their variations. - foreach ( $inputs as $input_font_family => $variations ) { - $handle = $wp_fonts->add_font_family( $input_font_family ); - foreach ( $variations as $variation_handle => $variation ) { - if ( ! is_string( $variation_handle ) ) { - $variation_handle = ''; - } - $wp_fonts->add_variation( $handle, $variation, $variation_handle ); - } - } - // Test the before state, just to make sure. - $this->assertArrayHasKey( $font_family, $wp_fonts->registered, 'Registered queue should contain the font family before remove' ); - $this->assertSame( $registered_handles, array_keys( $wp_fonts->registered ), 'Font family and variations should be registered before remove' ); - - $wp_fonts->remove_font_family( $font_family ); - - $this->assertArrayNotHasKey( $font_family, $wp_fonts->registered, 'Registered queue should not contain the font family' ); - $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing font family' ); - } -} diff --git a/phpunit/fonts-api/wpFonts/removeVariation-test.php b/phpunit/fonts-api/wpFonts/removeVariation-test.php deleted file mode 100644 index fb8f78e4122fb2..00000000000000 --- a/phpunit/fonts-api/wpFonts/removeVariation-test.php +++ /dev/null @@ -1,278 +0,0 @@ -<?php -/** - * WP_Fonts::remove_variation() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @group remove_fonts - * @covers WP_Fonts::remove_variation - */ -class Tests_Fonts_WpFonts_RemoveVariation extends WP_Fonts_TestCase { - private $wp_fonts; - private $fonts_to_register = array(); - - public function set_up() { - parent::set_up(); - $this->wp_fonts = new WP_Fonts(); - $this->fonts_to_register = $this->get_registered_local_fonts(); - } - - /** - * Sets up the unit test by mocking the WP_Dependencies object using stdClass and - * registering each font family directly to the WP_Fonts::$registered property - * and its variations to the mocked $deps property. - */ - private function setup_unit_test() { - $this->setup_registration_mocks( $this->fonts_to_register, $this->wp_fonts ); - } - - /** - * Sets up the integration test by properly registering each font family and its variations - * by using the WP_Fonts::add() and WP_Fonts::add_variation() methods. - */ - private function setup_integration_test() { - foreach ( $this->fonts_to_register as $font_family_handle => $variations ) { - $this->setup_register( $font_family_handle, $variations, $this->wp_fonts ); - } - } - - /** - * Testing the test setup to ensure it works. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - */ - public function test_mocked_setup( $font_family_handle, $variation_handle ) { - $this->setup_unit_test(); - - $this->assertArrayHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be in the registered queue before removal' ); - $this->assertContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should be in its font family deps before removal' ); - } - - /** - * Unit test. - * - * @dataProvider data_should_do_nothing_when_variation_and_font_family_not_registered - * - * @param string $font_family Font family name. - * @param string $font_family_handle Font family handle. - * @param string $variation_handle Variation handle to remove. - */ - public function test_unit_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { - // Set up the test. - unset( $this->fonts_to_register[ $font_family ] ); - $this->setup_unit_test(); - $registered_queue = $this->wp_fonts->registered; - - // Run the tests. - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertSame( $registered_queue, $this->wp_fonts->registered, 'Registered queue should not have changed' ); - } - - /** - * Integration test. - * - * @dataProvider data_should_do_nothing_when_variation_and_font_family_not_registered - * - * @param string $font_family Font family name. - * @param string $font_family_handle Font family handle. - * @param string $variation_handle Variation handle to remove. - */ - public function test_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { - // Set up the test. - unset( $this->fonts_to_register[ $font_family ] ); - $this->setup_integration_test(); - $registered_queue = $this->wp_fonts->get_registered(); - - // Run the tests. - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertSameSets( $registered_queue, $this->wp_fonts->get_registered(), 'Registered queue should not have changed' ); - } - - /** - * Data provider for testing removal of variations. - * - * @return array - */ - public function data_should_do_nothing_when_variation_and_font_family_not_registered() { - return array( - 'Font with 1 variation' => array( - 'font_family' => 'merriweather', - 'font_family_handle' => 'merriweather', - 'variation_handle' => 'merriweather-200-900-normal', - ), - 'Font with multiple variations' => array( - 'font_family' => 'Source Serif Pro', - 'font_family_handle' => 'source-serif-pro', - 'variation_handle' => 'Source Serif Pro-300-normal', - ), - ); - } - - /** - * Unit test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_unit_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_unit_test(); - $this->setup_remove_variation_from_registered( $variation_handle ); - - // Run the tests. - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Integration test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_integration_test(); - $this->setup_remove_variation_from_registered( $variation_handle ); - - // Run the tests. - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Unit test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_unit_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_unit_test(); - $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); - - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Integration test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_integration_test(); - $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); - - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Unit test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_unit_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_unit_test(); - - $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); - - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Integration test. - * - * @dataProvider data_remove_variations - * - * @param string $font_family_handle Font family for the variation. - * @param string $variation_handle Variation handle to remove. - * @param array $expected Expected results. - */ - public function test_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { - // Set up the test. - $this->setup_integration_test(); - - $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); - - $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); - - $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); - $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); - $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); - } - - /** - * Remove the variation handle from the font family's deps. - * - * @param string $font_family_handle Font family. - * @param string $variation_handle The variation handle to remove. - */ - private function setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ) { - foreach ( $this->wp_fonts->registered[ $font_family_handle ]->deps as $index => $vhandle ) { - if ( $variation_handle !== $vhandle ) { - continue; - } - unset( $this->wp_fonts->registered[ $font_family_handle ]->deps[ $index ] ); - break; - } - } - - /** - * Removes the variation from the WP_Fonts::$registered queue. - * - * @param string $variation_handle The variation handle to remove. - */ - private function setup_remove_variation_from_registered( $variation_handle ) { - unset( $this->wp_fonts->registered[ $variation_handle ] ); - } -} diff --git a/phpunit/fonts-api/wpFontsProviderLocal-test.php b/phpunit/fonts-api/wpFontsProviderLocal-test.php deleted file mode 100644 index 1bd28e4a6aba7b..00000000000000 --- a/phpunit/fonts-api/wpFontsProviderLocal-test.php +++ /dev/null @@ -1,180 +0,0 @@ -<?php -/** - * WP_Fonts_Local_Provider tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - */ -class Tests_Fonts_WpFontsProviderLocal extends WP_UnitTestCase { - private $provider; - private $theme_root; - private $orig_theme_dir; - - public function set_up() { - parent::set_up(); - - $this->provider = new WP_Fonts_Provider_Local(); - - $this->set_up_theme(); - } - - public function tear_down() { - // Restore the original theme directory setup. - $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; - wp_clean_themes_cache(); - unset( $GLOBALS['wp_themes'] ); - - parent::tear_down(); - } - - /** - * @covers WP_Fonts_Provider_Local::set_fonts - */ - public function test_set_fonts() { - $fonts = array( - 'source-serif-pro-200-900-normal-local' => array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - ), - 'source-serif-pro-200-900-italic-local' => array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - ), - ); - - $this->provider->set_fonts( $fonts ); - - $property = $this->get_fonts_property(); - $this->assertSame( $fonts, $property->getValue( $this->provider ) ); - } - - /** - * @covers WP_Fonts_Provider_Local::get_css - * - * @dataProvider data_get_css_print_styles - * - * @param array $fonts Prepared fonts (to store in WP_Fonts_Provider_Local::$fonts property). - * @param string $expected Expected CSS. - */ - public function test_get_css( array $fonts, $expected ) { - $property = $this->get_fonts_property(); - $property->setValue( $this->provider, $fonts ); - - $this->assertSame( $expected['font-face-css'], $this->provider->get_css() ); - } - - /** - * @covers WP_Fonts_Provider_Local::print_styles - * - * @dataProvider data_get_css_print_styles - * - * @param array $fonts Prepared fonts (to store in WP_Fonts_Provider_Local::$fonts property). - * @param string $expected Expected CSS. - */ - public function test_print_styles( array $fonts, $expected ) { - $property = $this->get_fonts_property(); - $property->setValue( $this->provider, $fonts ); - - $expected_output = sprintf( $expected['style-element'], $expected['font-face-css'] ); - $this->expectOutputString( $expected_output ); - $this->provider->print_styles(); - } - - /** - * Data provider. - * - * @return array - */ - public function data_get_css_print_styles() { - return array( - 'truetype format' => array( - 'fonts' => array( - 'open-sans-bold-italic-local' => array( - 'provider' => 'local', - 'font-family' => 'Open Sans', - 'font-style' => 'italic', - 'font-weight' => 'bold', - 'src' => 'http://example.org/assets/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf', - ), - ), - 'expected' => array( - 'style-element' => "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n", - 'font-face-css' => <<<CSS -@font-face{font-family:"Open Sans";font-style:italic;font-weight:bold;src:url('http://example.org/assets/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf') format('truetype');} -CSS - , - ), - ), - 'woff2 format' => array( - 'fonts' => array( - 'source-serif-pro-200-900-normal-local' => array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - ), - 'source-serif-pro-400-900-italic-local' => array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - ), - ), - 'expected' => array( - 'style-element' => "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n", - 'font-face-css' => <<<CSS -@font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-stretch:normal;src:url('http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');}@font-face{font-family:"Source Serif Pro";font-style:italic;font-weight:200 900;font-stretch:normal;src:url('http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2') format('woff2');} -CSS - - , - ), - ), - ); - } - - /** - * Local `src` paths to need to be relative to the theme. This method sets up the - * `wp-content/themes/` directory to ensure consistency when running tests. - */ - private function set_up_theme() { - $this->theme_root = realpath( DIR_TESTDATA . '/themedir1' ); - $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; - $GLOBALS['wp_theme_directories'] = array( $this->theme_root ); - - $theme_root_callback = function () { - return $this->theme_root; - }; - add_filter( 'theme_root', $theme_root_callback ); - add_filter( 'stylesheet_root', $theme_root_callback ); - add_filter( 'template_root', $theme_root_callback ); - - // Clear caches. - wp_clean_themes_cache(); - unset( $GLOBALS['wp_themes'] ); - } - - private function get_fonts_property() { - $property = new ReflectionProperty( $this->provider, 'fonts' ); - $property->setAccessible( true ); - - return $property; - } -} diff --git a/phpunit/fonts-api/wpFontsResolver/enqueueUserSelectedFonts-test.php b/phpunit/fonts-api/wpFontsResolver/enqueueUserSelectedFonts-test.php deleted file mode 100644 index c3ba0fd1f72ac6..00000000000000 --- a/phpunit/fonts-api/wpFontsResolver/enqueueUserSelectedFonts-test.php +++ /dev/null @@ -1,131 +0,0 @@ -<?php -/** - * WP_Fonts_Resolver::enqueue_user_selected_fonts() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts_Resolver::enqueue_user_selected_fonts - */ -class Tests_Fonts_WpFontsResolver_EnqueueUserSelectedFonts extends WP_Fonts_TestCase { - - public static function set_up_before_class() { - self::$requires_switch_theme_fixtures = true; - - parent::set_up_before_class(); - - self::$administrator_id = self::factory()->user->create( - array( - 'role' => 'administrator', - 'user_email' => 'administrator@example.com', - ) - ); - } - - /** - * @dataProvider data_should_not_enqueue_when_no_user_selected_fonts - * - * @param array $styles Optional. Test styles. Default empty array. - */ - public function test_should_not_enqueue_when_no_user_selected_fonts( $styles = array() ) { - $this->set_up_global_styles( $styles ); - - $mock = $this->set_up_mock( 'enqueue' ); - $mock->expects( $this->never() ) - ->method( 'enqueue' ); - - $expected = array(); - $this->assertSame( $expected, WP_Fonts_Resolver::enqueue_user_selected_fonts() ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_not_enqueue_when_no_user_selected_fonts() { - return array( - 'no user-selected styles' => array(), - 'invalid element' => array( - array( - 'elements' => array( - 'invalid' => array( - 'typography' => array( - 'fontFamily' => 'var:preset|font-family|font1', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - ), - ), - ), - ), - ), - ); - } - - /** - * @dataProvider data_should_enqueue_when_user_selected_fonts - * - * @param array $styles Test styles. - * @param array $expected Expected results. - */ - public function test_should_enqueue_when_user_selected_fonts( $styles, $expected ) { - $mock = $this->set_up_mock( 'enqueue' ); - $mock->expects( $this->once() ) - ->method( 'enqueue' ) - ->with( - $this->identicalTo( $expected ) - ); - - $this->set_up_global_styles( $styles ); - - $this->assertSameSets( $expected, WP_Fonts_Resolver::enqueue_user_selected_fonts() ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_enqueue_when_user_selected_fonts() { - $global_styles = $this->get_mock_user_selected_fonts_global_styles(); - - return array( - 'heading, caption, text' => array( - 'styles' => $global_styles['font1'], - 'expected' => array( 'font1' ), - ), - 'heading, button' => array( - 'styles' => $global_styles['font2'], - 'expected' => array( 'font2' ), - ), - 'text' => array( - 'styles' => $global_styles['font3'], - 'expected' => array( 'font3' ), - ), - 'all' => array( - 'styles' => $global_styles['all'], - 'expected' => array( - 0 => 'font1', - // font1 occurs 2 more times and gets removed as duplicates. - 3 => 'font2', - 4 => 'font3', - ), - ), - 'all with invalid element' => array( - 'styles' => $global_styles['all with invalid element'], - 'expected' => array( - 0 => 'font1', - // font1 occurs 2 more times and gets removed as duplicates. - 3 => 'font2', - // Skips font2 for the "invalid" element. - 4 => 'font3', - ), - ), - ); - } -} diff --git a/phpunit/fonts-api/wpFontsUtils/convertFontFamilyIntoHandle-test.php b/phpunit/fonts-api/wpFontsUtils/convertFontFamilyIntoHandle-test.php deleted file mode 100644 index 288a0d11715e2f..00000000000000 --- a/phpunit/fonts-api/wpFontsUtils/convertFontFamilyIntoHandle-test.php +++ /dev/null @@ -1,84 +0,0 @@ -<?php -/** - * WP_Fonts_Utils::convert_font_family_into_handle() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts_Utils::convert_font_family_into_handle - */ -class Tests_Fonts_WpFontsUtils_ConvertFontFamilyIntoHandle extends WP_Fonts_TestCase { - - /** - * @dataProvider data_with_valid_input - * - * @param mixed $font_family Font family to test. - * @param string $expected Expected results. - */ - public function test_should_convert_with_valid_input( $font_family, $expected ) { - $this->assertSame( $expected, WP_Fonts_Utils::convert_font_family_into_handle( $font_family ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_with_valid_input() { - return array( - 'font family single word name' => array( - 'font_family' => 'Merriweather', - 'expected' => 'merriweather', - ), - 'font family multiword name' => array( - 'font_family' => 'Source Sans Pro', - 'expected' => 'source-sans-pro', - ), - 'font family handle delimited by hyphens' => array( - 'font_family' => 'source-serif-pro', - 'expected' => 'source-serif-pro', - ), - 'font family handle delimited by underscore' => array( - 'font_family' => 'source_serif_pro', - 'expected' => 'source_serif_pro', - ), - 'font family handle delimited by hyphens and underscore' => array( - 'font_family' => 'my-custom_font_family', - 'expected' => 'my-custom_font_family', - ), - 'font family handle delimited mixture' => array( - 'font_family' => 'My custom_font-family', - 'expected' => 'my-custom_font-family', - ), - ); - } - - /** - * @dataProvider data_with_invalid_input - * - * @covers WP_Fonts_Utils::convert_font_family_into_handle - * - * @param mixed $invalid_input Invalid input. - */ - public function test_should_not_convert_with_invalid_input( $invalid_input ) { - $this->assertNull( WP_Fonts_Utils::convert_font_family_into_handle( $invalid_input ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_with_invalid_input() { - return array( - 'empty string' => array( '' ), - 'integer' => array( 10 ), - 'font family wrapped in an array' => array( array( 'source-serif-pro' ) ), - ); - } -} diff --git a/phpunit/fonts-api/wpFontsUtils/convertVariationIntoHandle-test.php b/phpunit/fonts-api/wpFontsUtils/convertVariationIntoHandle-test.php deleted file mode 100644 index 6be7f585c5a356..00000000000000 --- a/phpunit/fonts-api/wpFontsUtils/convertVariationIntoHandle-test.php +++ /dev/null @@ -1,122 +0,0 @@ -<?php -/** - * WP_Fonts_Utils::convert_variation_into_handle() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts_Utils::convert_variation_into_handle - */ -class Tests_Fonts_WpFontsUtils_ConvertVariationIntoHandle extends WP_Fonts_TestCase { - - /** - * @dataProvider data_with_valid_input - * - * @param string $font_family Font family to test. - * @param array $variation Variation to test. - * @param string $expected Expected results. - */ - public function test_should_convert_with_valid_inputs( $font_family, array $variation, $expected ) { - $this->assertSame( $expected, WP_Fonts_Utils::convert_variation_into_handle( $font_family, $variation ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_with_valid_input() { - return array( - 'with only font-weight' => array( - 'font_family' => 'merriweather', - 'variation' => array( - 'font-weight' => '400', - ), - 'expected' => 'merriweather-400', - ), - 'with no font-style' => array( - 'font_family' => 'source-sans-pro', - 'variation' => array( - 'font-weight' => '200 900', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'provider' => 'local', - ), - 'expected' => 'source-sans-pro-200-900', - ), - 'with font family name and full variant' => array( - 'font_family' => 'source-sans-pro', - 'variation' => array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - 'expected' => 'source-sans-pro-200-900-normal', - ), - ); - } - - /** - * @dataProvider data_with_invalid_input - * - * @param string $font_family Font family to test. - * @param array $invalid_input Variation to test. - */ - public function tests_should_convert_with_invalid_input( $font_family, $invalid_input ) { - $this->expectNotice(); - $this->expectNoticeMessage( 'Variant handle could not be determined as font-weight and/or font-style are require' ); - - $this->assertNull( WP_Fonts_Utils::convert_variation_into_handle( $font_family, $invalid_input ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_with_invalid_input() { - return array( - 'with no font-weight or font-style' => array( - 'font_family' => 'merriweather', - 'variation' => array( - 'provider' => 'local', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - 'with non-string font-weight' => array( - 'font_family' => 'merriweather', - 'variation' => array( - 'font-weight' => 400, - ), - ), - 'with non-string font-style' => array( - 'font_family' => 'merriweather', - 'variation' => array( - 'font-style' => 0, - ), - ), - 'with empty string font-weight' => array( - 'font_family' => 'merriweather', - 'variation' => array( - 'font-weight' => '', - ), - ), - 'with empty string font-style' => array( - 'font_family' => 'merriweather', - 'variation' => array( - 'font-style' => '', - ), - ), - ); - } -} diff --git a/phpunit/fonts-api/wpFontsUtils/getFontFamilyFromVariation-test.php b/phpunit/fonts-api/wpFontsUtils/getFontFamilyFromVariation-test.php deleted file mode 100644 index 21f1c4d5c6a594..00000000000000 --- a/phpunit/fonts-api/wpFontsUtils/getFontFamilyFromVariation-test.php +++ /dev/null @@ -1,134 +0,0 @@ -<?php -/** - * WP_Fonts_Utils::get_font_family_from_variation() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts_Utils::get_font_family_from_variation - */ -class Tests_Fonts_WpFontsUtils_GetFontFamilyFromVariation extends WP_Fonts_TestCase { - - /** - * @dataProvider data_with_valid_variation - * - * @param array $variation Variation to test. - * @param string $expected Expected results. - */ - public function test_with_valid_variation( array $variation, $expected ) { - $this->assertSame( $expected, WP_Fonts_Utils::get_font_family_from_variation( $variation ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_with_valid_variation() { - return array( - 'keyed by font-family' => array( - 'variation' => array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - 'expected' => 'Source Serif Pro', - ), - 'keyed by fontFamily and as a handle' => array( - 'variation' => array( - 'fontFamily' => 'source-sans-pro', - 'font-weight' => '200 900', - 'src' => 'https://example.com/assets/fonts/source-sans-pro/source-sans-pro.ttf.woff2', - 'provider' => 'local', - ), - 'expected' => 'source-sans-pro', - ), - 'with font family name and full variant' => array( - 'variation' => array( - 'provider' => 'local', - 'font-family' => 'Merriweather', - 'font-style' => 'normal', - 'font-weight' => '400 600', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - 'font-display' => 'fallback', - ), - 'expected' => 'Merriweather', - ), - ); - } - - /** - * @dataProvider data_with_invalid_input - * - * @param array $invalid_variation Variation to test. - * @param string $expected_message Expected notice message. - */ - public function test_with_invalid_input( array $invalid_variation, $expected_message ) { - $this->expectNotice(); - $this->expectNoticeMessage( $expected_message ); - - $this->assertNull( WP_Fonts_Utils::get_font_family_from_variation( $invalid_variation ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_with_invalid_input() { - return array( - 'keyed with underscore' => array( - 'variation' => array( - 'provider' => 'local', - 'font_family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - 'expected_message' => 'Font family not found.', - ), - 'keyed with space' => array( - 'variation' => array( - 'font family' => 'Source Sans Pro', - 'font-weight' => '200 900', - 'src' => 'https://example.com/assets/fonts/source-sans-pro/source-sans-pro.ttf.woff2', - 'provider' => 'local', - ), - 'expected_message' => 'Font family not found.', - ), - 'fontFamily => empty string' => array( - 'variation' => array( - 'fontFamily' => '', - 'font-weight' => '200 900', - 'src' => 'https://example.com/assets/fonts/source-sans-pro/source-sans-pro.ttf.woff2', - 'provider' => 'local', - ), - 'expected_message' => 'Font family not defined in the variation.', - ), - 'font-family => empty string' => array( - 'variation' => array( - 'provider' => 'local', - 'font-family' => '', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - 'expected_message' => 'Font family not defined in the variation.', - ), - ); - } -} diff --git a/phpunit/fonts-api/wpFontsUtils/isDefined-test.php b/phpunit/fonts-api/wpFontsUtils/isDefined-test.php deleted file mode 100644 index e770559b33a0e0..00000000000000 --- a/phpunit/fonts-api/wpFontsUtils/isDefined-test.php +++ /dev/null @@ -1,61 +0,0 @@ -<?php -/** - * WP_Fonts_Utils::is_defined() tests. - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/../wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers WP_Fonts_Utils::is_defined - */ -class Tests_Fonts_WpFontsUtils_IsDefined extends WP_Fonts_TestCase { - - /** - * @dataProvider data_when_defined - * - * @param mixed $input Input to test. - */ - public function test_should_return_true_when_defined( $input ) { - $this->assertTrue( WP_Fonts_Utils::is_defined( $input ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_when_defined() { - return array( - 'name: non empty string' => array( 'Some Font Family' ), - 'handle: non empty string' => array( 'some-font-family' ), - ); - } - - /** - * @dataProvider data_when_not_defined - * - * @param mixed $invalid_input Input to test. - */ - public function test_should_return_false_when_not_defined( $invalid_input ) { - $this->assertFalse( WP_Fonts_Utils::is_defined( $invalid_input ) ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_when_not_defined() { - return array( - 'empty string' => array( '' ), - 'string 0' => array( '0' ), - 'integer' => array( 10 ), - 'name wrapped in an array' => array( array( 'Some Font Family' ) ), - 'handle wrapped in an array' => array( array( 'some-font-family' ) ), - ); - } -} diff --git a/phpunit/fonts-api/wpPrintFonts-test.php b/phpunit/fonts-api/wpPrintFonts-test.php deleted file mode 100644 index 4f50cf6a8cd5ea..00000000000000 --- a/phpunit/fonts-api/wpPrintFonts-test.php +++ /dev/null @@ -1,230 +0,0 @@ -<?php -/** - * Unit and integration tests for wp_print_fonts(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; -require_once __DIR__ . '/../fixtures/mock-provider.php'; - -/** - * @group fontsapi - * @covers ::wp_print_fonts - */ -class Tests_Fonts_WpPrintFonts extends WP_Fonts_TestCase { - - public static function set_up_before_class() { - self::$requires_switch_theme_fixtures = true; - - parent::set_up_before_class(); - - static::set_up_admin_user(); - } - - public function test_should_return_empty_array_when_no_fonts_registered() { - $this->assertSame( array(), wp_print_fonts() ); - } - - /** - * Unit test which mocks WP_Fonts methods. - * - * @dataProvider data_mocked_handles - * - * @param string|string[] $handles Handles to test. - */ - public function test_should_return_mocked_handles( $handles ) { - $mock = $this->set_up_mock( array( 'get_registered_font_families', 'do_items' ) ); - $mock->expects( $this->once() ) - ->method( 'get_registered_font_families' ) - ->will( $this->returnValue( $handles ) ); - - $mock->expects( $this->once() ) - ->method( 'do_items' ) - ->with( - $this->identicalTo( $handles ) - ) - ->will( $this->returnValue( $handles ) ); - - wp_print_fonts( $handles ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_mocked_handles() { - return array( - 'font family' => array( - array( 'my-custom-font' ), - ), - 'multiple font families' => array( - array( - 'font1', - 'font2', - ), - ), - ); - } - - /** - * Integration test that registers providers and fonts and then enqueues before - * testing the printing functionality. - * - * @dataProvider data_print_enqueued - * - * @param array $setup Test set up information for provider, fonts, and enqueued. - * @param array $expected_done Expected array of printed handles. - * @param string $expected_output Expected printed output. - */ - public function test_should_print_enqueued( $setup, $expected_done, $expected_output ) { - $wp_fonts = wp_fonts(); - - $this->setup_integrated_deps( $setup, $wp_fonts ); - - $this->expectOutputString( $expected_output ); - $actual_done = wp_print_fonts(); - $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); - } - - /** - * Integration test to validate printing given handles. Rather than mocking internal functionality, - * it registers providers and fonts but does not enqueue. - * - * @dataProvider data_print_enqueued - * - * @param array $setup Test set up information for provider, fonts, and enqueued. - * @param array $expected_done Expected array of printed handles. - * @param string $expected_output Expected printed output. - */ - public function test_should_print_handles_when_not_enqueued( $setup, $expected_done, $expected_output ) { - $wp_fonts = wp_fonts(); - - $this->setup_integrated_deps( $setup, $wp_fonts, false ); - // Do not enqueue. Instead, pass the handles to wp_print_fonts(). - $handles = $setup['enqueued']; - $this->assertEmpty( $wp_fonts->queue, 'No fonts should be enqueued' ); - - $this->expectOutputString( $expected_output ); - $actual_done = wp_print_fonts( $handles ); - $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); - } - - /** - * @dataProvider data_should_print_all_registered_fonts_for_iframed_editor - * - * @param string $fonts Fonts to register. - * @param array $expected Expected results. - */ - public function test_should_print_all_registered_fonts_for_iframed_editor( $fonts, $expected ) { - wp_register_fonts( $fonts ); - - $this->expectOutputString( $expected['output'] ); - $actual_done = wp_print_fonts( true ); - $this->assertSameSets( $expected['done'], $actual_done, 'All registered font-family handles should be returned' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_should_print_all_registered_fonts_for_iframed_editor() { - $local_fonts = $this->get_registered_local_fonts(); - $font_faces = $this->get_registered_fonts_css(); - - return array( - 'Merriweather with 1 variation' => array( - 'fonts' => array( 'merriweather' => $local_fonts['merriweather'] ), - 'expected' => array( - 'done' => array( 'merriweather', 'merriweather-200-900-normal' ), - 'output' => sprintf( - "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n", - $font_faces['merriweather-200-900-normal'] - ), - ), - ), - 'Source Serif Pro with 2 variations' => array( - 'fonts' => array( 'Source Serif Pro' => $local_fonts['Source Serif Pro'] ), - 'expected' => array( - 'done' => array( 'source-serif-pro', 'Source Serif Pro-300-normal', 'Source Serif Pro-900-italic' ), - 'output' => sprintf( - "<style id='wp-fonts-local' type='text/css'>\n%s%s\n</style>\n", - $font_faces['Source Serif Pro-300-normal'], - $font_faces['Source Serif Pro-900-italic'] - ), - ), - ), - 'all fonts' => array( - 'fonts' => $local_fonts, - 'expected' => array( - 'done' => array( - 'merriweather', - 'merriweather-200-900-normal', - 'source-serif-pro', - 'Source Serif Pro-300-normal', - 'Source Serif Pro-900-italic', - ), - 'output' => sprintf( - "<style id='wp-fonts-local' type='text/css'>\n%s%s%s\n</style>\n", - $font_faces['merriweather-200-900-normal'], - $font_faces['Source Serif Pro-300-normal'], - $font_faces['Source Serif Pro-900-italic'] - ), - ), - ), - ); - } - - /** - * Integration test for printing user-selected global fonts. - * This test registers providers and fonts and then enqueues before testing the printing functionality. - * - * @dataProvider data_print_user_selected_fonts - * - * @param array $global_styles Test set up information for provider, fonts, and enqueued. - * @param array $expected_done Expected array of printed handles. - * @param string $expected_output Expected printed output. - */ - public function test_should_print_user_selected_fonts( $global_styles, $expected_done, $expected_output ) { - $wp_fonts = wp_fonts(); - - $setup = array( - 'provider' => array( 'mock' => $this->get_provider_definitions( 'mock' ) ), - 'registered' => $this->get_registered_mock_fonts(), - 'global_styles' => $global_styles, - ); - $this->setup_integrated_deps( $setup, $wp_fonts, false ); - - $this->expectOutputString( $expected_output ); - $actual_printed_fonts = wp_print_fonts(); - $this->assertSameSets( $expected_done, $actual_printed_fonts, 'Should print font-faces for given user-selected fonts' ); - } - - - /** - * Sets up the dependencies for integration test. - * - * @param array $setup Dependencies to set up. - * @param WP_Fonts $wp_fonts Instance of WP_Fonts. - * @param bool $enqueue Whether to enqueue. Default true. - */ - private function setup_integrated_deps( array $setup, $wp_fonts, $enqueue = true ) { - foreach ( $setup['provider'] as $provider ) { - $wp_fonts->register_provider( $provider['id'], $provider['class'] ); - } - foreach ( $setup['registered'] as $handle => $variations ) { - $this->setup_register( $handle, $variations, $wp_fonts ); - } - - if ( $enqueue ) { - $wp_fonts->enqueue( $setup['enqueued'] ); - } - - if ( ! empty( $setup['global_styles'] ) ) { - $this->set_up_global_styles( $setup['global_styles'] ); - } - } -} diff --git a/phpunit/fonts-api/wpRegisterFontProvider-test.php b/phpunit/fonts-api/wpRegisterFontProvider-test.php deleted file mode 100644 index 157ab7f4d0a66c..00000000000000 --- a/phpunit/fonts-api/wpRegisterFontProvider-test.php +++ /dev/null @@ -1,96 +0,0 @@ -<?php -/** - * Unit tests for wp_register_font_provider(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; -require_once __DIR__ . '/../fixtures/mock-provider.php'; - -/** - * @group fontsapi - * @covers ::wp_register_font_provider - */ -class Tests_Fonts_WpRegisterFontProvider extends WP_Fonts_TestCase { - - /** - * @dataProvider data_register_providers - * - * @param string $provider_id Provider ID. - * @param string $class Provider class name. - */ - public function test_should_register_provider( $provider_id, $class ) { - $mock = $this->set_up_mock( 'register_provider' ); - $mock->expects( $this->once() ) - ->method( 'register_provider' ) - ->with( - $this->identicalTo( $provider_id ), - $this->identicalTo( $class ) - ) - ->will( $this->returnValue( true ) ); - - $this->assertTrue( wp_register_font_provider( $provider_id, $class ), 'wp_register_font_provider() should return true' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_register_providers() { - return array( - 'mock' => array( - 'provider_id' => 'mock', - 'class' => Mock_Provider::class, - ), - 'local' => array( - 'provider_id' => 'local', - 'class' => WP_Fonts_Provider_Local::class, - ), - ); - } - - /** - * @dataProvider data_invalid_providers - * - * @param string $provider_id Provider ID. - * @param string $class Provider class name. - */ - public function test_should_not_register( $provider_id, $class ) { - $mock = $this->set_up_mock( 'register_provider' ); - $mock->expects( $this->once() ) - ->method( 'register_provider' ) - ->with( - $this->identicalTo( $provider_id ), - $this->identicalTo( $class ) - ) - ->will( $this->returnValue( false ) ); - - $this->assertFalse( wp_register_font_provider( $provider_id, $class ), 'wp_register_font_provider() should return false' ); - - } - - /** - * Data provider. - * - * @return array - */ - public function data_invalid_providers() { - return array( - 'provider_id is empty' => array( - 'provider_id' => '', - 'class' => Mock_Provider::class, - ), - 'class is empty' => array( - 'provider_id' => 'local', - 'class' => '', - ), - 'class does not exist' => array( - 'provider_id' => 'doesnotexist', - 'class' => 'Provider_Does_Not_Exist', - ), - ); - } -} diff --git a/phpunit/fonts-api/wpRegisterFonts-test.php b/phpunit/fonts-api/wpRegisterFonts-test.php deleted file mode 100644 index da54fd6438f970..00000000000000 --- a/phpunit/fonts-api/wpRegisterFonts-test.php +++ /dev/null @@ -1,104 +0,0 @@ -<?php -/** - * Integration tests for wp_register_fonts(). - * - * @package WordPress - * @subpackage Fonts API - */ - -require_once __DIR__ . '/wp-fonts-testcase.php'; - -/** - * @group fontsapi - * @covers ::wp_register_fonts - * @covers WP_Fonts::add - * @covers WP_Fonts::add_variation - */ -class Tests_Fonts_WpRegisterFonts extends WP_Fonts_TestCase { - - /** - * @dataProvider data_fonts - * - * @param array $fonts Array of fonts to test. - * @param array $expected Expected results. - */ - public function test_should_register( array $fonts, array $expected ) { - $actual = wp_register_fonts( $fonts ); - $this->assertSame( $expected['wp_register_fonts'], $actual, 'Font family handle(s) should be returned' ); - $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Web fonts should match registered queue' ); - } - - /** - * @dataProvider data_fonts - * - * @param array $fonts Array of fonts to test. - */ - public function test_should_not_enqueue_on_registration( array $fonts ) { - wp_register_fonts( $fonts ); - $this->assertEmpty( $this->get_enqueued_handles() ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_fonts() { - return array( - 'font family keyed with slug' => array( - 'fonts' => array( - 'source-serif-pro' => array( - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - ), - 'expected' => array( - 'wp_register_fonts' => array( 'source-serif-pro' ), - 'get_registered' => array( - 'source-serif-pro', - 'source-serif-pro-200-900-normal', - ), - ), - ), - 'font family keyed with name' => array( - 'fonts' => array( - 'Source Serif Pro' => array( - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '200 900', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - ), - 'expected' => array( - 'wp_register_fonts' => array( 'source-serif-pro' ), - 'get_registered' => array( - 'source-serif-pro', - 'source-serif-pro-200-900-normal', - 'source-serif-pro-200-900-italic', - ), - ), - ), - ); - } -} diff --git a/phpunit/multisite.xml b/phpunit/multisite.xml index 7a6117948547d3..ac18402abb0c82 100644 --- a/phpunit/multisite.xml +++ b/phpunit/multisite.xml @@ -10,15 +10,18 @@ <php> <env name="WP_MULTISITE" value="1" /> <env name="WORDPRESS_TABLE_PREFIX" value="wptests_" /> + <const name="FONT_LIBRARY_ENABLE" value="true"/> </php> <testsuites> <testsuite name="default"> <directory suffix="-test.php">./</directory> + <directory suffix=".php">./tests/</directory> </testsuite> </testsuites> <groups> <exclude> <group>ms-excluded</group> + <group>fontsapi</group> </exclude> </groups> </phpunit> diff --git a/phpunit/style-engine/class-wp-style-engine-processor-test.php b/phpunit/style-engine/class-wp-style-engine-processor-test.php index 18392f5156fcc5..4177ab276f1c8c 100644 --- a/phpunit/style-engine/class-wp-style-engine-processor-test.php +++ b/phpunit/style-engine/class-wp-style-engine-processor-test.php @@ -75,16 +75,19 @@ public function test_should_return_prettified_css_rules() { $a_wonderful_processor = new WP_Style_Engine_Processor_Gutenberg(); $a_wonderful_processor->add_rules( array( $a_wonderful_css_rule, $a_very_wonderful_css_rule, $a_more_wonderful_css_rule ) ); - $expected = '.a-more-wonderful-rule { - font-family: Wonderful sans; - font-size: 1em; + $expected = '.a-wonderful-rule { + color: var(--wonderful-color); background-color: orange; } -.a-wonderful-rule, .a-very_wonderful-rule { color: var(--wonderful-color); background-color: orange; } +.a-more-wonderful-rule { + font-family: Wonderful sans; + font-size: 1em; + background-color: orange; +} '; $this->assertSame( $expected, @@ -170,6 +173,9 @@ public function test_should_dedupe_and_merge_css_declarations() { /** * Tests printing out 'unoptimized' CSS, that is, uncombined selectors and duplicate CSS rules. + * This is the default. + * + * @ticket 58811 * * @covers ::get_css */ @@ -215,9 +221,11 @@ public function test_should_not_optimize_css_output() { /** * Tests that 'optimized' CSS is output, that is, that duplicate CSS rules are combined under their corresponding selectors. * + * @ticket 58811 + * * @covers ::get_css */ - public function test_should_optimize_css_output_by_default() { + public function test_should_not_optimize_css_output_by_default() { $a_sweet_rule = new WP_Style_Engine_CSS_Rule_Gutenberg( '.a-sweet-rule', array( @@ -238,13 +246,15 @@ public function test_should_optimize_css_output_by_default() { $a_sweet_processor->add_rules( array( $a_sweet_rule, $a_sweeter_rule ) ); $this->assertSame( - '.a-sweet-rule,#an-even-sweeter-rule > marquee{color:var(--sweet-color);background-color:purple;}', + '.a-sweet-rule{color:var(--sweet-color);background-color:purple;}#an-even-sweeter-rule > marquee{color:var(--sweet-color);background-color:purple;}', $a_sweet_processor->get_css( array( 'prettify' => false ) ) ); } /** - * Tests that incoming CSS rules are merged with existing CSS rules. + * Tests that incoming CSS rules are optimized and merged with existing CSS rules. + * + * @ticket 58811 * * @covers ::add_rules */ @@ -266,7 +276,12 @@ public function test_should_combine_previously_added_css_rules() { $a_lovely_processor->add_rules( $a_lovelier_rule ); $this->assertSame( '.a-lovely-rule,.a-lovelier-rule{border-color:purple;}', - $a_lovely_processor->get_css( array( 'prettify' => false ) ), + $a_lovely_processor->get_css( + array( + 'prettify' => false, + 'optimize' => true, + ) + ), 'Return value of get_css() does not match expectations when combining 2 CSS rules' ); @@ -288,7 +303,12 @@ public function test_should_combine_previously_added_css_rules() { $this->assertSame( '.a-lovely-rule,.a-lovelier-rule,.a-most-lovely-rule,.a-perfectly-lovely-rule{border-color:purple;}', - $a_lovely_processor->get_css( array( 'prettify' => false ) ), + $a_lovely_processor->get_css( + array( + 'prettify' => false, + 'optimize' => true, + ) + ), 'Return value of get_css() does not match expectations when combining 4 CSS rules' ); } diff --git a/phpunit/style-engine/style-engine-test.php b/phpunit/style-engine/style-engine-test.php index 20d817432c0a22..fae95b995ee44d 100644 --- a/phpunit/style-engine/style-engine-test.php +++ b/phpunit/style-engine/style-engine-test.php @@ -632,6 +632,8 @@ public function test_should_return_stylesheet_from_css_rules() { /** * Tests that incoming styles are deduped and merged. * + * @ticket 58811 + * * @covers ::gutenberg_style_engine_get_stylesheet_from_css_rules * @covers WP_Style_Engine_Gutenberg::compile_stylesheet_from_css_rules */ @@ -674,7 +676,7 @@ public function test_should_dedupe_and_merge_css_rules() { $compiled_stylesheet = gutenberg_style_engine_get_stylesheet_from_css_rules( $css_rules, array( 'prettify' => false ) ); - $this->assertSame( '.gandalf{color:white;height:190px;border-style:dotted;padding:10px;margin-bottom:100px;}.dumbledore,.rincewind{color:grey;height:90px;border-style:dotted;}', $compiled_stylesheet ); + $this->assertSame( '.gandalf{color:white;height:190px;border-style:dotted;padding:10px;margin-bottom:100px;}.dumbledore{color:grey;height:90px;border-style:dotted;}.rincewind{color:grey;height:90px;border-style:dotted;}', $compiled_stylesheet ); } /** diff --git a/phpunit/tests/fonts-api/base.php b/phpunit/tests/fonts-api/base.php new file mode 100644 index 00000000000000..11ae3b624ea8ca --- /dev/null +++ b/phpunit/tests/fonts-api/base.php @@ -0,0 +1,391 @@ +<?php +/** + * Test case for the Fonts API tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/wp-fonts-tests-dataset.php'; + +/** + * Abstracts the common tasks for the API's tests. + */ +abstract class WP_Fonts_TestCase extends WP_UnitTestCase { + use WP_Fonts_Tests_Datasets; + + /** + * Original WP_Fonts instance, before the tests. + * + * @var WP_Fonts + */ + private $old_wp_fonts; + + /** + * Current error reporting level (before a test changes it). + * + * @var null|int + */ + protected $error_reporting_level = null; + + /** + * Reflection data store for non-public property access. + * + * @var ReflectionProperty[] + */ + protected $property = array(); + + /** + * Indicates the test class uses `switch_theme()` and requires + * set_up and tear_down fixtures to set and reset hooks and memory. + * + * If a test class switches themes, set this property to `true`. + * + * @var bool + */ + protected static $requires_switch_theme_fixtures = false; + + /** + * Theme root directory. + * + * @var string + */ + protected static $theme_root; + + /** + * Original theme directory. + * + * @var string + */ + protected $orig_theme_dir; + + /** + * Administrator ID. + * + * @var int + */ + protected static $administrator_id = 0; + + public static function set_up_before_class() { + parent::set_up_before_class(); + + if ( self::$requires_switch_theme_fixtures ) { + self::$theme_root = realpath( GUTENBERG_DIR_TESTDATA . '/themedir1' ); + } + } + + public static function tear_down_after_class() { + // Reset static flags. + self::$requires_switch_theme_fixtures = false; + + parent::tear_down_after_class(); + } + + public function set_up() { + parent::set_up(); + + $this->old_wp_fonts = $GLOBALS['wp_fonts']; + $GLOBALS['wp_fonts'] = null; + + if ( self::$requires_switch_theme_fixtures ) { + $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; + + // /themes is necessary as theme.php functions assume /themes is the root if there is only one root. + $GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes', self::$theme_root ); + + // Set up the new root. + add_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); + + // Clear caches. + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + } + } + + public function tear_down() { + $this->property = array(); + $GLOBALS['wp_fonts'] = $this->old_wp_fonts; + + // Reset the error reporting when modified within a test. + if ( is_int( $this->error_reporting_level ) ) { + error_reporting( $this->error_reporting_level ); + $this->error_reporting_level = null; + } + + if ( self::$requires_switch_theme_fixtures ) { + // Clean up the filters to modify the theme root. + remove_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); + remove_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); + remove_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); + + WP_Theme_JSON_Resolver::clean_cached_data(); + if ( class_exists( 'WP_Theme_JSON_Resolver_Gutenberg' ) ) { + WP_Theme_JSON_Resolver_Gutenberg::clean_cached_data(); + } + } + + parent::tear_down(); + } + + public function clean_up_global_scope() { + parent::clean_up_global_scope(); + + if ( self::$requires_switch_theme_fixtures ) { + $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; + wp_clean_themes_cache(); + + if ( function_exists( 'wp_clean_theme_json_cache' ) ) { + wp_clean_theme_json_cache(); + } + + if ( function_exists( '_gutenberg_clean_theme_json_caches' ) ) { + _gutenberg_clean_theme_json_caches(); + } + + unset( $GLOBALS['wp_themes'] ); + } + } + + public function filter_set_theme_root() { + return self::$theme_root; + } + + protected function set_up_mock( $method ) { + $mock = $this->setup_object_mock( $method, WP_Fonts::class ); + + // Set the global. + $GLOBALS['wp_fonts'] = $mock; + + return $mock; + } + + protected function setup_object_mock( $method, $class ) { + if ( is_string( $method ) ) { + $method = array( $method ); + } + + return $this->getMockBuilder( $class )->setMethods( $method )->getMock(); + } + + protected function get_registered_handles() { + return array_keys( $this->get_registered() ); + } + + protected function get_registered() { + return wp_fonts()->registered; + } + + protected function get_variations( $font_family, $wp_fonts = null ) { + if ( ! ( $wp_fonts instanceof WP_Fonts ) ) { + $wp_fonts = wp_fonts(); + } + + return $wp_fonts->registered[ $font_family ]->deps; + } + + protected function get_enqueued_handles() { + return wp_fonts()->queue; + } + + protected function get_queued_before_register( $wp_fonts = null ) { + return $this->get_property_value( 'queued_before_register', WP_Dependencies::class, $wp_fonts ); + } + + protected function get_reflection_property( $property_name, $class = 'WP_Fonts' ) { + $property = new ReflectionProperty( $class, $property_name ); + $property->setAccessible( true ); + + return $property; + } + + protected function get_property_value( $property_name, $class, $wp_fonts = null ) { + $property = $this->get_reflection_property( $property_name, $class ); + + if ( ! $wp_fonts ) { + $wp_fonts = wp_fonts(); + } + + return $property->getValue( $wp_fonts ); + } + + protected function setup_property( $class, $property_name ) { + $key = $this->get_property_key( $class, $property_name ); + + if ( ! isset( $this->property[ $key ] ) ) { + $this->property[ $key ] = new ReflectionProperty( $class, 'providers' ); + $this->property[ $key ]->setAccessible( true ); + } + + return $this->property[ $key ]; + } + + protected function get_property_key( $class, $property_name ) { + return $class . '::$' . $property_name; + } + + /** + * Opens the accessibility to access the given private or protected method. + * + * @param string $method_name Name of the method to open. + * @return ReflectionMethod Instance of the method, ie to invoke it in the test. + */ + protected function get_reflection_method( $method_name ) { + $method = new ReflectionMethod( WP_Fonts::class, $method_name ); + $method->setAccessible( true ); + + return $method; + } + + /** + * Sets up multiple font family and variation mocks. + * + * @param array $inputs Array of array( font-family => variations ) to setup. + * @param WP_Fonts $wp_fonts Instance of WP_Fonts. + * @return stdClass[] Array of registered mocks. + */ + protected function setup_registration_mocks( array $inputs, WP_Fonts $wp_fonts ) { + $mocks = array(); + + $build_mock = static function ( $font_family, $is_font_family = false ) use ( &$mocks, $wp_fonts ) { + $mock = new stdClass(); + $mock->deps = array(); + $mock->extra = array( 'is_font_family' => $is_font_family ); + if ( $is_font_family ) { + $mock->extra['font-family'] = $font_family; + } + + $handle = $is_font_family ? WP_Fonts_Utils::convert_font_family_into_handle( $font_family ) : $font_family; + + // Add to each queue. + $mocks[ $handle ] = $mock; + $wp_fonts->registered[ $handle ] = $mock; + + return $mock; + }; + + foreach ( $inputs as $font_family => $variations ) { + $font_mock = $build_mock( $font_family, true ); + + foreach ( $variations as $variation_handle => $variation ) { + if ( ! is_string( $variation_handle ) ) { + $variation_handle = $variation; + } + $variation_mock = $build_mock( $variation_handle ); + $variation_mock->extra['font-properties'] = $variation; + $font_mock->deps[] = $variation_handle; + } + } + + return $mocks; + } + + /** + * Register one or more font-family and its variations to set up a test. + * + * @param string $font_family Font family to test. + * @param array $variations Variations. + * @param WP_Fonts|null $wp_fonts Optional. Instance of the WP_Fonts. + */ + protected function setup_register( $font_family, $variations, $wp_fonts = null ) { + if ( ! ( $wp_fonts instanceof WP_Fonts ) ) { + $wp_fonts = wp_fonts(); + } + + $font_family_handle = $wp_fonts->add_font_family( $font_family ); + + foreach ( $variations as $variation_handle => $variation ) { + if ( ! is_string( $variation_handle ) ) { + $variation_handle = ''; + } + $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); + } + } + + /** + * Sets up the WP_Fonts::$provider property. + * + * @param WP_Fonts $wp_fonts Instance of WP_Fonts. + * @param string|array $provider Provider ID when string. Else provider definition with 'id' and 'class' keys. + * @param array $font_handles Optional. Font handles for this provider. + */ + protected function setup_provider_property_mock( WP_Fonts $wp_fonts, $provider, array $font_handles = array() ) { + if ( is_string( $provider ) ) { + $provider = $this->get_provider_definitions( $provider ); + } + + $property = $this->setup_property( WP_Fonts::class, 'providers' ); + $providers = $property->getValue( $wp_fonts ); + + if ( ! isset( $providers[ $provider['id'] ] ) ) { + $providers[ $provider['id'] ] = array( + 'class' => $provider['class'], + 'fonts' => $font_handles, + ); + } else { + $providers[ $provider['id'] ] = array_merge( $font_handles, $providers[ $provider['id'] ]['fonts'] ); + } + + $property->setValue( $wp_fonts, $providers ); + } + + /** + * Gets the variation handles for the provider from the given fonts. + * + * @since X.X.X + * + * @param array $fonts Fonts definitions keyed by font family. + * @param string $provider_id Provider ID. + * @return array|string[] Array of handles on success. Else empty array. + */ + protected function get_handles_for_provider( array $fonts, $provider_id ) { + $handles = array(); + + foreach ( $fonts as $variations ) { + foreach ( $variations as $variation_handle => $variation ) { + if ( $provider_id !== $variation['provider'] ) { + continue; + } + $handles[] = $variation_handle; + } + } + + return $handles; + } + + protected static function set_up_admin_user() { + self::$administrator_id = self::factory()->user->create( + array( + 'role' => 'administrator', + 'user_email' => 'administrator@example.com', + ) + ); + } + + /** + * Sets up the global styles. + * + * @param array $styles User-selected styles structure. + * @param array $theme Optional. Theme to switch to for the test. Default 'fonts-block-theme'. + */ + protected function set_up_global_styles( array $styles, $theme = 'fonts-block-theme' ) { + switch_theme( $theme ); + + if ( empty( $styles ) ) { + return; + } + + // Make sure there is data from the user origin. + wp_set_current_user( self::$administrator_id ); + $user_cpt = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( wp_get_theme(), true ); + $config = json_decode( $user_cpt['post_content'], true ); + + // Add the test styles. + $config['styles'] = $styles; + + // Update the global styles and settings post. + $user_cpt['post_content'] = wp_json_encode( $config ); + wp_update_post( $user_cpt, true, false ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/base.php b/phpunit/tests/fonts-api/bc-layer/base.php new file mode 100644 index 00000000000000..27a4d43f7188a2 --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/base.php @@ -0,0 +1,46 @@ +<?php +/** + * Test case for the Fonts API's BC Layer tests. + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; +require_once __DIR__ . '/bc-layer-tests-dataset.php'; + +/** + * Abstracts the common tasks for the Font API's BC Layer tests. + */ +abstract class Fonts_BcLayer_TestCase extends WP_Fonts_TestCase { + use BC_Layer_Tests_Datasets; + + /** + * Original WP_Webfonts instance, before the tests. + * + * @var WP_Fonts + */ + private $old_wp_webfonts; + + public function set_up() { + parent::set_up(); + + $this->old_wp_webfonts = isset( $GLOBALS['wp_webfonts'] ) ? $GLOBALS['wp_webfonts'] : null; + $GLOBALS['wp_webfonts'] = null; + } + + public function tear_down() { + $GLOBALS['wp_webfonts'] = $this->old_wp_webfonts; + + parent::tear_down(); + } + + protected function set_up_webfonts_mock( $method ) { + $mock = $this->setup_object_mock( $method, WP_Webfonts::class ); + + // Set the global. + $GLOBALS['wp_webfonts'] = $mock; + + return $mock; + } +} diff --git a/phpunit/fonts-api/bc-layer/bc-layer-tests-dataset.php b/phpunit/tests/fonts-api/bc-layer/bc-layer-tests-dataset.php similarity index 100% rename from phpunit/fonts-api/bc-layer/bc-layer-tests-dataset.php rename to phpunit/tests/fonts-api/bc-layer/bc-layer-tests-dataset.php diff --git a/phpunit/tests/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure.php b/phpunit/tests/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure.php new file mode 100644 index 00000000000000..d76de3f205005d --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure.php @@ -0,0 +1,35 @@ +<?php +/** + * Integration tests for Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure + */ +class Tests_Fonts_GutenbergFontsApiBcLayer_IsDeprecatedStructure extends Fonts_BcLayer_TestCase { + + /** + * @dataProvider data_deprecated_structure + * + * @param array $fonts Fonts to test. + */ + public function test_should_detect_deprecated_structure( array $fonts ) { + $this->assertTrue( Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure( $fonts ) ); + } + + /** + * @dataProvider data_not_deprecated_structure + * + * @param array $fonts Fonts to test. + */ + public function test_should_not_detect_deprecated_structure( array $fonts ) { + $this->assertFalse( Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure( $fonts ) ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure.php b/phpunit/tests/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure.php new file mode 100644 index 00000000000000..b98481dfbf942f --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure.php @@ -0,0 +1,38 @@ +<?php +/** + * Integration tests for Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure + */ +class Tests_Fonts_GutenbergFontsApiBcLayer_MigrateDeprecatedStructure extends Fonts_BcLayer_TestCase { + + /** + * @dataProvider data_deprecated_structure + * + * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure + * + * @param array $fonts Fonts to test. + * @param array $expected Expected results. + */ + public function test_should_migrate_dprecated_structure_and_throw_deprecation( array $fonts, array $expected ) { + $this->assertSameSets( $expected['migration'], Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $fonts ) ); + } + + /** + * @dataProvider data_not_deprecated_structure + * + * @param array $fonts Fonts to test. + */ + public function test_should_return_fonts_and_not_throw_deprecation( array $fonts ) { + $this->assertSameSets( $fonts, Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $fonts ) ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/wpRegisterWebfonts.php b/phpunit/tests/fonts-api/bc-layer/wpRegisterWebfonts.php new file mode 100644 index 00000000000000..59e0a9d314a135 --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/wpRegisterWebfonts.php @@ -0,0 +1,62 @@ +<?php +/** + * Integration tests for wp_register_webfonts(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers ::wp_register_webfonts + */ +class Tests_Fonts_WpRegisterWebfonts extends Fonts_BcLayer_TestCase { + + /** + * @dataProvider data_deprecated_structure + * + * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure + * @expectedDeprecated wp_register_webfonts + * + * @param array $fonts Fonts to test. + */ + public function test_should_throw_deprecations( array $fonts ) { + wp_register_webfonts( $fonts ); + } + + /** + * @dataProvider data_deprecated_structure + * + * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure + * @expectedDeprecated wp_register_webfonts + * + * @param array $fonts Fonts to test. + * @param array $expected Expected results. + */ + public function test_should_register_with_deprecated_structure( array $fonts, array $expected ) { + $actual = wp_register_webfonts( $fonts ); + $this->assertSame( $expected['wp_register_webfonts'], $actual, 'Font family handle(s) should be returned' ); + $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Fonts should match registered queue' ); + } + + /** + * @dataProvider data_deprecated_structure_with_invalid_font_family + * + * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure + * @expectedDeprecated wp_register_webfonts + * + * @param array $fonts Fonts to test. + * @param string $expected_message Expected notice message. + */ + public function test_should_not_register_with_undefined_font_family( array $fonts, $expected_message ) { + $this->expectNotice(); + $this->expectNoticeMessage( $expected_message ); + + $actual = wp_register_webfonts( $fonts ); + $this->assertSame( array(), $actual, 'Return value should be an empty array' ); + $this->assertEmpty( $this->get_registered_handles(), 'No fonts should have registered' ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getAllWebfonts.php b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getAllWebfonts.php new file mode 100644 index 00000000000000..7a217a343c5359 --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getAllWebfonts.php @@ -0,0 +1,33 @@ +<?php +/** + * Integration tests for WP_Webfonts::get_all_webfonts(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers WP_Webfonts::get_all_webfonts + */ +class Tests_Fonts_WpWebfonts_GetAllWebfonts extends Fonts_BcLayer_TestCase { + use BC_Layer_Tests_Datasets; + + /** + * @dataProvider data_should_return_registered_webfonts + * + * @expectedDeprecated wp_webfonts + * @expectedDeprecated WP_Webfonts::get_all_webfonts + * + * @param array $fonts Fonts to register. + * @param array $expected Expected result. + */ + public function test_should_return_registered_webfonts( array $fonts, array $expected ) { + wp_register_fonts( $fonts ); + + $this->assertSame( $expected, wp_webfonts()->get_all_webfonts() ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getFontSlug.php b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getFontSlug.php new file mode 100644 index 00000000000000..7c05f24b037520 --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getFontSlug.php @@ -0,0 +1,134 @@ +<?php +/** + * Integration tests for WP_Webfonts::get_font_slug(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers WP_Webfonts::get_font_slug + */ +class Tests_Fonts_WpWebfonts_GetFontSlug extends Fonts_BcLayer_TestCase { + + /** + * @dataProvider data_should_get_font_slug + * + * @expectedDeprecated WP_Webfonts::get_font_slug + * + * @param array|string $to_convert Value to test. + * @param string $expected Expected result. + */ + public function test_should_get_font_slug( $to_convert, $expected ) { + $this->assertSame( $expected, WP_Webfonts::get_font_slug( $to_convert ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_get_font_slug() { + return array( + 'font family: single word' => array( + 'to_convert' => 'Merriweather', + 'expected' => 'merriweather', + ), + 'variation: single word font-family' => array( + 'to_convert' => array( + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + 'expected' => 'merriweather', + ), + 'font family: multiword' => array( + 'to_convert' => 'Source Sans Pro', + 'expected' => 'source-sans-pro', + ), + 'variation: multiword font-family' => array( + 'to_convert' => array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + ), + 'expected' => 'source-serif-pro', + ), + 'font family: delimited by hyphens' => array( + 'to_convert' => 'source-serif-pro', + 'expected' => 'source-serif-pro', + ), + 'variation: font-family delimited by hyphens' => array( + 'to_convert' => array( + 'font-family' => 'source-serif-pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + ), + 'expected' => 'source-serif-pro', + ), + 'font family: delimited by underscore' => array( + 'to_convert' => 'source_serif_pro', + 'expected' => 'source_serif_pro', + ), + 'variation: font family delimited by underscore' => array( + 'to_convert' => array( + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-family' => 'Source_Serif_Pro', + ), + 'expected' => 'source_serif_pro', + ), + 'font family: delimited by hyphens and underscore' => array( + 'to_convert' => 'my-custom_font_family', + 'expected' => 'my-custom_font_family', + ), + 'variation: font family delimited by hyphens and underscore' => array( + 'to_convert' => array( + 'font-weight' => '700', + 'font-family' => 'my-custom_font_family', + 'font-style' => 'italic', + ), + 'expected' => 'my-custom_font_family', + ), + 'font family: delimited mixture' => array( + 'to_convert' => 'My custom_font-family', + 'expected' => 'my-custom_font-family', + ), + 'variation: font family delimited mixture' => array( + 'to_convert' => array( + 'font-style' => 'italic', + 'font-family' => 'My custom_font-family', + 'font-weight' => '700', + ), + 'expected' => 'my-custom_font-family', + ), + ); + } + + /** + * @dataProvider data_should_not_get_font_slug + * + * @expectedDeprecated WP_Webfonts::get_font_slug + * + * @param array|string $to_convert Value to test. + */ + public function test_should_not_get_font_slug( $to_convert ) { + $this->assertFalse( WP_Webfonts::get_font_slug( $to_convert ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_not_get_font_slug() { + return array( + 'Empty string' => array( '' ), + 'Empty array' => array( array() ), + ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts.php b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts.php new file mode 100644 index 00000000000000..7a8c97a4d86471 --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts.php @@ -0,0 +1,32 @@ +<?php +/** + * Integration tests for WP_Webfonts::get_registered_webfonts(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers WP_Webfonts::get_registered_webfonts + */ +class Tests_Fonts_WpWebfonts_GetRegisteredWebfonts extends Fonts_BcLayer_TestCase { + + /** + * @dataProvider data_should_return_registered_webfonts + * + * @expectedDeprecated wp_webfonts + * @expectedDeprecated WP_Webfonts::get_registered_webfonts + * + * @param array $fonts Fonts to register. + * @param array $expected Expected result. + */ + public function test_should_return_registered_webfonts( array $fonts, array $expected ) { + wp_register_fonts( $fonts ); + + $this->assertSame( $expected, wp_webfonts()->get_registered_webfonts() ); + } +} diff --git a/phpunit/tests/fonts-api/bc-layer/wpWebfonts/registerWebfont.php b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/registerWebfont.php new file mode 100644 index 00000000000000..ccc4249ad4fcc6 --- /dev/null +++ b/phpunit/tests/fonts-api/bc-layer/wpWebfonts/registerWebfont.php @@ -0,0 +1,113 @@ +<?php +/** + * Integration tests for WP_Webfonts::register_webfont(). + * + * @package Gutenberg + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group fontsapi-bclayer + * @covers WP_Webfonts::register_webfont + */ +class Tests_Fonts_WpWebfonts_RegisterWebfont extends Fonts_BcLayer_TestCase { + + /** + * @expectedDeprecated wp_webfonts + * @expectedDeprecated WP_Webfonts::register_webfont + */ + public function test_should_bail_out() { + $webfont = array(); + $this->assertFalse( wp_webfonts()->register_webfont( $webfont ) ); + } + + /** + * @dataProvider data_should_register_webfont + * + * @expectedDeprecated wp_webfonts + * @expectedDeprecated WP_Webfonts::register_webfont + * + * @param array $input Font to register. + * @param string|false $expected Expected result. + */ + public function test_should_register_webfont( array $input, $expected ) { + $this->assertSame( $expected['register_webfont'], wp_webfonts()->register_webfont( ...$input ), 'Font-family handle should be returned' ); + $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Font should be registered' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_register_webfont() { + return array( + 'No font family or variation handles' => array( + 'input' => array( + array( + 'font-family' => 'Merriweather', + 'font-style' => 'italic', + 'font-weight' => '400', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + 'expected' => array( + 'register_webfont' => 'merriweather', + 'get_registered' => array( 'merriweather', 'merriweather-400-italic' ), + ), + ), + 'Has font family handle but no variation handles' => array( + 'input' => array( + array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro', + ), + 'expected' => array( + 'register_webfont' => 'source-serif-pro', + 'get_registered' => array( 'source-serif-pro', 'source-serif-pro-200-900-normal' ), + ), + ), + 'No font family handle but has variation handle' => array( + 'input' => array( + array( + 'font-family' => 'Merriweather', + 'font-style' => 'italic', + 'font-weight' => '400', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + '', + 'merriweather-italic-400', + ), + 'expected' => array( + 'register_webfont' => 'merriweather', + 'get_registered' => array( 'merriweather', 'merriweather-italic-400' ), + ), + ), + 'Has font family and variation handles' => array( + 'input' => array( + array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + 'source-serif-pro', + 'source-serif-pro-variable-italic', + ), + 'expected' => array( + 'register_webfont' => 'source-serif-pro', + 'get_registered' => array( 'source-serif-pro', 'source-serif-pro-variable-italic' ), + ), + ), + ); + } +} diff --git a/phpunit/fonts-api/wp-fonts-tests-dataset.php b/phpunit/tests/fonts-api/wp-fonts-tests-dataset.php similarity index 100% rename from phpunit/fonts-api/wp-fonts-tests-dataset.php rename to phpunit/tests/fonts-api/wp-fonts-tests-dataset.php diff --git a/phpunit/tests/fonts-api/wpDeregisterFontFamily.php b/phpunit/tests/fonts-api/wpDeregisterFontFamily.php new file mode 100644 index 00000000000000..59db2e2abc8264 --- /dev/null +++ b/phpunit/tests/fonts-api/wpDeregisterFontFamily.php @@ -0,0 +1,74 @@ +<?php +/** + * Unit and integration tests for wp_deregister_font_family(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @group remove_fonts + * @covers ::wp_deregister_font_family + * @covers WP_Fonts::remove_font_family + */ +class Tests_Webfonts_WpDeregisterFontFamily extends WP_Fonts_TestCase { + + /** + * Unit test for registering a font-family that mocks WP_Fonts. + * + * @dataProvider data_font_family_handles + * + * @param string $font_family_handle Font family handle to test. + */ + public function test_unit_should_deregister( $font_family_handle ) { + $mock = $this->set_up_mock( 'remove_font_family' ); + $mock->expects( $this->once() ) + ->method( 'remove_font_family' ) + ->with( + $this->identicalTo( $font_family_handle ) + ); + + wp_deregister_font_family( $font_family_handle ); + } + + /** + * Integration test for enqueuing before registering a font family and all of its variations. + * + * @dataProvider data_font_family_handles + * + * @param string $font_family Font family to test. + */ + public function test_should_deregister_before_registration( $font_family ) { + wp_deregister_font_family( $font_family ); + + $this->assertIsArray( $this->get_registered(), 'Registration queue should be an array' ); + $this->assertEmpty( $this->get_registered(), 'Registration queue should be empty after deregistering' ); + } + + /** + * Integration test for deregistering a font family and all of its variations. + * + * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations + * + * @param string $font_family Font family to test. + * @param array $inputs Font family(ies) and variations to pre-register. + * @param array $registered_handles Expected handles after registering. + * @param array $expected Array of expected handles. + */ + public function test_deregister_after_registration( $font_family, array $inputs, array $registered_handles, array $expected ) { + foreach ( $inputs as $handle => $variations ) { + $this->setup_register( $handle, $variations ); + } + // Test the before state, just to make sure. + $this->assertSame( $registered_handles, $this->get_registered_handles(), 'Font family and variations should be registered before deregistering' ); + + wp_deregister_font_family( $font_family ); + + // Test after deregistering. + $this->assertIsArray( $this->get_registered_handles(), 'Registration queue should be an array' ); + $this->assertSame( $expected, $this->get_registered_handles(), 'Registration queue should match after deregistering' ); + } +} diff --git a/phpunit/tests/fonts-api/wpDeregisterFontVariation.php b/phpunit/tests/fonts-api/wpDeregisterFontVariation.php new file mode 100644 index 00000000000000..78b4bf51f758af --- /dev/null +++ b/phpunit/tests/fonts-api/wpDeregisterFontVariation.php @@ -0,0 +1,298 @@ +<?php +/** + * Unit and integration tests for wp_deregister_font_variation(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @group remove_fonts + * @covers ::wp_deregister_font_variation + * @covers WP_Webfonts::remove_variation + */ +class Tests_Fonts_WpDeregisterFontVariation extends WP_Fonts_TestCase { + private $wp_fonts; + private $fonts_to_register = array(); + + public function set_up() { + parent::set_up(); + $this->wp_fonts = wp_fonts(); + $this->fonts_to_register = $this->get_registered_local_fonts(); + } + + /** + * Sets up the unit test by mocking the WP_Dependencies object using stdClass and + * registering each font family directly to the WP_Webfonts::$registered property + * and its variations to the mocked $deps property. + */ + private function setup_unit_test() { + $this->setup_registration_mocks( $this->fonts_to_register, $this->wp_fonts ); + } + + /** + * Sets up the integration test by properly registering each font family and its variations + * by using the WP_Webfonts::add() and WP_Webfonts::add_variation() methods. + */ + private function setup_integration_test() { + foreach ( $this->fonts_to_register as $font_family_handle => $variations ) { + $this->setup_register( $font_family_handle, $variations, $this->wp_fonts ); + } + } + + /** + * Testing the test setup to ensure it works. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + */ + public function test_mocked_setup( $font_family_handle, $variation_handle ) { + $this->setup_unit_test(); + + $this->assertArrayHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be in the registered queue before removal' ); + $this->assertContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should be in its font family deps before removal' ); + } + + /** + * Unit test for deregistering a font-family's variation using mock of WP_Webfonts. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family to test. + * @param string $variation_handle Variation's handle to test. + */ + public function test_should_deregister_when_mocked( $font_family_handle, $variation_handle ) { + $mock = $this->set_up_mock( 'remove_variation' ); + $mock->expects( $this->once() ) + ->method( 'remove_variation' ) + ->with( + $this->identicalTo( $font_family_handle, $variation_handle ) + ); + + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + } + + /** + * Unit test. + * + * @dataProvider data_should_do_nothing + * + * @param string $font_family Font family name. + * @param string $font_family_handle Font family handle. + * @param string $variation_handle Variation handle to remove. + */ + public function test_unit_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { + // Set up the test. + unset( $this->fonts_to_register[ $font_family ] ); + $this->setup_unit_test(); + $registered_queue = $this->wp_fonts->registered; + + // Run the tests. + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertSame( $registered_queue, $this->wp_fonts->registered, 'Registered queue should not have changed' ); + } + + /** + * Integration test. + * + * @dataProvider data_should_do_nothing + * + * @param string $font_family Font family name. + * @param string $font_family_handle Font family handle. + * @param string $variation_handle Variation handle to remove. + */ + public function test_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { + // Set up the test. + unset( $this->fonts_to_register[ $font_family ] ); + $this->setup_integration_test(); + $registered_queue = $this->wp_fonts->get_registered(); + + // Run the tests. + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertSameSets( $registered_queue, $this->wp_fonts->get_registered(), 'Registered queue should not have changed' ); + } + + /** + * Data provider for testing removal of variations. + * + * @return array + */ + public function data_should_do_nothing() { + return array( + 'Font with 1 variation' => array( + 'font_family' => 'merriweather', + 'font_family_handle' => 'merriweather', + 'variation_handle' => 'merriweather-200-900-normal', + ), + 'Font with multiple variations' => array( + 'font_family' => 'Source Serif Pro', + 'font_family_handle' => 'source-serif-pro', + 'variation_handle' => 'Source Serif Pro-300-normal', + ), + ); + } + + /** + * Unit test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_unit_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_unit_test(); + $this->setup_remove_variation_from_registered( $variation_handle ); + + // Run the tests. + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Integration test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_integration_test(); + $this->setup_remove_variation_from_registered( $variation_handle ); + + // Run the tests. + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Unit test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_unit_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_unit_test(); + $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); + + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Integration test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_integration_test(); + $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); + + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Unit test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_unit_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_unit_test(); + + $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); + + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Integration test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_integration_test(); + + $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); + + wp_deregister_font_variation( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Remove the variation handle from the font family's deps. + * + * @param string $font_family_handle Font family. + * @param string $variation_handle The variation handle to remove. + */ + private function setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ) { + foreach ( $this->wp_fonts->registered[ $font_family_handle ]->deps as $index => $vhandle ) { + if ( $variation_handle !== $vhandle ) { + continue; + } + unset( $this->wp_fonts->registered[ $font_family_handle ]->deps[ $index ] ); + break; + } + } + + /** + * Removes the variation from the WP_Webfonts::$registered queue. + * + * @param string $variation_handle The variation handle to remove. + */ + private function setup_remove_variation_from_registered( $variation_handle ) { + unset( $this->wp_fonts->registered[ $variation_handle ] ); + } +} diff --git a/phpunit/tests/fonts-api/wpEnqueueFontVariations.php b/phpunit/tests/fonts-api/wpEnqueueFontVariations.php new file mode 100644 index 00000000000000..6e026d99f58897 --- /dev/null +++ b/phpunit/tests/fonts-api/wpEnqueueFontVariations.php @@ -0,0 +1,82 @@ +<?php +/** + * Unit and integration tests for wp_enqueue_font_variations(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @covers ::wp_enqueue_font_variations + * @covers WP_Webfonts::enqueue + */ +class Tests_Fonts_WpEnqueueFontVariations extends WP_Fonts_TestCase { + + /** + * Unit test for registering one or more specific variations that mocks WP_Webfonts. + * + * @dataProvider data_variation_handles + * + * @param string|string[] $handles Variation handles to test. + */ + public function test_unit_should_enqueue( $handles ) { + $mock = $this->set_up_mock( 'enqueue' ); + $mock->expects( $this->once() ) + ->method( 'enqueue' ) + ->with( + $this->identicalTo( $handles ) + ); + + wp_enqueue_font_variations( $handles ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_variation_handles() { + return array( + '1 variation handle' => array( 'merriweather-200-900-normal' ), + 'multiple same font family handles' => array( array( 'Source Serif Pro-300-normal', 'Source Serif Pro-900-italic' ) ), + 'handles from different font families' => array( array( 'merriweather-200-900-normal', 'Source Serif Pro-900-italic' ) ), + ); + } + + /** + * Integration test for enqueuing one or more specific variations. + * + * @dataProvider data_enqueue_variations + * + * @param string|string[] $handles Variation handles to test. + * @param array $expected Expected queue. + */ + public function test_should_enqueue_after_registration( $handles, array $expected ) { + foreach ( $this->get_data_registry() as $handle => $variations ) { + $this->setup_register( $handle, $variations ); + } + + wp_enqueue_font_variations( $handles ); + $this->assertEmpty( $this->get_queued_before_register(), '"queued_before_register" queue should be empty' ); + $this->assertSame( $expected, $this->get_enqueued_handles(), 'Queue should contain the given handles' ); + } + + /** + * Integration test for enqueuing before registering one or more specific variations. + * + * @dataProvider data_enqueue_variations + * + * @param string|string[] $handles Variation handles to test. + * @param array $not_used Not used. + * @param array $expected Expected "queued_before_register" queue. + */ + public function test_should_enqueue_before_registration( $handles, array $not_used, array $expected ) { + wp_enqueue_font_variations( $handles ); + + $this->assertSame( $expected, $this->get_queued_before_register(), '"queued_before_register" queue should contain the given handles' ); + $this->assertEmpty( $this->get_enqueued_handles(), 'Queue should be empty' ); + } +} diff --git a/phpunit/tests/fonts-api/wpEnqueueFonts.php b/phpunit/tests/fonts-api/wpEnqueueFonts.php new file mode 100644 index 00000000000000..c00fb0f2dfdae5 --- /dev/null +++ b/phpunit/tests/fonts-api/wpEnqueueFonts.php @@ -0,0 +1,122 @@ +<?php +/** + * Unit and integration tests for wp_enqueue_fonts(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @covers ::wp_enqueue_fonts + * @covers WP_Fonts::enqueue + */ +class Tests_Fonts_WpEnqueueFonts extends WP_Fonts_TestCase { + + /** + * Unit test for registering a font-family that mocks WP_Fonts. + * + * @dataProvider data_should_enqueue + * + * @param string[] $font_families Font families to test. + * @param string[] $expected_handles Expected handles passed to WP_Fonts::enqueue(). + */ + public function test_unit_should_enqueue( $font_families, $expected_handles ) { + $mock = $this->set_up_mock( 'enqueue' ); + $mock->expects( $this->once() ) + ->method( 'enqueue' ) + ->with( + $this->identicalTo( $expected_handles ) + ); + + wp_enqueue_fonts( $font_families ); + } + + /** + * Integration test for enqueuing a font family and all of its variations. + * + * @dataProvider data_should_enqueue + * + * @param string[] $font_families Font families to test. + * @param string[] $expected_handles Expected handles passed to WP_Fonts::enqueue(). + */ + public function test_should_enqueue_after_registration( $font_families, $expected_handles ) { + // Register the font-families. + foreach ( $this->get_data_registry() as $handle => $variations ) { + $this->setup_register( $handle, $variations ); + } + + wp_enqueue_fonts( $font_families ); + + $this->assertEmpty( $this->get_queued_before_register(), '"queued_before_register" queue should be empty' ); + $this->assertSame( $expected_handles, $this->get_enqueued_handles(), 'Queue should contain the given font family(ies)' ); + } + + /** + * Integration test for enqueuing before registering a font family and all of its variations. + * + * @dataProvider data_should_enqueue + * + * @param string[] $font_families Font families to test. + * @param string[] $expected_handles Expected handles passed to WP_Fonts::enqueue(). + */ + public function test_should_enqueue_before_registration( $font_families, $expected_handles ) { + wp_enqueue_fonts( $font_families ); + + // Set up what "queued_before_register" queue should be. + $expected = array(); + foreach ( $expected_handles as $handle ) { + $expected[ $handle ] = null; + } + $this->assertSame( $expected, $this->get_queued_before_register(), '"queued_before_register" queue should contain the given font family(ies)' ); + $this->assertEmpty( $this->get_enqueued_handles(), 'Queue should be empty' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_enqueue() { + return array( + '1: single word handle' => array( + 'font_families' => array( 'lato' ), + 'expected_handles' => array( 'lato' ), + ), + '1: multiple word handle' => array( + 'font_families' => array( 'source-serif-pro' ), + 'expected_handles' => array( 'source-serif-pro' ), + ), + '1: single word name' => array( + 'font_families' => array( 'Merriweather' ), + 'expected_handles' => array( 'merriweather' ), + ), + '1: multiple word name' => array( + 'font_families' => array( 'My Font' ), + 'expected_handles' => array( 'my-font' ), + ), + '>1: single word handle' => array( + 'font_families' => array( 'lato', 'merriweather' ), + 'expected_handles' => array( 'lato', 'merriweather' ), + ), + '>1: multiple word handle' => array( + 'font_families' => array( 'source-serif-pro', 'my-font' ), + 'expected_handles' => array( 'source-serif-pro', 'my-font' ), + ), + '>1: single word name' => array( + 'font_families' => array( 'Lato', 'Merriweather' ), + 'expected_handles' => array( 'lato', 'merriweather' ), + ), + '>1: multiple word name' => array( + 'font_families' => array( 'My Font', 'Source Serif Pro' ), + 'expected_handles' => array( 'my-font', 'source-serif-pro' ), + ), + '>1: mixture of word handles and names' => array( + 'font_families' => array( 'Source Serif Pro', 'Merriweather', 'my-font', 'Lato' ), + 'expected_handles' => array( 'source-serif-pro', 'merriweather', 'my-font', 'lato' ), + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts.php b/phpunit/tests/fonts-api/wpFonts.php new file mode 100644 index 00000000000000..3b33e58edf2995 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts.php @@ -0,0 +1,38 @@ +<?php +/** + * Unit and integration tests for wp_fonts(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @covers ::wp_fonts + */ +class Tests_Fonts_WpFonts extends WP_Fonts_TestCase { + + public function test_returns_instance() { + $this->assertInstanceOf( WP_Fonts::class, wp_fonts() ); + } + + public function test_global_set() { + global $wp_fonts; + $this->assertNull( $wp_fonts ); + $instance = wp_fonts(); + $this->assertInstanceOf( WP_Fonts::class, $wp_fonts ); + $this->assertSame( $instance, $wp_fonts ); + } + + public function test_local_provider_is_automatically_registered() { + $expected = array( + 'local' => array( + 'class' => 'WP_Fonts_Provider_Local', + 'fonts' => array(), + ), + ); + $this->assertSame( $expected, wp_fonts()->get_providers() ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/add.php b/phpunit/tests/fonts-api/wpFonts/add.php new file mode 100644 index 00000000000000..f9449b87c53b53 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/add.php @@ -0,0 +1,45 @@ +<?php +/** + * WP_Fonts::add() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::add + */ +class Tests_Fonts_WpFonts_Add extends WP_Fonts_TestCase { + + /** + * @dataProvider data_handles + * + * @param string $handle Handle to register. + */ + public function test_add( $handle ) { + $wp_fonts = new WP_Fonts(); + + $this->assertTrue( $wp_fonts->add( $handle, false ), 'Registering a handle should return true' ); + $this->assertCount( 1, $wp_fonts->registered ); + $this->assertArrayHasKey( $handle, $wp_fonts->registered, 'Font family handle should be in the registry after registration' ); + + } + + /** + * Data provider. + * + * @return array + */ + public function data_handles() { + return array( + 'name: multiple' => array( 'Source Serif Pro' ), + 'handle: multiple' => array( 'source-serif-pro' ), + 'name: single' => array( 'Merriweather' ), + 'handle: single' => array( 'merriweather' ), + 'handle: variation' => array( 'my-custom-font-200-900-normal' ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/addFontFamily.php b/phpunit/tests/fonts-api/wpFonts/addFontFamily.php new file mode 100644 index 00000000000000..f227c2c4025a40 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/addFontFamily.php @@ -0,0 +1,66 @@ +<?php +/** + * WP_Fonts::add_font_family() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::add_font_family + */ +class Tests_Fonts_WpFonts_AddFontFamily extends WP_Fonts_TestCase { + + /** + * @dataProvider data_handles + * + * @param string $font_family Font family to register. + * @param string $expected Expected handle. + */ + public function test_should_register( $font_family, $expected ) { + $wp_fonts = new WP_Fonts(); + $font_family_handle = $wp_fonts->add_font_family( $font_family ); + + $this->assertSame( $expected, $font_family_handle, 'Registering a font-family should return its handle' ); + $this->assertCount( 1, $wp_fonts->registered ); + $this->assertArrayHasKey( $font_family_handle, $wp_fonts->registered, 'Font family handle should be in the registry after registration' ); + + } + + /** + * Data provider. + * + * @return array + */ + public function data_handles() { + return array( + 'name: multiple' => array( + 'font_family' => 'Source Serif Pro', + 'expected' => 'source-serif-pro', + ), + 'handle: multiple' => array( + 'font_family' => 'source-serif-pro', + 'expected' => 'source-serif-pro', + ), + 'name: single' => array( + 'font_family' => 'Merriweather', + 'expected' => 'merriweather', + ), + 'handle: single' => array( + 'font_family' => 'merriweather', + 'expected' => 'merriweather', + ), + 'handle: variation' => array( + 'font_family' => 'my-custom-font-200-900-normal', + 'expected' => 'my-custom-font-200-900-normal', + ), + 'name: multiple font-families' => array( + 'font_family' => 'Source Serif Pro, Merriweather', + 'expected' => 'source-serif-pro', + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/addVariation.php b/phpunit/tests/fonts-api/wpFonts/addVariation.php new file mode 100644 index 00000000000000..5b6d93bcc99fe0 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/addVariation.php @@ -0,0 +1,149 @@ +<?php +/** + * WP_Fonts::add_variation() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::add_variation + */ +class Tests_Fonts_WpFonts_AddVariation extends WP_Fonts_TestCase { + + /** + * @dataProvider data_valid_variation + * + * @param string|bool $expected Expected results. + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + * @param string $variation_handle Optional. The variation's handle. + */ + public function test_should_register_variation_when_font_family_is_registered( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->add( $font_family_handle, false ); + + $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); + $this->assertSame( $expected, $variation_handle, 'Registering a variation should return its handle' ); + $this->assertArrayHasKey( $variation_handle, $wp_fonts->registered, 'Variation handle should be in the registry after registration' ); + $this->assertSame( array( $expected ), $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should be registered to font family' ); + } + + /** + * @dataProvider data_valid_variation + * + * @param string|bool $expected Expected results. + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + * @param string $variation_handle Optional. The variation's handle. + */ + public function test_should_not_reregister_font_family( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->add( $font_family_handle, false ); + + $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); + + // Font family should appear only once in the registered queue. + $expected = array( $font_family_handle, $variation_handle ); + $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Font family should not be re-registered after registering a variation' ); + } + + /** + * @dataProvider data_valid_variation + * + * @param string|bool $expected Expected results. + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + * @param string $variation_handle Optional. The variation's handle. + */ + public function test_should_not_reregister_variation( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->add( $font_family_handle, false ); + + // Set up the test. + $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); + + // Run the test. + $variant_handle_on_reregister = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); + $this->assertSame( $expected, $variant_handle_on_reregister, 'Variation should be registered to font family' ); + $this->assertSame( $variation_handle, $variant_handle_on_reregister, 'Variation should return the previously registered variant handle' ); + $this->assertSame( array( $variation_handle ), $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should only be registered once' ); + + $this->assertCount( 2, $wp_fonts->registered ); + $this->assertArrayHasKey( $variation_handle, $wp_fonts->registered, 'Variation handle should be in the registry after registration' ); + } + + /** + * @dataProvider data_valid_variation + * + * @param string|bool $expected Expected results. + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + * @param string $variation_handle Optional. The variation's handle. + */ + public function test_should_register_font_family_and_variation( $expected, $font_family_handle, array $variation, $variation_handle = '' ) { + $wp_fonts = new WP_Fonts(); + + $variation_handle = $wp_fonts->add_variation( $font_family_handle, $variation, $variation_handle ); + $this->assertSame( $expected, $variation_handle, 'Variation should return its registered handle' ); + + // Extra checks to ensure both are registered. + $this->assertCount( 2, $wp_fonts->registered ); + $this->assertArrayHasKey( $font_family_handle, $wp_fonts->registered, 'Font family handle should be in the registry after registration' ); + $this->assertArrayHasKey( $variation_handle, $wp_fonts->registered, 'Variation handle should be in the registry after registration' ); + $this->assertSame( array( $variation_handle ), $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should be registered to the font family' ); + } + + /** + * @dataProvider data_font_family_handle_undefined + * + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + */ + public function test_should_not_register_font_family_or_variant( $font_family_handle, array $variation ) { + $this->expectNotice(); + $this->expectNoticeMessage( 'Font family handle must be a non-empty string.' ); + + $wp_fonts = new WP_Fonts(); + $wp_fonts->add_variation( $font_family_handle, $variation ); + + $this->assertEmpty( $wp_fonts->registered, 'Registered queue should be empty' ); + $this->assertEmpty( $this->get_variations( $font_family_handle, $wp_fonts ), 'Variation should not be registered to the font family' ); + } + + /** + * @dataProvider data_font_family_undefined_in_variation + * @dataProviders data_unable_determine_variation_handle + * + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + * @param string $expected_message Expected notice message. + */ + public function test_should_not_register_variation_when_font_family_not_defined( $font_family_handle, array $variation, $expected_message ) { + $this->expectNotice(); + $this->expectNoticeMessage( $expected_message ); + + $wp_fonts = new WP_Fonts(); + $this->assertNull( $wp_fonts->add_variation( $font_family_handle, $variation ) ); + } + + /** + * @dataProvider data_unable_determine_variation_handle + * + * @param string $font_family_handle The font family's handle for this variation. + * @param array $variation An array of variation properties to add. + */ + public function test_should_register_font_family_when_variant_fails_to_register( $font_family_handle, array $variation ) { + $this->expectNotice(); + $this->expectNoticeMessage( 'Variant handle could not be determined as font-weight and/or font-style are require' ); + + $wp_fonts = new WP_Fonts(); + $wp_fonts->add_variation( $font_family_handle, $variation ); + + $this->assertCount( 1, $wp_fonts->registered ); + $this->assertArrayHasKey( $font_family_handle, $wp_fonts->registered ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/dequeue.php b/phpunit/tests/fonts-api/wpFonts/dequeue.php new file mode 100644 index 00000000000000..6cd11dc55b9376 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/dequeue.php @@ -0,0 +1,72 @@ +<?php +/** + * WP_Fonts::dequeue() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::dequeue + */ +class Tests_Fonts_WpFonts_Dequeue extends WP_Fonts_TestCase { + + /** + * @dataProvider data_enqueue + * @dataProvider data_enqueue_variations + * + * @param string|string[] $handles Handles to test. + */ + public function test_should_do_nothing_when_handles_not_queued( $handles ) { + $wp_fonts = new WP_Fonts(); + + $wp_fonts->dequeue( $handles ); + $this->assertEmpty( $this->get_queued_before_register( $wp_fonts ), 'Prequeue should be empty' ); + $this->assertEmpty( $wp_fonts->queue, 'Queue should be empty' ); + } + + /** + * Integration test for dequeuing from queue. It first registers and then enqueues before dequeuing. + * + * @dataProvider data_enqueue + * @dataProvider data_enqueue_variations + * + * @param string|string[] $handles Handles to test. + */ + public function test_should_dequeue_from_queue( $handles ) { + $wp_fonts = new WP_Fonts(); + + // Register and enqueue. + foreach ( $this->get_data_registry() as $handle => $variations ) { + $this->setup_register( $handle, $variations, $wp_fonts ); + } + $wp_fonts->enqueue( $handles ); + + // To make sure the handles are in the queue before dequeuing. + $this->assertNotEmpty( $wp_fonts->queue, 'Queue not be empty before dequeueing' ); + + // Run the test. + $wp_fonts->dequeue( $handles ); + $this->assertEmpty( $wp_fonts->queue, 'Queue should be empty after dequeueing' ); + } + + /** + * Integration test for dequeuing from prequeue. It enqueues first. + * + * @dataProvider data_enqueue + * @dataProvider data_enqueue_variations + * + * @param string|string[] $handles Handles to test. + */ + public function test_should_dequeue_from_prequeue( $handles ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->enqueue( $handles ); + $this->assertNotEmpty( $this->get_queued_before_register( $wp_fonts ), 'Prequeue not be empty before dequeueing' ); + + $wp_fonts->dequeue( $handles ); + $this->assertEmpty( $this->get_queued_before_register( $wp_fonts ), 'Prequeue should be empty after dequeueing' ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/doItem.php b/phpunit/tests/fonts-api/wpFonts/doItem.php new file mode 100644 index 00000000000000..af46f376b3bc5c --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/doItem.php @@ -0,0 +1,336 @@ +<?php +/** + * WP_Fonts::do_item() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group printfonts + * @covers WP_Fonts::do_item + */ +class Tests_Fonts_WpFonts_DoItem extends WP_Fonts_TestCase { + private $wp_fonts; + + public function set_up() { + parent::set_up(); + $this->wp_fonts = new WP_Fonts; + } + + public function test_should_return_false_when_provider_not_registered() { + $this->assertFalse( $this->wp_fonts->do_item( 'provider_not_registered' ) ); + } + + /** + * @dataProvider data_provider_definitions + * + * @param array $provider Provider to mock. + */ + public function test_should_return_false_when_no_fonts_enqueued_for_provider( array $provider ) { + $this->setup_provider_property_mock( $this->wp_fonts, $provider ); + $this->assertFalse( $this->wp_fonts->do_item( $provider['id'] ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_definitions() { + $providers = $this->get_provider_definitions(); + + return array( + 'mock' => array( $providers['mock'] ), + 'local' => array( $providers['local'] ), + ); + } + + /** + * Test the test set up to ensure the `Tests_Fonts_WpFonts_DoItem_::setup_provider_property_mock()` + * method works as expected. + */ + public function test_mocking_providers_property() { + $font_handles = array( 'font1', 'font2', 'font3' ); + $expected = array( + 'mock' => array( + 'class' => Mock_Provider::class, + 'fonts' => $font_handles, + ), + ); + + $this->setup_provider_property_mock( $this->wp_fonts, $this->get_provider_definitions( 'mock' ), $font_handles ); + $actual = $this->property['WP_Fonts::$providers']->getValue( $this->wp_fonts ); + $this->assertSame( $expected, $actual ); + } + + /** + * Test the private method WP_Fonts::get_enqueued_fonts_for_provider(). + * + * Why? This test validates the right fonts are returned for use within + * WP_Fonts::do_item(). + * + * @dataProvider data_get_enqueued_fonts_for_provider + * + * @param array $font_handles Array of handles for the provider. + * @param array $to_do Handles to set for the WP_Fonts::$to_do property. + * @param array $expected Expected result. + */ + public function test_get_enqueued_fonts_for_provider( $font_handles, $to_do, $expected ) { + // Set up the `to_do` property. + $this->wp_fonts->to_do = $to_do; + + // Open the method's visibility for testing. + $get_enqueued_fonts_for_provider = $this->get_reflection_method( 'get_enqueued_fonts_for_provider' ); + + // Mock the WP_Fonts::$property to set up the test. + $this->setup_provider_property_mock( $this->wp_fonts, $this->get_provider_definitions( 'mock' ), $font_handles ); + + $actual = $get_enqueued_fonts_for_provider->invoke( $this->wp_fonts, 'mock' ); + $this->assertSameSets( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_enqueued_fonts_for_provider() { + return array( + 'to_do queue is empty' => array( + 'font_handles ' => array( 'font1', 'font2', 'font3' ), + 'to_do' => array(), + 'expected' => array(), + ), + 'fonts not in to_do queue' => array( + 'font_handles ' => array( 'font1', 'font2', 'font3' ), + 'to_do' => array( 'font12', 'font13' ), + 'expected' => array(), + ), + '2 of the provider fonts in to_do queue' => array( + 'font_handles ' => array( 'font11', 'font12', 'font13' ), + 'to_do' => array( 'font11', 'font13' ), + 'expected' => array( 'font11', 'font13' ), + ), + 'do all of the provider fonts' => array( + 'font_handles ' => array( 'font21', 'font22', 'font23' ), + 'to_do' => array( 'font21', 'font22', 'font23' ), + 'expected' => array( 'font21', 'font22', 'font23' ), + ), + ); + } + + /** + * Test the private method WP_Fonts::get_font_properties_for_provider(). + * + * Why? This test validates the right font properties are returned for use within + * WP_Fonts::do_item(). + * + * @dataProvider data_get_font_properties_for_provider + * + * @param array $font_handles Web fonts for testing. + * @param array $expected Expected result. + */ + public function test_get_font_properties_for_provider( $font_handles, $expected ) { + // Set up the fonts for WP_Dependencies:get_data(). + $fonts = $this->get_registered_fonts(); + // Set all variations to 'mock' provider. + + // Mock the WP_Fonts::$property to set up the test. + $this->setup_provider_property_mock( $this->wp_fonts, $this->get_provider_definitions( 'mock' ), $font_handles ); + $this->setup_registration_mocks( $fonts, $this->wp_fonts ); + + // Open the method's visibility for testing. + $method = $this->get_reflection_method( 'get_font_properties_for_provider' ); + + $actual = $method->invoke( $this->wp_fonts, $font_handles ); + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_font_properties_for_provider() { + $fonts = $this->get_registered_fonts(); + + return array( + 'handles not registered' => array( + 'font_handles' => array( 'font-not-registered1', 'font-not-registered2', 'font-not-registered3' ), + 'expected' => array(), + ), + 'registered and non-registered handles' => array( + 'font_handles' => array( 'Source Serif Pro-300-normal', 'not-registered-handle', 'Source Serif Pro-900-italic' ), + 'expected' => array( + 'Source Serif Pro-300-normal' => $fonts['Source Serif Pro']['Source Serif Pro-300-normal'], + 'Source Serif Pro-900-italic' => $fonts['Source Serif Pro']['Source Serif Pro-900-italic'], + ), + ), + 'font-family handles, ie no "font-properties" extra data' => array( + 'font_handles' => array( 'font1', 'font2', 'merriweather' ), + 'expected' => array(), + ), + ); + } + + /** + * @dataProvider data_print_enqueued_fonts + * + * @param array $provider Define provider. + * @param array $fonts Fonts to register and enqueue. + * @param array $expected Expected results. + */ + public function test_should_trigger_provider_when_mocked( array $provider, array $fonts, array $expected ) { + $this->setup_print_deps( $provider, $fonts ); + + $provider_mock = $this->setup_object_mock( array( 'set_fonts', 'print_styles' ), $provider['class'] ); + + // Test the provider's methods are invoked. + $provider_mock->expects( $this->once() )->method( 'set_fonts' )->with( $this->identicalTo( $expected['set_fonts'] ) ); + $provider_mock->expects( $this->once() )->method( 'print_styles' ); + + // Set up the WP_Fonts::$provider_instances property. + $provider_instances = $this->get_reflection_property( 'provider_instances' ); + $provider_instances->setValue( $this->wp_fonts, array( $provider['id'] => $provider_mock ) ); + + // Test the method successfully processes the provider. + $this->expectOutputString( '' ); + $this->assertTrue( $this->wp_fonts->do_item( $provider['id'] ), 'WP_Fonts::do_item() should return true' ); + } + + /** + * Integration test. + * + * @dataProvider data_print_enqueued_fonts + * + * @param array $provider Define provider. + * @param array $fonts Fonts to register and enqueue. + * @param array $expected Expected results. + */ + public function test_should_print( array $provider, array $fonts, array $expected ) { + $this->setup_print_deps( $provider, $fonts ); + + // Test the method successfully processes the provider. + $this->expectOutputString( $expected['printed_output'] ); + $this->assertTrue( $this->wp_fonts->do_item( $provider['id'] ), 'WP_Fonts::do_item() should return true' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_print_enqueued_fonts() { + $mock = $this->get_registered_mock_fonts(); + $local = $this->get_registered_local_fonts(); + $font_faces = $this->get_registered_fonts_css(); + + return array( + 'mock' => array( + 'provider' => $this->get_provider_definitions( 'mock' ), + 'fonts' => $mock, + 'expected' => array( + 'set_fonts' => array_merge( $mock['font1'], $mock['font2'], $mock['font3'] ), + 'printed_output' => sprintf( + '<mock id="wp-fonts-mock" attr="some-attr">%s; %s; %s; %s; %s; %s</mock>\n', + $font_faces['font1-300-normal'], + $font_faces['font1-300-italic'], + $font_faces['font1-900-normal'], + $font_faces['font2-200-900-normal'], + $font_faces['font2-200-900-italic'], + $font_faces['font3-bold-normal'] + ), + ), + ), + 'local' => array( + 'provider' => $this->get_provider_definitions( 'local' ), + 'fonts' => $local, + 'expected' => array( + 'set_fonts' => array_merge( $local['merriweather'], $local['Source Serif Pro'] ), + 'printed_output' => sprintf( + "<style id='wp-fonts-local' type='text/css'>\n%s%s%s\n</style>\n", + $font_faces['merriweather-200-900-normal'], + $font_faces['Source Serif Pro-300-normal'], + $font_faces['Source Serif Pro-900-italic'] + ), + ), + ), + ); + } + + /** + * Integration test. + * + * @dataProvider data_not_print_enqueued_fonts + * + * @param array $provider Define provider. + * @param array $fonts Fonts to register and enqueue. + * @param array $expected Not used. + * @param array $to_do_queue Value to set in the WP_Fonts::$to_do queue. + */ + public function test_should_not_print_when_to_do_queue_empty( array $provider, array $fonts, $expected, $to_do_queue ) { + $this->setup_print_deps( $provider, $fonts, $to_do_queue ); + + // Test the method successfully processes the provider. + $this->expectOutputString( '' ); + $this->assertFalse( $this->wp_fonts->do_item( $provider['id'] ), 'WP_Fonts::do_item() should return false' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_not_print_enqueued_fonts() { + $mock = $this->get_registered_mock_fonts(); + $local = $this->get_registered_local_fonts(); + + return array( + 'mock provider when to_do queue is empty' => array( + 'provider' => $this->get_provider_definitions( 'mock' ), + 'fonts' => $mock, + 'expected' => array(), + 'to_do_queue' => array(), + ), + 'local provider when to_do queue is empty' => array( + 'provider' => $this->get_provider_definitions( 'local' ), + 'fonts' => $local, + 'expected' => array(), + 'to_do_queue' => array(), + ), + 'fonts not in to_do queue' => array( + 'provider' => $this->get_provider_definitions( 'mock' ), + 'fonts' => $mock, + 'expected' => array(), + 'to_do_queue' => array(), + ), + ); + } + + /** + * Sets up the print dependencies. + * + * @param array $provider Provider id and class. + * @param array $fonts Fonts to register and enqueue. + * @param array|null $to_do_queue Set the WP_Fonts:$to_do queue. + */ + private function setup_print_deps( $provider, $fonts, $to_do_queue = null ) { + // Set up the fonts for WP_Dependencies:get_data(). + $mocks = $this->setup_registration_mocks( $fonts, $this->wp_fonts ); + $handles = array_keys( $mocks ); + $this->setup_provider_property_mock( $this->wp_fonts, $provider, $handles ); + + // Set up the `WP_Fonts::$to_do` and `WP_Fonts::$to_do_keyed_handles` properties. + if ( null === $to_do_queue ) { + $to_do_queue = $handles; + } + + $this->wp_fonts->to_do = $to_do_queue; + $to_do_keyed_handles = $this->get_reflection_property( 'to_do_keyed_handles' ); + $to_do_keyed_handles->setValue( $this->wp_fonts, array_flip( $to_do_queue ) ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/doItems.php b/phpunit/tests/fonts-api/wpFonts/doItems.php new file mode 100644 index 00000000000000..23307366101cb0 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/doItems.php @@ -0,0 +1,196 @@ +<?php +/** + * WP_Fonts::do_items() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; +require_once GUTENBERG_DIR_TESTFIXTURES . '/mock-provider.php'; + +/** + * @group fontsapi + * @group printfonts + * @covers WP_Fonts::do_items + */ +class Tests_Fonts_WpFonts_DoItems extends WP_Fonts_TestCase { + private $wp_fonts; + + public function set_up() { + parent::set_up(); + $this->wp_fonts = new WP_Fonts; + } + + public function test_should_not_process_when_no_providers_registered() { + $this->setup_deps( array( 'enqueued' => 'font1' ) ); + + $done = $this->wp_fonts->do_items(); + + $this->assertSame( array(), $done, 'WP_Fonts::do_items() should return an empty array' ); + $this->assertSame( array(), $this->wp_fonts->to_do, 'WP_Fonts::$to_do should be an empty array' ); + } + + /** + * @dataProvider data_invalid_handles + * + * @param mixed $handles Handles to test. + */ + public function test_should_throw_notice_when_invalid_handles( $handles ) { + $this->expectNotice(); + $this->expectNoticeMessage( 'Handles must be a non-empty string or array of non-empty strings' ); + + $this->wp_fonts->do_items( $handles ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_invalid_handles() { + return array( + 'null' => array( null ), + 'empty array' => array( array() ), + 'empty string' => array( '' ), + 'array of empty strings' => array( array( '', '' ) ), + 'array of mixed falsey values' => array( array( '', false, null, array() ) ), + ); + } + + public function test_should_throw_notice_when_provider_class_not_found() { + $this->expectNotice(); + $this->expectNoticeMessage( 'Class "Provider_Does_Not_Exist" not found for "doesnotexist" font provider' ); + + $setup = array( + 'provider' => array( + 'doesnotexist' => array( + 'id' => 'doesnotexist', + 'class' => 'Provider_Does_Not_Exist', + ), + ), + 'provider_handles' => array( 'doesnotexist' => array( 'font1' ) ), + 'registered' => array( + 'doesnotexist' => array( + 'font1' => array( + 'font1-300-normal' => array( + 'provider' => 'doesnotexist', + 'font-weight' => '300', + 'font-style' => 'normal', + 'font-display' => 'fallback', + ), + ), + ), + ), + 'enqueued' => array( 'font1', 'font1-300-normal' ), + ); + $this->setup_deps( $setup ); + + $this->wp_fonts->do_items(); + } + + /** + * @dataProvider data_print_enqueued + * + * @param array $setup Test set up information for provider, fonts, and enqueued. + * @param array $expected_done Expected array of printed handles. + * @param string $expected_output Expected printed output. + */ + public function test_should_print_mocked_enqueued( $setup, $expected_done, $expected_output ) { + $this->setup_deps( $setup ); + + $this->expectOutputString( $expected_output ); + $actual_done = $this->wp_fonts->do_items(); + $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); + } + + /** + * Integration test that registers providers and fonts and then enqueues before + * testing the printing functionality. + * + * @dataProvider data_print_enqueued + * + * @param array $setup Test set up information for provider, fonts, and enqueued. + * @param array $expected_done Expected array of printed handles. + * @param string $expected_output Expected printed output. + */ + public function test_should_print_enqueued( $setup, $expected_done, $expected_output ) { + $this->setup_integrated_deps( $setup ); + + $this->expectOutputString( $expected_output, 'Printed @font-face styles should match' ); + $actual_done = $this->wp_fonts->do_items(); + $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); + } + + /** + * Integration test to validate printing given handles. Rather than mocking internal functionality, + * it registers providers and fonts but does not enqueue. + * + * @dataProvider data_print_enqueued + * + * @param array $setup Test set up information for provider, fonts, and enqueued. + * @param array $expected_done Expected array of printed handles. + * @param string $expected_output Expected printed output. + */ + public function test_should_print_handles_when_not_enqueued( $setup, $expected_done, $expected_output ) { + $this->setup_integrated_deps( $setup, false ); + // Do not enqueue. Instead, pass the handles to WP_Fonts::do_items(). + $handles = $setup['enqueued']; + $this->assertEmpty( $this->wp_fonts->queue, 'No fonts should be enqueued' ); + + $this->expectOutputString( $expected_output ); + $actual_done = $this->wp_fonts->do_items( $handles ); + $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); + } + + /** + * Sets up the dependencies for the mocked test. + * + * @param array $setup Dependencies to set up. + */ + private function setup_deps( array $setup ) { + $setup = array_merge( + array( + 'provider' => array(), + 'provider_handles' => array(), + 'registered' => array(), + 'enqueued' => array(), + ), + $setup + ); + + if ( ! empty( $setup['provider'] ) ) { + foreach ( $setup['provider'] as $provider_id => $provider ) { + $this->setup_provider_property_mock( $this->wp_fonts, $provider, $setup['provider_handles'][ $provider_id ] ); + } + } + + if ( ! empty( $setup['registered'] ) ) { + $this->setup_registration_mocks( $setup['registered'], $this->wp_fonts ); + } + + if ( ! empty( $setup['enqueued'] ) ) { + $queue = $this->get_reflection_property( 'queue' ); + $queue->setValue( $this->wp_fonts, $setup['enqueued'] ); + } + } + + /** + * Sets up the dependencies for integration test. + * + * @param array $setup Dependencies to set up. + * @param bool $enqueue Whether to enqueue. Default true. + */ + private function setup_integrated_deps( array $setup, $enqueue = true ) { + foreach ( $setup['provider'] as $provider ) { + $this->wp_fonts->register_provider( $provider['id'], $provider['class'] ); + } + foreach ( $setup['registered'] as $handle => $variations ) { + $this->setup_register( $handle, $variations, $this->wp_fonts ); + } + + if ( $enqueue ) { + $this->wp_fonts->enqueue( $setup['enqueued'] ); + } + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/enqueue.php b/phpunit/tests/fonts-api/wpFonts/enqueue.php new file mode 100644 index 00000000000000..09c14676c7ae09 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/enqueue.php @@ -0,0 +1,53 @@ +<?php +/** + * WP_Fonts::enqueue() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::enqueue + */ +class Tests_Fonts_WpFonts_Enqueue extends WP_Fonts_TestCase { + + /** + * @dataProvider data_enqueue + * @dataProvider data_enqueue_variations + * + * @param string|string[] $handles Handles to test. + * @param array $not_used Not used. + * @param array $expected Expected "queued_before_register" queue. + */ + public function test_should_prequeue_when_not_registered( $handles, $not_used, $expected ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->enqueue( $handles ); + + $this->assertSame( $expected, $this->get_queued_before_register( $wp_fonts ), 'Handles should be added to before registered queue' ); + $this->assertEmpty( $wp_fonts->queue, 'Handles should not be added to the enqueue queue when not registered' ); + } + + /** + * Integration test for enqueuing (a) a font family and all of its variations or (b) specific variations. + * + * @dataProvider data_enqueue + * @dataProviders data_enqueue_variations + * + * @param string|string[] $handles Handles to test. + * @param array $expected Expected queue. + */ + public function test_should_enqueue_when_registered( $handles, array $expected ) { + $wp_fonts = new WP_Fonts(); + foreach ( $this->get_data_registry() as $font_family => $variations ) { + $this->setup_register( $font_family, $variations, $wp_fonts ); + } + + $wp_fonts->enqueue( $handles ); + + $this->assertEmpty( $this->get_queued_before_register( $wp_fonts ), '"queued_before_register" queue should be empty' ); + $this->assertSame( $expected, $wp_fonts->queue, 'Queue should contain the given handles' ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/getEnqueued.php b/phpunit/tests/fonts-api/wpFonts/getEnqueued.php new file mode 100644 index 00000000000000..bacb8a31aa6500 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/getEnqueued.php @@ -0,0 +1,57 @@ +<?php +/** + * WP_Fonts::get_enqueued() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::get_enqueued + */ +class Tests_Fonts_WpFonts_GetEnqueued extends WP_Fonts_TestCase { + + public function test_should_return_empty_when_non_enqueued() { + $wp_fonts = new WP_Fonts(); + $this->assertEmpty( $wp_fonts->get_enqueued() ); + } + + /** + * Unit test for when font families are enqueued. + * + * @dataProvider data_enqueue + * + * @param string|string[] $not_used Not used. + * @param array $expected Expected queue. + */ + public function test_should_return_queue_when_property_has_font_families( $not_used, array $expected ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->queue = $expected; + + $this->assertSame( $expected, $wp_fonts->get_enqueued() ); + } + + /** + * Full integration test that registers and enqueues the queue + * is properly wired for "get_enqueued()". + * + * @dataProvider data_enqueue + * + * @param string|string[] $font_family Font family to test. + * @param array $expected Expected queue. + */ + public function test_should_return_queue_when_font_families_registered_and_enqueued( $font_family, array $expected ) { + $wp_fonts = new WP_Fonts(); + + // Register and enqueue. + foreach ( $this->get_data_registry() as $handle => $variations ) { + $this->setup_register( $handle, $variations, $wp_fonts ); + } + $wp_fonts->enqueue( $font_family ); + + $this->assertSame( $expected, $wp_fonts->get_enqueued() ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/getProviders.php b/phpunit/tests/fonts-api/wpFonts/getProviders.php new file mode 100644 index 00000000000000..410adaace286aa --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/getProviders.php @@ -0,0 +1,62 @@ +<?php +/** + * WP_Fonts::get_providers() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; +require_once GUTENBERG_DIR_TESTFIXTURES . '/mock-provider.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::get_providers + */ +class Tests_Fonts_WpFonts_GetProviders extends WP_Fonts_TestCase { + private $wp_fonts; + private $providers_property; + + public function set_up() { + parent::set_up(); + $this->wp_fonts = new WP_Fonts(); + + $this->providers_property = new ReflectionProperty( WP_Fonts::class, 'providers' ); + $this->providers_property->setAccessible( true ); + } + + public function test_should_be_empty() { + $actual = $this->wp_fonts->get_providers(); + $this->assertIsArray( $actual, 'Should return an empty array' ); + $this->assertEmpty( $actual, 'Should return an empty array when no providers are registered' ); + } + + /** + * @dataProvider data_get_providers + * + * @param array $providers Array of providers to test. + * @param array $expected Expected results. + */ + public function test_get_providers( array $providers, array $expected ) { + $this->setup_providers( $providers ); + $this->assertSame( $expected, $this->wp_fonts->get_providers() ); + } + + /** + * Sets up the given providers and stores them in the `WP_Fonts::providers` property. + * + * @param array $providers Array of providers to set up. + */ + private function setup_providers( array $providers ) { + $data = array(); + + foreach ( $providers as $provider_id => $class ) { + $data[ $provider_id ] = array( + 'class' => $class, + 'fonts' => array(), + ); + } + + $this->providers_property->setValue( $this->wp_fonts, $data ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/getRegistered.php b/phpunit/tests/fonts-api/wpFonts/getRegistered.php new file mode 100644 index 00000000000000..7dc116f5875390 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/getRegistered.php @@ -0,0 +1,90 @@ +<?php +/** + * WP_Fonts::get_registered() tests. + * + * @package WordPress + * @subpackage Fonts API + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::get_registered + */ +class Tests_Fonts_WpFonts_GetRegistered extends WP_Fonts_TestCase { + + public function test_should_return_empty_when_none_registered() { + $wp_fonts = new WP_Fonts(); + $this->assertEmpty( $wp_fonts->get_registered() ); + } + + /** + * Unit test for when font families are enqueued. + * + * @dataProvider data_get_registered + * + * @param array $inputs Font family(ies) and variations to register. + */ + public function test_should_return_queue_when_mocking_registered_property( array $inputs ) { + $wp_fonts = new WP_Fonts(); + $mocks = $this->setup_registration_mocks( $inputs, $wp_fonts ); + $expected = array_keys( $mocks ); + + $this->assertSame( $expected, $wp_fonts->get_registered() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_registered() { + return array( + 'no variations' => array( + 'inputs' => array( + 'lato' => array(), + ), + ), + 'with 1 variation' => array( + 'inputs' => array( + 'Source Serif Pro' => array( 'variation-1' ), + ), + ), + 'with 2 variations' => array( + 'inputs' => array( + 'my-cool-font' => array( 'cool-1', 'cool-2' ), + ), + ), + 'when multiple font families registered' => array( + 'inputs' => array( + 'font-family-1' => array( 'variation-11', 'variation-12' ), + 'font-family-2' => array( 'variation-21', 'variation-22' ), + 'font-family-3' => array( 'variation-31', 'variation-32' ), + ), + ), + ); + } + + /** + * Full integration test that registers varying number of font families and variations + * to validate if "get_registered()" internals is property wired to the registered queue. + * + * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations + * + * @param string $font_family Not used. + * @param array $inputs Font family(ies) and variations to register. + * @param array $expected Expected results. + */ + public function test_should_return_queue_when_items_are_registered( $font_family, array $inputs, array $expected ) { + $wp_fonts = new WP_Fonts(); + + // Register before testing. + foreach ( $inputs as $handle => $variations ) { + $this->setup_register( $handle, $variations, $wp_fonts ); + } + + $this->assertSame( $expected, $wp_fonts->get_registered() ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/query.php b/phpunit/tests/fonts-api/wpFonts/query.php new file mode 100644 index 00000000000000..0e56b515e7ebf4 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/query.php @@ -0,0 +1,151 @@ +<?php +/** + * WP_Fonts::query() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::query + */ +class Tests_Fonts_WpFonts_Query extends WP_Fonts_TestCase { + private $wp_fonts; + + public function set_up() { + parent::set_up(); + + $this->wp_fonts = new WP_Fonts(); + } + + /** + * @dataProvider data_invalid_query + * @dataProvider data_valid_query + * + * @param string $query_handle Handle to test. + */ + public function test_should_fail_when_handles_not_registered( $query_handle ) { + $this->assertFalse( $this->wp_fonts->query( $query_handle, 'registered' ) ); + } + + /** + * @dataProvider data_invalid_query + * @dataProvider data_valid_query + * + * @param string $query_handle Handle to test. + */ + public function test_should_fail_when_handles_not_registered_or_enqueued( $query_handle ) { + $this->assertFalse( $this->wp_fonts->query( $query_handle, 'queue' ) ); + } + + /** + * @dataProvider data_valid_query + * + * @param string $query_handle Handle to test. + */ + public function test_registered_query_should_succeed_when_registered( $query_handle ) { + $this->setup_registry(); + + $actual = $this->wp_fonts->query( $query_handle, 'registered' ); + $this->assertInstanceOf( '_WP_Dependency', $actual, 'Query should return an instance of _WP_Dependency' ); + $this->assertSame( $query_handle, $actual->handle, 'Query object handle should match the given handle to query' ); + } + + /** + * @dataProvider data_valid_query + * + * @param string $query_handle Handle to test. + */ + public function test_enqueued_query_should_succeed_when_registered_and_enqueued( $query_handle ) { + $this->setup_registry(); + $this->wp_fonts->enqueue( $query_handle ); + + $this->assertTrue( $this->wp_fonts->query( $query_handle, 'enqueued' ) ); + } + + /** + * @dataProvider data_valid_query + * + * @param string $query_handle Handle to test. + */ + public function test_enqueued_query_should_fail_when_not_registered_but_enqueued( $query_handle ) { + $this->wp_fonts->enqueue( $query_handle ); + + $this->assertFalse( $this->wp_fonts->query( $query_handle, 'enqueued' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_invalid_query() { + return array( + 'DM Sans' => array( 'DM Sans' ), + 'roboto' => array( 'roboto' ), + 'my-font' => array( 'my-font' ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_valid_query() { + return array( + 'lato' => array( 'lato' ), + 'merriweather' => array( 'merriweather' ), + 'Source Serif Pro' => array( 'source-serif-pro' ), + ); + } + + public function test_done_query_should_fail_when_no_variations() { + $this->wp_fonts->register_provider( 'local', WP_Fonts_Provider_Local::class ); + $this->setup_registry(); + $this->wp_fonts->enqueue( 'lato' ); + + $this->wp_fonts->do_items( 'lato' ); + + $this->assertFalse( $this->wp_fonts->query( 'lato', 'done' ) ); + } + + /** + * @dataProvider data_done_query + * + * @param string $query_handle Handle to test. + */ + public function test_done_query_should_succeed_when_registered_and_enqueued( $query_handle ) { + $this->wp_fonts->register_provider( 'local', WP_Fonts_Provider_Local::class ); + $this->setup_registry(); + $this->wp_fonts->enqueue( $query_handle ); + + // Process the fonts while ignoring all the printed output. + $this->expectOutputRegex( '`.`' ); + $this->wp_fonts->do_items( $query_handle ); + $this->getActualOutput(); + + $this->assertTrue( $this->wp_fonts->query( $query_handle, 'done' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_done_query() { + return array( + 'merriweather' => array( 'merriweather' ), + 'Source Serif Pro' => array( 'source-serif-pro' ), + ); + } + + private function setup_registry() { + foreach ( $this->get_registered_local_fonts() as $handle => $variations ) { + $this->setup_register( $handle, $variations, $this->wp_fonts ); + } + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/registerProvider.php b/phpunit/tests/fonts-api/wpFonts/registerProvider.php new file mode 100644 index 00000000000000..09d3d7f3891720 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/registerProvider.php @@ -0,0 +1,116 @@ +<?php +/** + * WP_Fonts::register_provider() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; +require_once GUTENBERG_DIR_TESTFIXTURES . '/mock-provider.php'; + +/** + * @group fontsapi + * @covers WP_Fonts::register_provider + */ +class Tests_Fonts_WpFonts_RegisterProvider extends WP_Fonts_TestCase { + + /** + * @dataProvider data_register_providers + * + * @param string $provider_id Provider ID. + * @param string $class Provider class name. + * @param array $expected Expected providers queue. + */ + public function test_should_register_provider( $provider_id, $class, $expected ) { + $wp_fonts = new WP_Fonts(); + $this->assertTrue( $wp_fonts->register_provider( $provider_id, $class ), 'WP_Fonts::register_provider() should return true' ); + $this->assertSame( $expected, $wp_fonts->get_providers(), 'Provider "' . $provider_id . '" should be registered in providers queue' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_register_providers() { + return array( + 'mock' => array( + 'provider_id' => 'mock', + 'class' => Mock_Provider::class, + 'expected' => array( + 'mock' => array( + 'class' => Mock_Provider::class, + 'fonts' => array(), + ), + ), + ), + 'local' => array( + 'provider_id' => 'local', + 'class' => WP_Fonts_Provider_Local::class, + 'expected' => array( + 'local' => array( + 'class' => WP_Fonts_Provider_Local::class, + 'fonts' => array(), + ), + ), + ), + ); + } + + public function test_should_register_multiple_providers() { + $wp_fonts = new WP_Fonts(); + $providers = $this->get_provider_definitions(); + foreach ( $providers as $provider ) { + $this->assertTrue( $wp_fonts->register_provider( $provider['id'], $provider['class'] ), 'WP_Fonts::register_provider() should return true for provider ' . $provider['id'] ); + } + + $expected = array( + 'mock' => array( + 'class' => $providers['mock']['class'], + 'fonts' => array(), + ), + 'local' => array( + 'class' => $providers['local']['class'], + 'fonts' => array(), + ), + ); + + $this->assertSame( $expected, $wp_fonts->get_providers(), 'Both local and mock providers should be registered' ); + } + + /** + * @dataProvider data_invalid_providers + * + * @param string $provider_id Provider ID. + * @param string $class Provider class name. + */ + public function test_should_not_register( $provider_id, $class ) { + $wp_fonts = new WP_Fonts(); + + $this->assertFalse( $wp_fonts->register_provider( $provider_id, $class ), 'WP_Fonts::register_provider() should return false' ); + $this->assertArrayNotHasKey( $provider_id, $wp_fonts->get_providers(), 'Both local and mock providers should be registered' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_invalid_providers() { + return array( + 'provider_id is empty' => array( + 'provider_id' => '', + 'class' => Mock_Provider::class, + ), + 'class is empty' => array( + 'provider_id' => 'local', + 'class' => '', + ), + 'class does not exist' => array( + 'provider_id' => 'doesnotexist', + 'class' => 'Provider_Does_Not_Exist', + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/remove.php b/phpunit/tests/fonts-api/wpFonts/remove.php new file mode 100644 index 00000000000000..208bd58c3d2c30 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/remove.php @@ -0,0 +1,118 @@ +<?php +/** + * WP_Fonts::remove() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group remove_fonts + * @covers WP_Fonts::remove + */ +class Tests_Fonts_WpFonts_Remove extends WP_Fonts_TestCase { + + public function test_should_not_remove_when_none_registered() { + $wp_fonts = new WP_Fonts(); + + $wp_fonts->remove( array( 'handle-1', 'handle2' ) ); + + $this->assertEmpty( $wp_fonts->registered ); + } + + /** + * @dataProvider data_remove_when_registered + * + * @param array $handles Handles to remove. + * @param array $expected Expected handles are running test. + */ + public function test_should_remove_when_registered( array $handles, array $expected ) { + $wp_fonts = new WP_Fonts(); + $wp_fonts->registered = $this->generate_registered_queue(); + + $wp_fonts->remove( $handles ); + + $this->assertSameSets( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing handles' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_remove_when_registered() { + $all = array( + 'handle-1', + 'handle-2', + 'handle-3', + 'handle-4', + 'handle-5', + 'handle-6', + 'handle-7', + 'handle-8', + 'handle-9', + 'handle-10', + ); + + return array( + 'remove none' => array( + 'handles' => array(), + 'expected' => $all, + ), + 'remove handle-5' => array( + 'handles' => array( 'handle-5' ), + 'expected' => array( + 'handle-1', + 'handle-2', + 'handle-3', + 'handle-4', + 'handle-6', + 'handle-7', + 'handle-8', + 'handle-9', + 'handle-10', + ), + ), + 'remove 2 from start and end' => array( + 'handles' => array( 'handle-1', 'handle-2', 'handle-9', 'handle-10' ), + 'expected' => array( + 'handle-3', + 'handle-4', + 'handle-5', + 'handle-6', + 'handle-7', + 'handle-8', + ), + ), + 'remove all' => array( + 'handles' => $all, + 'expected' => array(), + ), + 'remove only registered' => array( + 'handles' => array( 'handle-1', 'handle-10', 'handle-abc', 'handle-5' ), + 'expected' => array( + 'handle-2', + 'handle-3', + 'handle-4', + 'handle-6', + 'handle-7', + 'handle-8', + 'handle-9', + ), + ), + ); + } + + private function generate_registered_queue() { + $queue = array(); + for ( $num = 1; $num <= 10; $num++ ) { + $handle = "handle-{$num}"; + $queue[ $handle ] = $num; + } + + return $queue; + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/removeFontFamily.php b/phpunit/tests/fonts-api/wpFonts/removeFontFamily.php new file mode 100644 index 00000000000000..6e762307d05207 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/removeFontFamily.php @@ -0,0 +1,89 @@ +<?php +/** + * WP_Fonts::remove_font_family() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group remove_fonts + * @covers WP_Fonts::remove_font_family + */ +class Tests_Fonts_WpFonts_RemoveFontFamily extends WP_Fonts_TestCase { + + /** + * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations + * + * @param string $font_family Font family to test. + * @param array $inputs Font family(ies) and variations to pre-register. + * @param array $registered_handles Expected handles after registering. + * @param array $expected Array of expected handles. + */ + public function test_should_dequeue_when_mocks_registered( $font_family, array $inputs, array $registered_handles, array $expected ) { + $wp_fonts = new WP_Fonts(); + $this->setup_registration_mocks( $inputs, $wp_fonts ); + // Test the before state, just to make sure. + $this->assertArrayHasKey( $font_family, $wp_fonts->registered, 'Registered queue should contain the font family before remove' ); + $this->assertSame( $registered_handles, array_keys( $wp_fonts->registered ), 'Font family and variations should be registered before remove' ); + + $wp_fonts->remove_font_family( $font_family ); + + $this->assertArrayNotHasKey( $font_family, $wp_fonts->registered, 'Registered queue should not contain the font family' ); + $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing font family' ); + } + + /** + * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations + * + * @param string $font_family Font family to test. + * @param array $inputs Font family(ies) and variations to pre-register. + * @param array $registered_handles Not used. + * @param array $expected Array of expected handles. + */ + public function test_should_bail_out_when_not_registered( $font_family, array $inputs, array $registered_handles, array $expected ) { + $wp_fonts = new WP_Fonts(); + unset( $inputs[ $font_family ] ); + $this->setup_registration_mocks( $inputs, $wp_fonts ); + + $wp_fonts->remove_font_family( $font_family ); + + $this->assertArrayNotHasKey( $font_family, $wp_fonts->registered, 'Registered queue should not contain the font family' ); + $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing font family' ); + } + + /** + * Integration test for removing a font family and all of its variation when font family is registered. + * + * @dataProvider data_one_to_many_font_families_and_zero_to_many_variations + * + * @param string $font_family Font family to test. + * @param array $inputs Font family(ies) and variations to pre-register. + * @param array $registered_handles Expected handles after registering. + * @param array $expected Array of expected handles. + */ + public function test_should_deregister_when_registered( $font_family, array $inputs, array $registered_handles, array $expected ) { + $wp_fonts = new WP_Fonts(); + // Register all font families and their variations. + foreach ( $inputs as $input_font_family => $variations ) { + $handle = $wp_fonts->add_font_family( $input_font_family ); + foreach ( $variations as $variation_handle => $variation ) { + if ( ! is_string( $variation_handle ) ) { + $variation_handle = ''; + } + $wp_fonts->add_variation( $handle, $variation, $variation_handle ); + } + } + // Test the before state, just to make sure. + $this->assertArrayHasKey( $font_family, $wp_fonts->registered, 'Registered queue should contain the font family before remove' ); + $this->assertSame( $registered_handles, array_keys( $wp_fonts->registered ), 'Font family and variations should be registered before remove' ); + + $wp_fonts->remove_font_family( $font_family ); + + $this->assertArrayNotHasKey( $font_family, $wp_fonts->registered, 'Registered queue should not contain the font family' ); + $this->assertSame( $expected, array_keys( $wp_fonts->registered ), 'Registered queue should match after removing font family' ); + } +} diff --git a/phpunit/tests/fonts-api/wpFonts/removeVariation.php b/phpunit/tests/fonts-api/wpFonts/removeVariation.php new file mode 100644 index 00000000000000..508c8ce264d8fb --- /dev/null +++ b/phpunit/tests/fonts-api/wpFonts/removeVariation.php @@ -0,0 +1,278 @@ +<?php +/** + * WP_Fonts::remove_variation() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @group remove_fonts + * @covers WP_Fonts::remove_variation + */ +class Tests_Fonts_WpFonts_RemoveVariation extends WP_Fonts_TestCase { + private $wp_fonts; + private $fonts_to_register = array(); + + public function set_up() { + parent::set_up(); + $this->wp_fonts = new WP_Fonts(); + $this->fonts_to_register = $this->get_registered_local_fonts(); + } + + /** + * Sets up the unit test by mocking the WP_Dependencies object using stdClass and + * registering each font family directly to the WP_Fonts::$registered property + * and its variations to the mocked $deps property. + */ + private function setup_unit_test() { + $this->setup_registration_mocks( $this->fonts_to_register, $this->wp_fonts ); + } + + /** + * Sets up the integration test by properly registering each font family and its variations + * by using the WP_Fonts::add() and WP_Fonts::add_variation() methods. + */ + private function setup_integration_test() { + foreach ( $this->fonts_to_register as $font_family_handle => $variations ) { + $this->setup_register( $font_family_handle, $variations, $this->wp_fonts ); + } + } + + /** + * Testing the test setup to ensure it works. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + */ + public function test_mocked_setup( $font_family_handle, $variation_handle ) { + $this->setup_unit_test(); + + $this->assertArrayHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be in the registered queue before removal' ); + $this->assertContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should be in its font family deps before removal' ); + } + + /** + * Unit test. + * + * @dataProvider data_should_do_nothing_when_variation_and_font_family_not_registered + * + * @param string $font_family Font family name. + * @param string $font_family_handle Font family handle. + * @param string $variation_handle Variation handle to remove. + */ + public function test_unit_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { + // Set up the test. + unset( $this->fonts_to_register[ $font_family ] ); + $this->setup_unit_test(); + $registered_queue = $this->wp_fonts->registered; + + // Run the tests. + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertSame( $registered_queue, $this->wp_fonts->registered, 'Registered queue should not have changed' ); + } + + /** + * Integration test. + * + * @dataProvider data_should_do_nothing_when_variation_and_font_family_not_registered + * + * @param string $font_family Font family name. + * @param string $font_family_handle Font family handle. + * @param string $variation_handle Variation handle to remove. + */ + public function test_should_do_nothing_when_variation_and_font_family_not_registered( $font_family, $font_family_handle, $variation_handle ) { + // Set up the test. + unset( $this->fonts_to_register[ $font_family ] ); + $this->setup_integration_test(); + $registered_queue = $this->wp_fonts->get_registered(); + + // Run the tests. + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $font_family_handle, $this->wp_fonts->registered, 'Font family should not be registered' ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertSameSets( $registered_queue, $this->wp_fonts->get_registered(), 'Registered queue should not have changed' ); + } + + /** + * Data provider for testing removal of variations. + * + * @return array + */ + public function data_should_do_nothing_when_variation_and_font_family_not_registered() { + return array( + 'Font with 1 variation' => array( + 'font_family' => 'merriweather', + 'font_family_handle' => 'merriweather', + 'variation_handle' => 'merriweather-200-900-normal', + ), + 'Font with multiple variations' => array( + 'font_family' => 'Source Serif Pro', + 'font_family_handle' => 'source-serif-pro', + 'variation_handle' => 'Source Serif Pro-300-normal', + ), + ); + } + + /** + * Unit test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_unit_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_unit_test(); + $this->setup_remove_variation_from_registered( $variation_handle ); + + // Run the tests. + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Integration test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_should_only_remove_from_font_family_deps_when_variation_not_in_queue( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_integration_test(); + $this->setup_remove_variation_from_registered( $variation_handle ); + + // Run the tests. + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variant should not be registered' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Unit test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_unit_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_unit_test(); + $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); + + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Integration test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_should_remove_variation_from_registered_queue_though_font_family_not_registered( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_integration_test(); + $this->setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should not be in its font family deps before removal' ); + + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Unit test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_unit_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_unit_test(); + + $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); + + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Integration test. + * + * @dataProvider data_remove_variations + * + * @param string $font_family_handle Font family for the variation. + * @param string $variation_handle Variation handle to remove. + * @param array $expected Expected results. + */ + public function test_should_remove_variation_from_queue_and_font_family_deps( $font_family_handle, $variation_handle, $expected ) { + // Set up the test. + $this->setup_integration_test(); + + $this->assertArrayHasKey( $variation_handle, array_flip( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Variation should be in its font family deps before removal' ); + + $this->wp_fonts->remove_variation( $font_family_handle, $variation_handle ); + + $this->assertArrayNotHasKey( $variation_handle, $this->wp_fonts->registered, 'Variation should be not be in registered queue' ); + $this->assertNotContains( $variation_handle, $this->wp_fonts->registered[ $font_family_handle ]->deps, 'Variation should not be its font family deps' ); + $this->assertSameSets( $expected['font_family_deps'], array_values( $this->wp_fonts->registered[ $font_family_handle ]->deps ), 'Only the tested variation handle should be removed from font family deps' ); + } + + /** + * Remove the variation handle from the font family's deps. + * + * @param string $font_family_handle Font family. + * @param string $variation_handle The variation handle to remove. + */ + private function setup_remove_from_font_family_deps( $font_family_handle, $variation_handle ) { + foreach ( $this->wp_fonts->registered[ $font_family_handle ]->deps as $index => $vhandle ) { + if ( $variation_handle !== $vhandle ) { + continue; + } + unset( $this->wp_fonts->registered[ $font_family_handle ]->deps[ $index ] ); + break; + } + } + + /** + * Removes the variation from the WP_Fonts::$registered queue. + * + * @param string $variation_handle The variation handle to remove. + */ + private function setup_remove_variation_from_registered( $variation_handle ) { + unset( $this->wp_fonts->registered[ $variation_handle ] ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsProviderLocal.php b/phpunit/tests/fonts-api/wpFontsProviderLocal.php new file mode 100644 index 00000000000000..f9319390c3b6b5 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsProviderLocal.php @@ -0,0 +1,180 @@ +<?php +/** + * WP_Fonts_Local_Provider tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + */ +class Tests_Fonts_WpFontsProviderLocal extends WP_UnitTestCase { + private $provider; + private $theme_root; + private $orig_theme_dir; + + public function set_up() { + parent::set_up(); + + $this->provider = new WP_Fonts_Provider_Local(); + + $this->set_up_theme(); + } + + public function tear_down() { + // Restore the original theme directory setup. + $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + + parent::tear_down(); + } + + /** + * @covers WP_Fonts_Provider_Local::set_fonts + */ + public function test_set_fonts() { + $fonts = array( + 'source-serif-pro-200-900-normal-local' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro-200-900-italic-local' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ); + + $this->provider->set_fonts( $fonts ); + + $property = $this->get_fonts_property(); + $this->assertSame( $fonts, $property->getValue( $this->provider ) ); + } + + /** + * @covers WP_Fonts_Provider_Local::get_css + * + * @dataProvider data_get_css_print_styles + * + * @param array $fonts Prepared fonts (to store in WP_Fonts_Provider_Local::$fonts property). + * @param string $expected Expected CSS. + */ + public function test_get_css( array $fonts, $expected ) { + $property = $this->get_fonts_property(); + $property->setValue( $this->provider, $fonts ); + + $this->assertSame( $expected['font-face-css'], $this->provider->get_css() ); + } + + /** + * @covers WP_Fonts_Provider_Local::print_styles + * + * @dataProvider data_get_css_print_styles + * + * @param array $fonts Prepared fonts (to store in WP_Fonts_Provider_Local::$fonts property). + * @param string $expected Expected CSS. + */ + public function test_print_styles( array $fonts, $expected ) { + $property = $this->get_fonts_property(); + $property->setValue( $this->provider, $fonts ); + + $expected_output = sprintf( $expected['style-element'], $expected['font-face-css'] ); + $this->expectOutputString( $expected_output ); + $this->provider->print_styles(); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_css_print_styles() { + return array( + 'truetype format' => array( + 'fonts' => array( + 'open-sans-bold-italic-local' => array( + 'provider' => 'local', + 'font-family' => 'Open Sans', + 'font-style' => 'italic', + 'font-weight' => 'bold', + 'src' => 'http://example.org/assets/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf', + ), + ), + 'expected' => array( + 'style-element' => "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n", + 'font-face-css' => <<<CSS +@font-face{font-family:"Open Sans";font-style:italic;font-weight:bold;src:url('http://example.org/assets/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf') format('truetype');} +CSS + , + ), + ), + 'woff2 format' => array( + 'fonts' => array( + 'source-serif-pro-200-900-normal-local' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro-400-900-italic-local' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ), + 'expected' => array( + 'style-element' => "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n", + 'font-face-css' => <<<CSS +@font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-stretch:normal;src:url('http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');}@font-face{font-family:"Source Serif Pro";font-style:italic;font-weight:200 900;font-stretch:normal;src:url('http://example.org/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2') format('woff2');} +CSS + + , + ), + ), + ); + } + + /** + * Local `src` paths to need to be relative to the theme. This method sets up the + * `wp-content/themes/` directory to ensure consistency when running tests. + */ + private function set_up_theme() { + $this->theme_root = realpath( GUTENBERG_DIR_TESTDATA . '/themedir1' ); + $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; + $GLOBALS['wp_theme_directories'] = array( $this->theme_root ); + + $theme_root_callback = function () { + return $this->theme_root; + }; + add_filter( 'theme_root', $theme_root_callback ); + add_filter( 'stylesheet_root', $theme_root_callback ); + add_filter( 'template_root', $theme_root_callback ); + + // Clear caches. + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + } + + private function get_fonts_property() { + $property = new ReflectionProperty( $this->provider, 'fonts' ); + $property->setAccessible( true ); + + return $property; + } +} diff --git a/phpunit/tests/fonts-api/wpFontsResolver/addMissingFontsToThemeJson.php b/phpunit/tests/fonts-api/wpFontsResolver/addMissingFontsToThemeJson.php new file mode 100644 index 00000000000000..d6e45561e5e335 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsResolver/addMissingFontsToThemeJson.php @@ -0,0 +1,252 @@ +<?php + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * Test WP_Fonts_Resolver::add_missing_fonts_to_theme_json(). + * + * @package WordPress + * @subpackage Fonts API + * + * @since X.X.X + * @group fontsapi + * @covers WP_Fonts_Resolver::add_missing_fonts_to_theme_json + */ +class Tests_Fonts_WPFontsResolver_AddMissingFontsToThemeJson extends WP_Fonts_TestCase { + const FONTS_THEME = 'fonts-block-theme'; + + /** + * Cache of test themes' `theme.json` contents. + * + * @var array + */ + private static $theme_json_data = array(); + + public static function set_up_before_class() { + self::$requires_switch_theme_fixtures = true; + + parent::set_up_before_class(); + + $themes = array( + 'block-theme', + 'fonts-block-theme', + ); + foreach ( $themes as $theme ) { + $file = self::$theme_root . "/{$theme}/theme.json"; + self::$theme_json_data[ $theme ] = json_decode( file_get_contents( $file ), true ); + } + } + + /** + * @dataProvider data_themes + * + * @param string $theme Theme to use. + */ + public function test_should_return_instance( $theme ) { + switch_theme( $theme ); + + $data = new WP_Theme_JSON_Gutenberg( self::$theme_json_data[ $theme ] ); + $actual = WP_Fonts_Resolver::add_missing_fonts_to_theme_json( $data ); + + $this->assertInstanceOf( WP_Theme_JSON_Gutenberg::class, $actual, 'Instance of WP_Theme_JSON_Gutenberg should be returned' ); + } + + /** + * @dataProvider data_themes + * + * @param string $theme Theme to use. + */ + public function test_should_bail_out_when_no_registered_fonts( $theme ) { + switch_theme( $theme ); + + $data = new WP_Theme_JSON_Gutenberg( self::$theme_json_data[ $theme ] ); + $actual = WP_Fonts_Resolver::add_missing_fonts_to_theme_json( $data ); + + $this->assertEmpty( wp_fonts()->get_registered_font_families(), 'No fonts should be registered in Fonts API' ); + $this->assertSame( $data, $actual, 'Same instance of WP_Theme_JSON_Gutenberg should be returned' ); + } + + /** + * Data Provider. + * + * @return array + */ + public function data_themes() { + return array( + 'no fonts defined' => array( 'block-theme' ), + 'no fonts registered' => array( static::FONTS_THEME ), + ); + } + + /** + * @dataProvider data_should_add_non_theme_json_fonts + * + * @param string $theme Theme to use. + * @param array $fonts Fonts to register. + * @param array $expected Expected fonts to be added. + */ + public function test_should_add_non_theme_json_fonts( $theme, $fonts, $expected ) { + switch_theme( static::FONTS_THEME ); + + // Register the fonts. + wp_register_fonts( $fonts ); + + $data = new WP_Theme_JSON_Gutenberg( self::$theme_json_data[ $theme ] ); + $actual = WP_Fonts_Resolver::add_missing_fonts_to_theme_json( $data ); + + $this->assertNotSame( $data, $actual, 'New instance of WP_Theme_JSON_Gutenberg should be returned' ); + $actual_raw_data = $actual->get_raw_data(); + + $this->assertArrayHasKey( 'typography', $actual_raw_data['settings'] ); + $this->assertArrayHasKey( 'fontFamilies', $actual_raw_data['settings']['typography'] ); + $this->assertArrayHasKey( 'theme', $actual_raw_data['settings']['typography']['fontFamilies'] ); + + $this->assertContains( + $expected, + $actual_raw_data['settings']['typography']['fontFamilies']['theme'], + 'Fonts should be added after running WP_Fonts_Resolver::add_missing_fonts_to_theme_json()' + ); + } + + /** + * Data Provider. + * + * @return array + */ + public function data_should_add_non_theme_json_fonts() { + $lato = array( + 'Lato' => array( + array( + 'font-family' => 'Lato', + 'font-style' => 'normal', + 'font-weight' => '400', + 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular.woff2', + ), + array( + 'font-family' => 'Lato', + 'font-style' => 'italic', + 'font-weight' => '400', + 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular-Italic.woff2', + ), + ), + ); + + $expected_lato = array( + 'fontFamily' => 'Lato', + 'name' => 'Lato', + 'slug' => 'lato', + 'fontFace' => array( + 'lato-400-normal' => array( + 'origin' => 'gutenberg_wp_fonts_api', + 'provider' => 'local', + 'fontFamily' => 'Lato', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'fontDisplay' => 'fallback', + 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular.woff2', + ), + 'lato-400-italic' => array( + 'origin' => 'gutenberg_wp_fonts_api', + 'provider' => 'local', + 'fontFamily' => 'Lato', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'fontDisplay' => 'fallback', + 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular-Italic.woff2', + ), + ), + ); + + return array( + 'theme with no fonts defined' => array( + 'theme' => 'block-theme', + 'fonts' => $lato, + 'expected' => $expected_lato, + ), + 'theme with fonts: new fonts not in theme' => array( + 'theme' => static::FONTS_THEME, + 'fonts' => $lato, + 'expected' => $expected_lato, + ), + + /* + * @TODO Add these tests fixing https://github.com/WordPress/gutenberg/issues/50047. + * + 'theme with fonts: new variations registered' => array( + 'theme' => static::FONTS_THEME, + 'fonts' => array( + 'DM Sans' => array( + 'dm-sans-500-normal' => array( + 'font-family' => 'DM Sans', + 'font-style' => 'normal', + 'font-weight' => '500', + 'src' => 'https://example.com/tests/assets/fonts/dm-sans/DMSans-Medium.woff2', + ), + 'dm-sans-500-italic' => array( + 'font-family' => 'DM Sans', + 'font-style' => 'italic', + 'font-weight' => '500', + 'src' => 'https://example.com/tests/assets/fonts/dm-sans/DMSans-Medium.woff2', + ), + ), + ), + 'expected' => array( + 'fontFace' => array( + array( + 'fontFamily' => 'DM Sans', + 'fontStretch' => 'normal', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Regular.woff2' ), + ), + array( + 'fontFamily' => 'DM Sans', + 'fontStretch' => 'normal', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Regular-Italic.woff2' ), + ), + 'dm-sans-500-normal' => array( + 'origin' => 'gutenberg_wp_fonts_api', + 'provider' => 'local', + 'fontFamily' => 'DM Sans', + 'fontStretch' => 'normal', + 'fontStyle' => 'normal', + 'fontWeight' => '500', + 'fontDisplay' => 'fallback', + 'src' => array( get_stylesheet_directory_uri() . 'assets/fonts/dm-sans/DMSans-Medium.woff2' ), + ), + 'dm-sans-500-italic' => array( + 'origin' => 'gutenberg_wp_fonts_api', + 'provider' => 'local', + 'fontFamily' => 'DM Sans', + 'fontStretch' => 'normal', + 'fontStyle' => 'italic', + 'fontWeight' => '500', + 'fontDisplay' => 'fallback', + 'src' => array( get_stylesheet_directory_uri() . 'assets/fonts/dm-sans/DMSans-Medium-Italic.woff2' ), + ), + array( + 'fontFamily' => 'DM Sans', + 'fontStretch' => 'normal', + 'fontStyle' => 'normal', + 'fontWeight' => '700', + 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Bold.woff2' ), + ), + array( + 'fontFamily' => 'DM Sans', + 'fontStretch' => 'normal', + 'fontStyle' => 'italic', + 'fontWeight' => '700', + 'src' => array( 'file:./assets/fonts/dm-sans/DMSans-Bold-Italic.woff2' ), + ), + ), + 'fontFamily' => '"DM Sans", sans-serif', + 'name' => 'DM Sans', + 'slug' => 'dm-sans', + ), + ), + */ + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsResolver/enqueueUserSelectedFonts.php b/phpunit/tests/fonts-api/wpFontsResolver/enqueueUserSelectedFonts.php new file mode 100644 index 00000000000000..9ff01f1c3166cd --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsResolver/enqueueUserSelectedFonts.php @@ -0,0 +1,131 @@ +<?php +/** + * WP_Fonts_Resolver::enqueue_user_selected_fonts() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts_Resolver::enqueue_user_selected_fonts + */ +class Tests_Fonts_WpFontsResolver_EnqueueUserSelectedFonts extends WP_Fonts_TestCase { + + public static function set_up_before_class() { + self::$requires_switch_theme_fixtures = true; + + parent::set_up_before_class(); + + self::$administrator_id = self::factory()->user->create( + array( + 'role' => 'administrator', + 'user_email' => 'administrator@example.com', + ) + ); + } + + /** + * @dataProvider data_should_not_enqueue_when_no_user_selected_fonts + * + * @param array $styles Optional. Test styles. Default empty array. + */ + public function test_should_not_enqueue_when_no_user_selected_fonts( $styles = array() ) { + $this->set_up_global_styles( $styles ); + + $mock = $this->set_up_mock( 'enqueue' ); + $mock->expects( $this->never() ) + ->method( 'enqueue' ); + + $expected = array(); + $this->assertSame( $expected, WP_Fonts_Resolver::enqueue_user_selected_fonts() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_not_enqueue_when_no_user_selected_fonts() { + return array( + 'no user-selected styles' => array(), + 'invalid element' => array( + array( + 'elements' => array( + 'invalid' => array( + 'typography' => array( + 'fontFamily' => 'var:preset|font-family|font1', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + ), + ), + ), + ), + ), + ); + } + + /** + * @dataProvider data_should_enqueue_when_user_selected_fonts + * + * @param array $styles Test styles. + * @param array $expected Expected results. + */ + public function test_should_enqueue_when_user_selected_fonts( $styles, $expected ) { + $mock = $this->set_up_mock( 'enqueue' ); + $mock->expects( $this->once() ) + ->method( 'enqueue' ) + ->with( + $this->identicalTo( $expected ) + ); + + $this->set_up_global_styles( $styles ); + + $this->assertSameSets( $expected, WP_Fonts_Resolver::enqueue_user_selected_fonts() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_enqueue_when_user_selected_fonts() { + $global_styles = $this->get_mock_user_selected_fonts_global_styles(); + + return array( + 'heading, caption, text' => array( + 'styles' => $global_styles['font1'], + 'expected' => array( 'font1' ), + ), + 'heading, button' => array( + 'styles' => $global_styles['font2'], + 'expected' => array( 'font2' ), + ), + 'text' => array( + 'styles' => $global_styles['font3'], + 'expected' => array( 'font3' ), + ), + 'all' => array( + 'styles' => $global_styles['all'], + 'expected' => array( + 0 => 'font1', + // font1 occurs 2 more times and gets removed as duplicates. + 3 => 'font2', + 4 => 'font3', + ), + ), + 'all with invalid element' => array( + 'styles' => $global_styles['all with invalid element'], + 'expected' => array( + 0 => 'font1', + // font1 occurs 2 more times and gets removed as duplicates. + 3 => 'font2', + // Skips font2 for the "invalid" element. + 4 => 'font3', + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsResolver/registerFontsFromThemeJson.php b/phpunit/tests/fonts-api/wpFontsResolver/registerFontsFromThemeJson.php new file mode 100644 index 00000000000000..c812b98d3d156d --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsResolver/registerFontsFromThemeJson.php @@ -0,0 +1,297 @@ +<?php + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * Test WP_Fonts_Resolver::register_fonts_from_theme_json(). + * + * @package WordPress + * @subpackage Fonts API + * + * @since X.X.X + * @group fontsapi + * @covers WP_Fonts_Resolver::register_fonts_from_theme_json + */ +class Tests_Fonts_WPFontsResolver_RegisterFontsFromThemeJson extends WP_Fonts_TestCase { + const FONTS_THEME = 'fonts-block-theme'; + const FONT_FAMILIES = array( + 'fonts-block-theme' => array( + // From theme.json. + 'dm-sans', + 'source-serif-pro', + // From style variation. + 'open-sans', + ), + ); + + public static function set_up_before_class() { + self::$requires_switch_theme_fixtures = true; + + parent::set_up_before_class(); + } + + public function test_should_bails_out_when_no_fonts_defined() { + switch_theme( 'block-theme' ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + $wp_fonts = wp_fonts(); + + $this->assertEmpty( $wp_fonts->get_registered() ); + $this->assertEmpty( $wp_fonts->get_enqueued() ); + } + + public function test_should_register_and_enqueue_style_variation_fonts() { + switch_theme( static::FONTS_THEME ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + $wp_fonts = wp_fonts(); + + $this->assertContains( 'open-sans', $wp_fonts->get_registered_font_families(), 'Font families should be registered' ); + $this->assertContains( 'open-sans', $wp_fonts->get_enqueued(), 'Font families should be enqueued' ); + } + + /** + * Tests all font families are registered and enqueued. "All" means all font families from + * the theme's theme.json and within the style variations. + */ + public function test_should_register_and_enqueue_all_defined_font_families() { + switch_theme( static::FONTS_THEME ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + $wp_fonts = wp_fonts(); + + $expected = static::FONT_FAMILIES[ static::FONTS_THEME ]; + $this->assertSameSetsWithIndex( $expected, $wp_fonts->get_registered_font_families(), 'Font families should be registered' ); + $this->assertSameSetsWithIndex( $expected, $wp_fonts->get_enqueued(), 'Font families should be enqueued' ); + } + + /** + * Test ensures duplicate fonts and variations in the style variations + * are not re-registered. + * + * The Dm Sans fonts are duplicated in the theme's /styles/variations-duplicate-fonts.json. + */ + public function test_should_not_reregister_duplicate_fonts_from_style_variations() { + switch_theme( static::FONTS_THEME ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + $wp_fonts = wp_fonts(); + + // Font families are not duplicated. + $this->assertSameSetsWithIndex( + static::FONT_FAMILIES[ static::FONTS_THEME ], + $wp_fonts->get_registered_font_families(), + 'Font families should not be duplicated' + ); + + // Font variations are not duplicated. + $this->assertSameSets( + array( + // From theme.json. + 'dm-sans', + 'dm-sans-400-normal', + 'dm-sans-400-italic', + 'dm-sans-700-normal', + 'dm-sans-700-italic', + 'source-serif-pro', + 'source-serif-pro-200-900-normal', + 'source-serif-pro-200-900-italic', + // From style variation. + 'open-sans', + 'open-sans-400-normal', + 'open-sans-400-italic', + 'dm-sans-500-normal', + 'dm-sans-500-italic', + ), + $wp_fonts->get_registered(), + 'Font families and their variations should not be duplicated' + ); + } + + /** + * @dataProvider data_should_replace_src_file_placeholder + * + * @param string $handle Variation's handle. + * @param string $expected Expected src. + */ + public function test_should_replace_src_file_placeholder( $handle, $expected ) { + switch_theme( static::FONTS_THEME ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + + $variation = wp_fonts()->registered[ $handle ]; + $actual = array_pop( $variation->src ); + $expected = get_stylesheet_directory_uri() . $expected; + + $this->assertStringNotContainsString( 'file:./', $actual, 'Font src should not contain the "file:./" placeholder' ); + $this->assertSame( $expected, $actual, 'Font src should be an URL to its file' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_replace_src_file_placeholder() { + return array( + // Theme's theme.json. + 'DM Sans: 400 normal' => array( + 'handle' => 'dm-sans-400-normal', + 'expected' => '/assets/fonts/dm-sans/DMSans-Regular.woff2', + ), + 'DM Sans: 400 italic' => array( + 'handle' => 'dm-sans-400-italic', + 'expected' => '/assets/fonts/dm-sans/DMSans-Regular-Italic.woff2', + ), + 'DM Sans: 700 normal' => array( + 'handle' => 'dm-sans-700-normal', + 'expected' => '/assets/fonts/dm-sans/DMSans-Bold.woff2', + ), + 'DM Sans: 700 italic' => array( + 'handle' => 'dm-sans-700-italic', + 'expected' => '/assets/fonts/dm-sans/DMSans-Bold-Italic.woff2', + ), + 'Source Serif Pro: 200-900 normal' => array( + 'handle' => 'source-serif-pro-200-900-normal', + 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'Source Serif Pro: 200-900 italic' => array( + 'handle' => 'source-serif-pro-200-900-italic', + 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + + // Style Variation: variation-with-new-font-family.json. + 'Style Variation: new font-family' => array( + 'handle' => 'open-sans-400-normal', + 'expected' => '/assets/fonts/open-sans/OpenSans-VariableFont_wdth,wght.tff', + ), + 'Style Variation: new font-family italic variation' => array( + 'handle' => 'open-sans-400-italic', + 'expected' => '/assets/fonts/open-sans/OpenSans-Italic-VariableFont_wdth,wght.tff', + ), + + // Style Variation: variation-with-new-variation.json. + 'Style Variation: new medium variation' => array( + 'handle' => 'dm-sans-500-normal', + 'expected' => '/assets/fonts/dm-sans/DMSans-Medium.woff2', + ), + 'Style Variation: new medium italic variation' => array( + 'handle' => 'dm-sans-500-italic', + 'expected' => '/assets/fonts/dm-sans/DMSans-Medium-Italic.woff2', + ), + ); + } + + public function test_should_convert_font_face_properties_into_kebab_case() { + switch_theme( static::FONTS_THEME ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + + // Testing only one variation since this theme's fonts use the same properties. + $variation = wp_fonts()->registered['dm-sans-400-normal']; + $actual_properties = $variation->extra['font-properties']; + + $this->assertArrayHasKey( 'font-family', $actual_properties, 'fontFamily should have been converted into font-family' ); + $this->assertArrayNotHasKey( 'fontFamily', $actual_properties, 'fontFamily should not exist.' ); + $this->assertArrayHasKey( 'font-stretch', $actual_properties, 'fontStretch should have been converted into font-stretch' ); + $this->assertArrayNotHasKey( 'fontStretch', $actual_properties, 'fontStretch should not exist' ); + $this->assertArrayHasKey( 'font-style', $actual_properties, 'fontStyle should have been converted into font-style' ); + $this->assertArrayNotHasKey( 'fontStyle', $actual_properties, 'fontStyle should not exist.' ); + $this->assertArrayHasKey( 'font-weight', $actual_properties, 'fontWeight should have been converted into font-weight' ); + $this->assertArrayNotHasKey( 'fontWeight', $actual_properties, 'fontWeight should not exist' ); + } + + /** + * Tests that WP_Fonts_Resolver::register_fonts_from_theme_json() skips fonts that are already registered + * in the Fonts API. How does it do that? Using the 'origin' property when checking each variation. + * This property is added when WP_Theme_JSON_Resolver_Gutenberg::get_merged_data() runs. + * + * To simulate this scenario, a font is registered first, but not enqueued. Then after running, + * it checks if the WP_Fonts_Resolver::register_fonts_from_theme_json() enqueued the font. If no, then + * it was skipped as expected. + */ + public function test_should_skip_registered_fonts() { + switch_theme( static::FONTS_THEME ); + + // Register Lato font. + wp_register_fonts( + array( + 'Lato' => array( + array( + 'font-family' => 'Lato', + 'font-style' => 'normal', + 'font-weight' => '400', + 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular.woff2', + ), + array( + 'font-family' => 'Lato', + 'font-style' => 'italic', + 'font-weight' => '400', + 'src' => 'https://example.com/tests/assets/fonts/lato/Lato-Regular-Italic.woff2', + ), + ), + ) + ); + + // Pre-check to ensure no fonts are enqueued. + $this->assertEmpty( wp_fonts()->get_enqueued(), 'No fonts should be enqueued before running WP_Fonts_Resolver::register_fonts_from_theme_json()' ); + + /* + * When this function runs, it invokes WP_Theme_JSON_Resolver_Gutenberg::get_merged_data(), + * which will include the Lato fonts with a 'origin' property set in each variation. + */ + WP_Fonts_Resolver::register_fonts_from_theme_json(); + + $actual_enqueued_fonts = wp_fonts()->get_enqueued(); + + $this->assertNotContains( 'lato', $actual_enqueued_fonts, 'Lato font-family should not be enqueued' ); + $this->assertSameSets( static::FONT_FAMILIES[ static::FONTS_THEME ], $actual_enqueued_fonts, 'Only the theme font families should be enqueued' ); + } + + public function test_should_skip_when_font_face_not_defined() { + switch_theme( static::FONTS_THEME ); + $expected_font_family = 'source-serif-pro'; + + /** + * Callback that removes the 'fontFace' of the expected font family from the theme's theme.json data. + * This callback is invoked at the start of WP_Fonts_Resolver::register_fonts_from_theme_json() before processing + * within that function. How? It's in the call stack of WP_Theme_JSON_Resolver_Gutenberg::get_merged_data(). + * + * @param WP_Theme_JSON_Data_Gutenberg| WP_Theme_JSON_Data $theme_json_data Instance of the Data object. + * @return WP_Theme_JSON_Data_Gutenberg| WP_Theme_JSON_Data Modified instance. + * @throws ReflectionException + */ + $remove_expected_font_family = static function( $theme_json_data ) use ( $expected_font_family ) { + // Need to get the underlying data array which is in WP_Theme_JSON_Gutenberg | WP_Theme_JSON object. + $property = new ReflectionProperty( $theme_json_data, 'theme_json' ); + $property->setAccessible( true ); + $theme_json_object = $property->getValue( $theme_json_data ); + + $property = new ReflectionProperty( $theme_json_object, 'theme_json' ); + $property->setAccessible( true ); + $data = $property->getValue( $theme_json_object ); + + // Loop through the fonts to find the expected font-family to modify. + foreach ( $data['settings']['typography']['fontFamilies']['theme'] as $index => $definitions ) { + if ( $expected_font_family !== $definitions['slug'] ) { + continue; + } + + // Remove the 'fontFace' element, which removes the font's variations. + unset( $data['settings']['typography']['fontFamilies']['theme'][ $index ]['fontFace'] ); + break; + } + + $theme_json_data->update_with( $data ); + + return $theme_json_data; + }; + add_filter( 'wp_theme_json_data_theme', $remove_expected_font_family ); + + WP_Fonts_Resolver::register_fonts_from_theme_json(); + + remove_filter( 'wp_theme_json_data_theme', $remove_expected_font_family ); + + $this->assertNotContains( $expected_font_family, wp_fonts()->get_registered_font_families() ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsUtils/convertFontFamilyIntoHandle.php b/phpunit/tests/fonts-api/wpFontsUtils/convertFontFamilyIntoHandle.php new file mode 100644 index 00000000000000..19b2f15c826ed1 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsUtils/convertFontFamilyIntoHandle.php @@ -0,0 +1,84 @@ +<?php +/** + * WP_Fonts_Utils::convert_font_family_into_handle() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts_Utils::convert_font_family_into_handle + */ +class Tests_Fonts_WpFontsUtils_ConvertFontFamilyIntoHandle extends WP_Fonts_TestCase { + + /** + * @dataProvider data_with_valid_input + * + * @param mixed $font_family Font family to test. + * @param string $expected Expected results. + */ + public function test_should_convert_with_valid_input( $font_family, $expected ) { + $this->assertSame( $expected, WP_Fonts_Utils::convert_font_family_into_handle( $font_family ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_with_valid_input() { + return array( + 'font family single word name' => array( + 'font_family' => 'Merriweather', + 'expected' => 'merriweather', + ), + 'font family multiword name' => array( + 'font_family' => 'Source Sans Pro', + 'expected' => 'source-sans-pro', + ), + 'font family handle delimited by hyphens' => array( + 'font_family' => 'source-serif-pro', + 'expected' => 'source-serif-pro', + ), + 'font family handle delimited by underscore' => array( + 'font_family' => 'source_serif_pro', + 'expected' => 'source_serif_pro', + ), + 'font family handle delimited by hyphens and underscore' => array( + 'font_family' => 'my-custom_font_family', + 'expected' => 'my-custom_font_family', + ), + 'font family handle delimited mixture' => array( + 'font_family' => 'My custom_font-family', + 'expected' => 'my-custom_font-family', + ), + ); + } + + /** + * @dataProvider data_with_invalid_input + * + * @covers WP_Fonts_Utils::convert_font_family_into_handle + * + * @param mixed $invalid_input Invalid input. + */ + public function test_should_not_convert_with_invalid_input( $invalid_input ) { + $this->assertNull( WP_Fonts_Utils::convert_font_family_into_handle( $invalid_input ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_with_invalid_input() { + return array( + 'empty string' => array( '' ), + 'integer' => array( 10 ), + 'font family wrapped in an array' => array( array( 'source-serif-pro' ) ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsUtils/convertVariationIntoHandle.php b/phpunit/tests/fonts-api/wpFontsUtils/convertVariationIntoHandle.php new file mode 100644 index 00000000000000..9268aac8ce372b --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsUtils/convertVariationIntoHandle.php @@ -0,0 +1,122 @@ +<?php +/** + * WP_Fonts_Utils::convert_variation_into_handle() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts_Utils::convert_variation_into_handle + */ +class Tests_Fonts_WpFontsUtils_ConvertVariationIntoHandle extends WP_Fonts_TestCase { + + /** + * @dataProvider data_with_valid_input + * + * @param string $font_family Font family to test. + * @param array $variation Variation to test. + * @param string $expected Expected results. + */ + public function test_should_convert_with_valid_inputs( $font_family, array $variation, $expected ) { + $this->assertSame( $expected, WP_Fonts_Utils::convert_variation_into_handle( $font_family, $variation ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_with_valid_input() { + return array( + 'with only font-weight' => array( + 'font_family' => 'merriweather', + 'variation' => array( + 'font-weight' => '400', + ), + 'expected' => 'merriweather-400', + ), + 'with no font-style' => array( + 'font_family' => 'source-sans-pro', + 'variation' => array( + 'font-weight' => '200 900', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'provider' => 'local', + ), + 'expected' => 'source-sans-pro-200-900', + ), + 'with font family name and full variant' => array( + 'font_family' => 'source-sans-pro', + 'variation' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + 'expected' => 'source-sans-pro-200-900-normal', + ), + ); + } + + /** + * @dataProvider data_with_invalid_input + * + * @param string $font_family Font family to test. + * @param array $invalid_input Variation to test. + */ + public function tests_should_convert_with_invalid_input( $font_family, $invalid_input ) { + $this->expectNotice(); + $this->expectNoticeMessage( 'Variant handle could not be determined as font-weight and/or font-style are require' ); + + $this->assertNull( WP_Fonts_Utils::convert_variation_into_handle( $font_family, $invalid_input ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_with_invalid_input() { + return array( + 'with no font-weight or font-style' => array( + 'font_family' => 'merriweather', + 'variation' => array( + 'provider' => 'local', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'with non-string font-weight' => array( + 'font_family' => 'merriweather', + 'variation' => array( + 'font-weight' => 400, + ), + ), + 'with non-string font-style' => array( + 'font_family' => 'merriweather', + 'variation' => array( + 'font-style' => 0, + ), + ), + 'with empty string font-weight' => array( + 'font_family' => 'merriweather', + 'variation' => array( + 'font-weight' => '', + ), + ), + 'with empty string font-style' => array( + 'font_family' => 'merriweather', + 'variation' => array( + 'font-style' => '', + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsUtils/getFontFamilyFromVariation.php b/phpunit/tests/fonts-api/wpFontsUtils/getFontFamilyFromVariation.php new file mode 100644 index 00000000000000..2b3e3689c4317f --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsUtils/getFontFamilyFromVariation.php @@ -0,0 +1,134 @@ +<?php +/** + * WP_Fonts_Utils::get_font_family_from_variation() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts_Utils::get_font_family_from_variation + */ +class Tests_Fonts_WpFontsUtils_GetFontFamilyFromVariation extends WP_Fonts_TestCase { + + /** + * @dataProvider data_with_valid_variation + * + * @param array $variation Variation to test. + * @param string $expected Expected results. + */ + public function test_with_valid_variation( array $variation, $expected ) { + $this->assertSame( $expected, WP_Fonts_Utils::get_font_family_from_variation( $variation ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_with_valid_variation() { + return array( + 'keyed by font-family' => array( + 'variation' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + 'expected' => 'Source Serif Pro', + ), + 'keyed by fontFamily and as a handle' => array( + 'variation' => array( + 'fontFamily' => 'source-sans-pro', + 'font-weight' => '200 900', + 'src' => 'https://example.com/assets/fonts/source-sans-pro/source-sans-pro.ttf.woff2', + 'provider' => 'local', + ), + 'expected' => 'source-sans-pro', + ), + 'with font family name and full variant' => array( + 'variation' => array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400 600', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + 'font-display' => 'fallback', + ), + 'expected' => 'Merriweather', + ), + ); + } + + /** + * @dataProvider data_with_invalid_input + * + * @param array $invalid_variation Variation to test. + * @param string $expected_message Expected notice message. + */ + public function test_with_invalid_input( array $invalid_variation, $expected_message ) { + $this->expectNotice(); + $this->expectNoticeMessage( $expected_message ); + + $this->assertNull( WP_Fonts_Utils::get_font_family_from_variation( $invalid_variation ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_with_invalid_input() { + return array( + 'keyed with underscore' => array( + 'variation' => array( + 'provider' => 'local', + 'font_family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + 'expected_message' => 'Font family not found.', + ), + 'keyed with space' => array( + 'variation' => array( + 'font family' => 'Source Sans Pro', + 'font-weight' => '200 900', + 'src' => 'https://example.com/assets/fonts/source-sans-pro/source-sans-pro.ttf.woff2', + 'provider' => 'local', + ), + 'expected_message' => 'Font family not found.', + ), + 'fontFamily => empty string' => array( + 'variation' => array( + 'fontFamily' => '', + 'font-weight' => '200 900', + 'src' => 'https://example.com/assets/fonts/source-sans-pro/source-sans-pro.ttf.woff2', + 'provider' => 'local', + ), + 'expected_message' => 'Font family not defined in the variation.', + ), + 'font-family => empty string' => array( + 'variation' => array( + 'provider' => 'local', + 'font-family' => '', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + 'expected_message' => 'Font family not defined in the variation.', + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpFontsUtils/isDefined.php b/phpunit/tests/fonts-api/wpFontsUtils/isDefined.php new file mode 100644 index 00000000000000..3ae48ad52671a2 --- /dev/null +++ b/phpunit/tests/fonts-api/wpFontsUtils/isDefined.php @@ -0,0 +1,61 @@ +<?php +/** + * WP_Fonts_Utils::is_defined() tests. + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * @group fontsapi + * @covers WP_Fonts_Utils::is_defined + */ +class Tests_Fonts_WpFontsUtils_IsDefined extends WP_Fonts_TestCase { + + /** + * @dataProvider data_when_defined + * + * @param mixed $input Input to test. + */ + public function test_should_return_true_when_defined( $input ) { + $this->assertTrue( WP_Fonts_Utils::is_defined( $input ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_when_defined() { + return array( + 'name: non empty string' => array( 'Some Font Family' ), + 'handle: non empty string' => array( 'some-font-family' ), + ); + } + + /** + * @dataProvider data_when_not_defined + * + * @param mixed $invalid_input Input to test. + */ + public function test_should_return_false_when_not_defined( $invalid_input ) { + $this->assertFalse( WP_Fonts_Utils::is_defined( $invalid_input ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_when_not_defined() { + return array( + 'empty string' => array( '' ), + 'string 0' => array( '0' ), + 'integer' => array( 10 ), + 'name wrapped in an array' => array( array( 'Some Font Family' ) ), + 'handle wrapped in an array' => array( array( 'some-font-family' ) ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpPrintFonts.php b/phpunit/tests/fonts-api/wpPrintFonts.php new file mode 100644 index 00000000000000..40b416aa56421f --- /dev/null +++ b/phpunit/tests/fonts-api/wpPrintFonts.php @@ -0,0 +1,230 @@ +<?php +/** + * Unit and integration tests for wp_print_fonts(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; +require_once GUTENBERG_DIR_TESTFIXTURES . '/mock-provider.php'; + +/** + * @group fontsapi + * @covers ::wp_print_fonts + */ +class Tests_Fonts_WpPrintFonts extends WP_Fonts_TestCase { + + public static function set_up_before_class() { + self::$requires_switch_theme_fixtures = true; + + parent::set_up_before_class(); + + static::set_up_admin_user(); + } + + public function test_should_return_empty_array_when_no_fonts_registered() { + $this->assertSame( array(), wp_print_fonts() ); + } + + /** + * Unit test which mocks WP_Fonts methods. + * + * @dataProvider data_mocked_handles + * + * @param string|string[] $handles Handles to test. + */ + public function test_should_return_mocked_handles( $handles ) { + $mock = $this->set_up_mock( array( 'get_registered_font_families', 'do_items' ) ); + $mock->expects( $this->once() ) + ->method( 'get_registered_font_families' ) + ->will( $this->returnValue( $handles ) ); + + $mock->expects( $this->once() ) + ->method( 'do_items' ) + ->with( + $this->identicalTo( $handles ) + ) + ->will( $this->returnValue( $handles ) ); + + wp_print_fonts( $handles ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_mocked_handles() { + return array( + 'font family' => array( + array( 'my-custom-font' ), + ), + 'multiple font families' => array( + array( + 'font1', + 'font2', + ), + ), + ); + } + + /** + * Integration test that registers providers and fonts and then enqueues before + * testing the printing functionality. + * + * @dataProvider data_print_enqueued + * + * @param array $setup Test set up information for provider, fonts, and enqueued. + * @param array $expected_done Expected array of printed handles. + * @param string $expected_output Expected printed output. + */ + public function test_should_print_enqueued( $setup, $expected_done, $expected_output ) { + $wp_fonts = wp_fonts(); + + $this->setup_integrated_deps( $setup, $wp_fonts ); + + $this->expectOutputString( $expected_output ); + $actual_done = wp_print_fonts(); + $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); + } + + /** + * Integration test to validate printing given handles. Rather than mocking internal functionality, + * it registers providers and fonts but does not enqueue. + * + * @dataProvider data_print_enqueued + * + * @param array $setup Test set up information for provider, fonts, and enqueued. + * @param array $expected_done Expected array of printed handles. + * @param string $expected_output Expected printed output. + */ + public function test_should_print_handles_when_not_enqueued( $setup, $expected_done, $expected_output ) { + $wp_fonts = wp_fonts(); + + $this->setup_integrated_deps( $setup, $wp_fonts, false ); + // Do not enqueue. Instead, pass the handles to wp_print_fonts(). + $handles = $setup['enqueued']; + $this->assertEmpty( $wp_fonts->queue, 'No fonts should be enqueued' ); + + $this->expectOutputString( $expected_output ); + $actual_done = wp_print_fonts( $handles ); + $this->assertSameSets( $expected_done, $actual_done, 'Printed handles should match' ); + } + + /** + * @dataProvider data_should_print_all_registered_fonts_for_iframed_editor + * + * @param string $fonts Fonts to register. + * @param array $expected Expected results. + */ + public function test_should_print_all_registered_fonts_for_iframed_editor( $fonts, $expected ) { + wp_register_fonts( $fonts ); + + $this->expectOutputString( $expected['output'] ); + $actual_done = wp_print_fonts( true ); + $this->assertSameSets( $expected['done'], $actual_done, 'All registered font-family handles should be returned' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_print_all_registered_fonts_for_iframed_editor() { + $local_fonts = $this->get_registered_local_fonts(); + $font_faces = $this->get_registered_fonts_css(); + + return array( + 'Merriweather with 1 variation' => array( + 'fonts' => array( 'merriweather' => $local_fonts['merriweather'] ), + 'expected' => array( + 'done' => array( 'merriweather', 'merriweather-200-900-normal' ), + 'output' => sprintf( + "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n", + $font_faces['merriweather-200-900-normal'] + ), + ), + ), + 'Source Serif Pro with 2 variations' => array( + 'fonts' => array( 'Source Serif Pro' => $local_fonts['Source Serif Pro'] ), + 'expected' => array( + 'done' => array( 'source-serif-pro', 'Source Serif Pro-300-normal', 'Source Serif Pro-900-italic' ), + 'output' => sprintf( + "<style id='wp-fonts-local' type='text/css'>\n%s%s\n</style>\n", + $font_faces['Source Serif Pro-300-normal'], + $font_faces['Source Serif Pro-900-italic'] + ), + ), + ), + 'all fonts' => array( + 'fonts' => $local_fonts, + 'expected' => array( + 'done' => array( + 'merriweather', + 'merriweather-200-900-normal', + 'source-serif-pro', + 'Source Serif Pro-300-normal', + 'Source Serif Pro-900-italic', + ), + 'output' => sprintf( + "<style id='wp-fonts-local' type='text/css'>\n%s%s%s\n</style>\n", + $font_faces['merriweather-200-900-normal'], + $font_faces['Source Serif Pro-300-normal'], + $font_faces['Source Serif Pro-900-italic'] + ), + ), + ), + ); + } + + /** + * Integration test for printing user-selected global fonts. + * This test registers providers and fonts and then enqueues before testing the printing functionality. + * + * @dataProvider data_print_user_selected_fonts + * + * @param array $global_styles Test set up information for provider, fonts, and enqueued. + * @param array $expected_done Expected array of printed handles. + * @param string $expected_output Expected printed output. + */ + public function test_should_print_user_selected_fonts( $global_styles, $expected_done, $expected_output ) { + $wp_fonts = wp_fonts(); + + $setup = array( + 'provider' => array( 'mock' => $this->get_provider_definitions( 'mock' ) ), + 'registered' => $this->get_registered_mock_fonts(), + 'global_styles' => $global_styles, + ); + $this->setup_integrated_deps( $setup, $wp_fonts, false ); + + $this->expectOutputString( $expected_output ); + $actual_printed_fonts = wp_print_fonts(); + $this->assertSameSets( $expected_done, $actual_printed_fonts, 'Should print font-faces for given user-selected fonts' ); + } + + + /** + * Sets up the dependencies for integration test. + * + * @param array $setup Dependencies to set up. + * @param WP_Fonts $wp_fonts Instance of WP_Fonts. + * @param bool $enqueue Whether to enqueue. Default true. + */ + private function setup_integrated_deps( array $setup, $wp_fonts, $enqueue = true ) { + foreach ( $setup['provider'] as $provider ) { + $wp_fonts->register_provider( $provider['id'], $provider['class'] ); + } + foreach ( $setup['registered'] as $handle => $variations ) { + $this->setup_register( $handle, $variations, $wp_fonts ); + } + + if ( $enqueue ) { + $wp_fonts->enqueue( $setup['enqueued'] ); + } + + if ( ! empty( $setup['global_styles'] ) ) { + $this->set_up_global_styles( $setup['global_styles'] ); + } + } +} diff --git a/phpunit/tests/fonts-api/wpRegisterFontProvider.php b/phpunit/tests/fonts-api/wpRegisterFontProvider.php new file mode 100644 index 00000000000000..9288ee3850672d --- /dev/null +++ b/phpunit/tests/fonts-api/wpRegisterFontProvider.php @@ -0,0 +1,96 @@ +<?php +/** + * Unit tests for wp_register_font_provider(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; +require_once GUTENBERG_DIR_TESTFIXTURES . '/mock-provider.php'; + +/** + * @group fontsapi + * @covers ::wp_register_font_provider + */ +class Tests_Fonts_WpRegisterFontProvider extends WP_Fonts_TestCase { + + /** + * @dataProvider data_register_providers + * + * @param string $provider_id Provider ID. + * @param string $class Provider class name. + */ + public function test_should_register_provider( $provider_id, $class ) { + $mock = $this->set_up_mock( 'register_provider' ); + $mock->expects( $this->once() ) + ->method( 'register_provider' ) + ->with( + $this->identicalTo( $provider_id ), + $this->identicalTo( $class ) + ) + ->will( $this->returnValue( true ) ); + + $this->assertTrue( wp_register_font_provider( $provider_id, $class ), 'wp_register_font_provider() should return true' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_register_providers() { + return array( + 'mock' => array( + 'provider_id' => 'mock', + 'class' => Mock_Provider::class, + ), + 'local' => array( + 'provider_id' => 'local', + 'class' => WP_Fonts_Provider_Local::class, + ), + ); + } + + /** + * @dataProvider data_invalid_providers + * + * @param string $provider_id Provider ID. + * @param string $class Provider class name. + */ + public function test_should_not_register( $provider_id, $class ) { + $mock = $this->set_up_mock( 'register_provider' ); + $mock->expects( $this->once() ) + ->method( 'register_provider' ) + ->with( + $this->identicalTo( $provider_id ), + $this->identicalTo( $class ) + ) + ->will( $this->returnValue( false ) ); + + $this->assertFalse( wp_register_font_provider( $provider_id, $class ), 'wp_register_font_provider() should return false' ); + + } + + /** + * Data provider. + * + * @return array + */ + public function data_invalid_providers() { + return array( + 'provider_id is empty' => array( + 'provider_id' => '', + 'class' => Mock_Provider::class, + ), + 'class is empty' => array( + 'provider_id' => 'local', + 'class' => '', + ), + 'class does not exist' => array( + 'provider_id' => 'doesnotexist', + 'class' => 'Provider_Does_Not_Exist', + ), + ); + } +} diff --git a/phpunit/tests/fonts-api/wpRegisterFonts.php b/phpunit/tests/fonts-api/wpRegisterFonts.php new file mode 100644 index 00000000000000..c57c1406a6cacc --- /dev/null +++ b/phpunit/tests/fonts-api/wpRegisterFonts.php @@ -0,0 +1,104 @@ +<?php +/** + * Integration tests for wp_register_fonts(). + * + * @package WordPress + * @subpackage Fonts API + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fontsapi + * @covers ::wp_register_fonts + * @covers WP_Fonts::add + * @covers WP_Fonts::add_variation + */ +class Tests_Fonts_WpRegisterFonts extends WP_Fonts_TestCase { + + /** + * @dataProvider data_fonts + * + * @param array $fonts Array of fonts to test. + * @param array $expected Expected results. + */ + public function test_should_register( array $fonts, array $expected ) { + $actual = wp_register_fonts( $fonts ); + $this->assertSame( $expected['wp_register_fonts'], $actual, 'Font family handle(s) should be returned' ); + $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Web fonts should match registered queue' ); + } + + /** + * @dataProvider data_fonts + * + * @param array $fonts Array of fonts to test. + */ + public function test_should_not_enqueue_on_registration( array $fonts ) { + wp_register_fonts( $fonts ); + $this->assertEmpty( $this->get_enqueued_handles() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_fonts() { + return array( + 'font family keyed with slug' => array( + 'fonts' => array( + 'source-serif-pro' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + ), + 'expected' => array( + 'wp_register_fonts' => array( 'source-serif-pro' ), + 'get_registered' => array( + 'source-serif-pro', + 'source-serif-pro-200-900-normal', + ), + ), + ), + 'font family keyed with name' => array( + 'fonts' => array( + 'Source Serif Pro' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + ), + 'expected' => array( + 'wp_register_fonts' => array( 'source-serif-pro' ), + 'get_registered' => array( + 'source-serif-pro', + 'source-serif-pro-200-900-normal', + 'source-serif-pro-200-900-italic', + ), + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-face/base.php b/phpunit/tests/fonts/font-face/base.php new file mode 100644 index 00000000000000..4d8d487cb47c97 --- /dev/null +++ b/phpunit/tests/fonts/font-face/base.php @@ -0,0 +1,187 @@ +<?php +/** + * Test case for the Fonts tests. + * + * @package WordPress + * @subpackage Fonts + */ + +require_once __DIR__ . '/wp-font-face-tests-dataset.php'; +/** + * Abstracts the common tasks for the Font Face tests. + */ +abstract class WP_Font_Face_TestCase extends WP_UnitTestCase { + use WP_Font_Face_Tests_Datasets; + + /** + * Current error reporting level (before a test changes it). + * + * @var null|int + */ + protected $error_reporting_level = null; + + /** + * Reflection data store for non-public property access. + * + * @var ReflectionProperty[] + */ + protected $property = array(); + + /** + * Indicates the test class uses `switch_theme()` and requires + * set_up and tear_down fixtures to set and reset hooks and memory. + * + * If a test class switches themes, set this property to `true`. + * + * @var bool + */ + protected static $requires_switch_theme_fixtures = false; + + /** + * Theme root directory. + * + * @var string + */ + protected static $theme_root; + + /** + * Original theme directory. + * + * @var string + */ + protected $orig_theme_dir; + + /** + * Administrator ID. + * + * @var int + */ + protected static $administrator_id = 0; + + public static function set_up_before_class() { + parent::set_up_before_class(); + + if ( self::$requires_switch_theme_fixtures ) { + self::$theme_root = realpath( GUTENBERG_DIR_TESTDATA . '/themedir1' ); + } + } + + public static function tear_down_after_class() { + // Reset static flags. + self::$requires_switch_theme_fixtures = false; + + parent::tear_down_after_class(); + } + + public function set_up() { + parent::set_up(); + + if ( self::$requires_switch_theme_fixtures ) { + $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; + + // /themes is necessary as theme.php functions assume /themes is the root if there is only one root. + $GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes', self::$theme_root ); + + // Set up the new root. + add_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); + + // Clear caches. + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + } + } + + public function tear_down() { + $this->property = array(); + + // Reset the error reporting when modified within a test. + if ( is_int( $this->error_reporting_level ) ) { + error_reporting( $this->error_reporting_level ); + $this->error_reporting_level = null; + } + + if ( self::$requires_switch_theme_fixtures ) { + // Clean up the filters to modify the theme root. + remove_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); + remove_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); + remove_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); + + WP_Theme_JSON_Resolver::clean_cached_data(); + if ( class_exists( 'WP_Theme_JSON_Resolver_Gutenberg' ) ) { + WP_Theme_JSON_Resolver_Gutenberg::clean_cached_data(); + } + } + + parent::tear_down(); + } + + public function clean_up_global_scope() { + parent::clean_up_global_scope(); + + if ( self::$requires_switch_theme_fixtures ) { + $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; + wp_clean_themes_cache(); + + if ( function_exists( 'wp_clean_theme_json_cache' ) ) { + wp_clean_theme_json_cache(); + } + + if ( function_exists( '_gutenberg_clean_theme_json_caches' ) ) { + _gutenberg_clean_theme_json_caches(); + } + + unset( $GLOBALS['wp_themes'] ); + } + } + + public function filter_set_theme_root() { + return self::$theme_root; + } + + protected function get_reflection_property( $property_name, $class = 'WP_Fonts' ) { + $property = new ReflectionProperty( $class, $property_name ); + $property->setAccessible( true ); + + return $property; + } + + protected function get_property_value( $property_name, $class, $wp_fonts = null ) { + $property = $this->get_reflection_property( $property_name, $class ); + + if ( ! $wp_fonts ) { + $wp_fonts = wp_fonts(); + } + + return $property->getValue( $wp_fonts ); + } + + protected function setup_property( $class, $property_name ) { + $key = $this->get_property_key( $class, $property_name ); + + if ( ! isset( $this->property[ $key ] ) ) { + $this->property[ $key ] = new ReflectionProperty( $class, 'providers' ); + $this->property[ $key ]->setAccessible( true ); + } + + return $this->property[ $key ]; + } + + protected function get_property_key( $class, $property_name ) { + return $class . '::$' . $property_name; + } + + /** + * Opens the accessibility to access the given private or protected method. + * + * @param string $method_name Name of the method to open. + * @return ReflectionMethod Instance of the method, ie to invoke it in the test. + */ + protected function get_reflection_method( $method_name ) { + $method = new ReflectionMethod( WP_Fonts::class, $method_name ); + $method->setAccessible( true ); + + return $method; + } +} diff --git a/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php b/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php new file mode 100644 index 00000000000000..00ae66e865a89c --- /dev/null +++ b/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php @@ -0,0 +1,274 @@ +<?php +/** + * Datasets for unit and integration tests. + * + * @package WordPress + * @subpackage Fonts + */ + +/** + * Trait for reusing datasets within the Fonts tests. + */ +trait WP_Font_Face_Tests_Datasets { + /** + * Data provider. + * + * @return array + */ + public function data_should_print_given_fonts() { + return array( + 'single truetype format font' => array( + 'fonts' => array( + 'Inter' => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf', + ), + 'font-family' => 'Inter', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '200', + ), + ), + ), + 'expected' => <<<CSS +@font-face{font-family:Inter;font-style:normal;font-weight:200;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf') format('truetype');font-stretch:normal;} +CSS + , + ), + 'multiple truetype format fonts' => array( + 'fonts' => array( + 'Inter' => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf', + ), + 'font-family' => 'Inter', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '200', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/inter/Inter-VariableFont_slnt-Italic,wght.ttf', + ), + 'font-family' => 'Inter', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '900', + ), + ), + ), + 'expected' => <<<CSS +@font-face{font-family:Inter;font-style:normal;font-weight:200;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf') format('truetype');font-stretch:normal;} +@font-face{font-family:Inter;font-style:italic;font-weight:900;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt-Italic,wght.ttf') format('truetype');font-stretch:normal;} +CSS + , + ), + 'single woff2 format font' => array( + 'fonts' => array( + 'DM Sans' => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/dm-sans/DMSans-Regular.woff2', + ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + ), + ), + 'expected' => <<<CSS +@font-face{font-family:"DM Sans";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/assets/fonts/dm-sans/DMSans-Regular.woff2') format('woff2');font-stretch:normal;} +CSS + , + ), + 'multiple woff2 format fonts' => array( + 'fonts' => array( + 'DM Sans' => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/dm-sans/DMSans-Regular.woff2', + ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/dm-sans/DMSans-Regular-Italic.woff2', + ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '400', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/dm-sans/DMSans-Bold.woff2', + ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '700', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/dm-sans/DMSans-Bold-Italic.woff2', + ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '700', + ), + ), + 'IBM Plex Mono' => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Light.woff2', + ), + 'font-family' => 'IBM Plex Mono', + 'font-display' => 'block', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '300', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.woff2', + ), + 'font-family' => 'IBM Plex Mono', + 'font-display' => 'block', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Italic.woff2', + ), + 'font-family' => 'IBM Plex Mono', + 'font-display' => 'block', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '400', + ), + array( + 'src' => + array( + 'https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Bold.woff2', + ), + 'font-family' => 'IBM Plex Mono', + 'font-display' => 'block', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '700', + ), + ), + ), + 'expected' => <<<CSS +@font-face{font-family:"DM Sans";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/assets/fonts/dm-sans/DMSans-Regular.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"DM Sans";font-style:italic;font-weight:400;font-display:fallback;src:url('https://example.org/assets/fonts/dm-sans/DMSans-Regular-Italic.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"DM Sans";font-style:normal;font-weight:700;font-display:fallback;src:url('https://example.org/assets/fonts/dm-sans/DMSans-Bold.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"DM Sans";font-style:italic;font-weight:700;font-display:fallback;src:url('https://example.org/assets/fonts/dm-sans/DMSans-Bold-Italic.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:300;font-display:block;src:url('https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Light.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:400;font-display:block;src:url('https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"IBM Plex Mono";font-style:italic;font-weight:400;font-display:block;src:url('https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Italic.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:700;font-display:block;src:url('https://example.org/assets/fonts/ibm-plex-mono/IBMPlexMono-Bold.woff2') format('woff2');font-stretch:normal;} +CSS + , + ), + ); + } + + public function get_expected_fonts_for_fonts_block_theme( $key = '' ) { + static $data = null; + + if ( null === $data ) { + $uri = get_stylesheet_directory_uri() . '/assets/fonts/'; + $data = array( + 'fonts' => array( + 'DM Sans' => array( + array( + 'src' => array( $uri . 'dm-sans/DMSans-Regular.woff2' ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + array( + 'src' => array( $uri . 'dm-sans/DMSans-Regular-Italic.woff2' ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '400', + ), + array( + 'src' => array( $uri . 'dm-sans/DMSans-Bold.woff2' ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '700', + ), + array( + 'src' => array( $uri . 'dm-sans/DMSans-Bold-Italic.woff2' ), + 'font-family' => 'DM Sans', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '700', + ), + ), + 'Source Serif Pro' => array( + array( + 'src' => array( $uri . 'source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ), + 'font-family' => 'Source Serif Pro', + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '200 900', + ), + array( + 'src' => array( $uri . 'source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2' ), + 'font-family' => 'Source Serif Pro', + 'font-stretch' => 'normal', + 'font-style' => 'italic', + 'font-weight' => '200 900', + ), + ), + ), + 'font_face_styles' => <<<CSS +@font-face{font-family:"DM Sans";font-style:normal;font-weight:400;font-display:fallback;src:url('{$uri}dm-sans/DMSans-Regular.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"DM Sans";font-style:italic;font-weight:400;font-display:fallback;src:url('{$uri}dm-sans/DMSans-Regular-Italic.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"DM Sans";font-style:normal;font-weight:700;font-display:fallback;src:url('{$uri}dm-sans/DMSans-Bold.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"DM Sans";font-style:italic;font-weight:700;font-display:fallback;src:url('{$uri}dm-sans/DMSans-Bold-Italic.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-display:fallback;src:url('{$uri}source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');font-stretch:normal;} +@font-face{font-family:"Source Serif Pro";font-style:italic;font-weight:200 900;font-display:fallback;src:url('{$uri}source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2') format('woff2');font-stretch:normal;} +CSS + , + ); + } + + if ( isset( $data[ $key ] ) ) { + return $data[ $key ]; + } + + return $data; + } +} diff --git a/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php b/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php new file mode 100644 index 00000000000000..ac68c006ff4d4c --- /dev/null +++ b/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php @@ -0,0 +1,47 @@ +<?php +/** + * Test case for WP_Font_Face::generate_and_print(). + * + * @package WordPress + * @subpackage Fonts + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * Test WP_Font_Face::generate_and_print(). + * + * @package WordPress + * @subpackage Fonts + * + * @since X.X.X + * @group fonts + * @group fontface + * @covers WP_Font_Face::generate_and_print + */ +class Tests_Fonts_WPFontFace_GenerateAndPrint extends WP_UnitTestCase { + use WP_Font_Face_Tests_Datasets; + + public function test_should_not_generate_and_print_when_no_fonts() { + $font_face = new WP_Font_Face(); + $fonts = array(); + + $this->expectOutputString( '' ); + $font_face->generate_and_print( $fonts ); + } + + /** + * @dataProvider data_should_print_given_fonts + * + * @param array $fonts Prepared fonts. + * @param string $expected Expected CSS. + */ + public function test_should_generate_and_print_given_fonts( array $fonts, $expected ) { + $font_face = new WP_Font_Face(); + $style_element = "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n"; + $expected_output = sprintf( $style_element, $expected ); + + $this->expectOutputString( $expected_output ); + $font_face->generate_and_print( $fonts ); + } +} diff --git a/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php b/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php new file mode 100644 index 00000000000000..51342b19cafb12 --- /dev/null +++ b/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php @@ -0,0 +1,106 @@ +<?php +/** + * Test case for WP_Font_Face_Resolver::get_fonts_from_theme_json(). + * + * @package WordPress + * @subpackage Fonts + */ + +require_once dirname( __DIR__ ) . '/base.php'; + +/** + * Tests WP_Font_Face_Resolver::get_fonts_from_theme_json(). + * + * @package WordPress + * @subpackage Fonts + * + * @since X.X.X + * @group fonts + * @group fontface + * @covers WP_Font_Face_Resolver::get_fonts_from_theme_json + */ +class Tests_Fonts_WPFontFaceResolver_GetFontsFromThemeJson extends WP_Font_Face_TestCase { + const FONTS_THEME = 'fonts-block-theme'; + + public static function set_up_before_class() { + self::$requires_switch_theme_fixtures = true; + + parent::set_up_before_class(); + } + + public function test_should_return_empty_array_when_no_fonts_defined_in_theme() { + switch_theme( 'block-theme' ); + + $fonts = WP_Font_Face_Resolver::get_fonts_from_theme_json(); + $this->assertIsArray( $fonts, 'Should return an array data type' ); + $this->assertEmpty( $fonts, 'Should return an empty array' ); + } + + public function test_should_return_all_fonts_from_theme() { + switch_theme( static::FONTS_THEME ); + + $actual = WP_Font_Face_Resolver::get_fonts_from_theme_json(); + $expected = $this->get_expected_fonts_for_fonts_block_theme( 'fonts' ); + $this->assertSame( $expected, $actual ); + } + + /** + * @dataProvider data_should_replace_src_file_placeholder + * + * @param string $font_name Font's name. + * @param string $font_index Font's index in the $fonts array. + * @param string $expected Expected src. + */ + public function test_should_replace_src_file_placeholder( $font_name, $font_index, $expected ) { + switch_theme( static::FONTS_THEME ); + + $fonts = WP_Font_Face_Resolver::get_fonts_from_theme_json(); + + $actual = $fonts[ $font_name ][ $font_index ]['src'][0]; + $expected = get_stylesheet_directory_uri() . $expected; + + $this->assertStringNotContainsString( 'file:./', $actual, 'Font src should not contain the "file:./" placeholder' ); + $this->assertSame( $expected, $actual, 'Font src should be an URL to its file' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_replace_src_file_placeholder() { + return array( + // Theme's theme.json. + 'DM Sans: 400 normal' => array( + 'font_name' => 'DM Sans', + 'font_index' => 0, + 'expected' => '/assets/fonts/dm-sans/DMSans-Regular.woff2', + ), + 'DM Sans: 400 italic' => array( + 'font_name' => 'DM Sans', + 'font_index' => 1, + 'expected' => '/assets/fonts/dm-sans/DMSans-Regular-Italic.woff2', + ), + 'DM Sans: 700 normal' => array( + 'font_name' => 'DM Sans', + 'font_index' => 2, + 'expected' => '/assets/fonts/dm-sans/DMSans-Bold.woff2', + ), + 'DM Sans: 700 italic' => array( + 'font_name' => 'DM Sans', + 'font_index' => 3, + 'expected' => '/assets/fonts/dm-sans/DMSans-Bold-Italic.woff2', + ), + 'Source Serif Pro: 200-900 normal' => array( + 'font_name' => 'Source Serif Pro', + 'font_index' => 0, + 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'Source Serif Pro: 200-900 italic' => array( + 'font_name' => 'Source Serif Pro', + 'font_index' => 1, + 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-face/wpPrintFontFaces.php b/phpunit/tests/fonts/font-face/wpPrintFontFaces.php new file mode 100644 index 00000000000000..cb3ff2471cc64f --- /dev/null +++ b/phpunit/tests/fonts/font-face/wpPrintFontFaces.php @@ -0,0 +1,65 @@ +<?php +/** + * Test case for wp_print_font_faces(). + * + * @package WordPress + * @subpackage Fonts + */ + +require_once __DIR__ . '/base.php'; + +/** + * Test wp_print_font_faces(). + * + * @package WordPress + * @subpackage Fonts + * + * @since X.X.X + * @group fonts + * @group fontface + * @covers wp_print_font_faces + */ +class Tests_Fonts_WPPrintFontFaces extends WP_Font_Face_TestCase { + const FONTS_THEME = 'fonts-block-theme'; + + public static function set_up_before_class() { + self::$requires_switch_theme_fixtures = true; + + parent::set_up_before_class(); + } + + public function test_should_not_print_when_no_fonts() { + switch_theme( 'block-theme' ); + + $this->expectOutputString( '' ); + wp_print_font_faces(); + } + + /** + * @dataProvider data_should_print_given_fonts + * + * @param array $fonts Fonts to process. + * @param string $expected Expected CSS. + */ + public function test_should_print_given_fonts( array $fonts, $expected ) { + $expected_output = $this->get_expected_styles_output( $expected ); + + $this->expectOutputString( $expected_output ); + wp_print_font_faces( $fonts ); + } + + public function test_should_print_fonts_in_merged_data() { + switch_theme( static::FONTS_THEME ); + + $expected = $this->get_expected_fonts_for_fonts_block_theme( 'font_face_styles' ); + $expected_output = $this->get_expected_styles_output( $expected ); + + $this->expectOutputString( $expected_output ); + wp_print_font_faces(); + } + + private function get_expected_styles_output( $styles ) { + $style_element = "<style id='wp-fonts-local' type='text/css'>\n%s\n</style>\n"; + return sprintf( $style_element, $styles ); + } +} diff --git a/phpunit/tests/fonts/font-library/class-wp-rest-font-library-controller.php b/phpunit/tests/fonts/font-library/class-wp-rest-font-library-controller.php new file mode 100644 index 00000000000000..f55d221acba965 --- /dev/null +++ b/phpunit/tests/fonts/font-library/class-wp-rest-font-library-controller.php @@ -0,0 +1,480 @@ +<?php +/** + * Tests for WP_REST_Font_Library_Controller class + * + * @package Gutenberg + * @subpackage Font Library + */ + +/** + * @coversDefaultClass WP_REST_Font_Library_Controller + */ +class WP_REST_Font_Library_Controller_Test extends WP_UnitTestCase { + /** + * @var int + */ + protected static $admin_id; + + /** + * Creates fake data before our tests run. + * + * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. + */ + public static function wpSetupBeforeClass( $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + } + + /** + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/fonts', $routes, 'Rest server has not the fonts path intialized.' ); + $this->assertCount( 2, $routes['/wp/v2/fonts'], 'Rest server has not the 2 fonts paths initialized.' ); + $this->assertArrayHasKey( 'POST', $routes['/wp/v2/fonts'][0]['methods'], 'Rest server has not the POST method for fonts intialized.' ); + $this->assertArrayHasKey( 'DELETE', $routes['/wp/v2/fonts'][1]['methods'], 'Rest server has not the DELETE method for fonts intialized.' ); + } + + /** + * @covers ::uninstall_fonts + */ + public function test_uninstall_non_existing_fonts() { + wp_set_current_user( self::$admin_id ); + $uninstall_request = new WP_REST_Request( 'DELETE', '/wp/v2/fonts' ); + + $non_existing_font_data = array( + array( + 'slug' => 'non-existing-font', + 'name' => 'Non existing font', + ), + array( + 'slug' => 'another-not-installed-font', + 'name' => 'Another not installed font', + ), + ); + + $uninstall_request->set_param( 'fontFamilies', $non_existing_font_data ); + $response = rest_get_server()->dispatch( $uninstall_request ); + $response->get_data(); + $this->assertSame( 500, $response->get_status(), 'The response status is not 500.' ); + } + + + /** + * @covers ::install_fonts + * @covers ::uninstall_fonts + * + * @dataProvider data_install_and_uninstall_fonts + * + * @param array $font_families Font families to install in theme.json format. + * @param array $files Font files to install. + * @param array $expected_response Expected response data. + */ + public function test_install_and_uninstall_fonts( $font_families, $files, $expected_response ) { + wp_set_current_user( self::$admin_id ); + $install_request = new WP_REST_Request( 'POST', '/wp/v2/fonts' ); + $font_families_json = json_encode( $font_families ); + $install_request->set_param( 'fontFamilies', $font_families_json ); + $install_request->set_file_params( $files ); + $response = rest_get_server()->dispatch( $install_request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); + $this->assertCount( count( $expected_response ), $data, 'Not all the font families were installed correctly.' ); + + // Checks that the font families were installed correctly. + for ( $family_index = 0; $family_index < count( $data ); $family_index++ ) { + $installed_font = $data[ $family_index ]; + $expected_font = $expected_response[ $family_index ]; + + if ( isset( $installed_font['fontFace'] ) || isset( $expected_font['fontFace'] ) ) { + for ( $face_index = 0; $face_index < count( $installed_font['fontFace'] ); $face_index++ ) { + // Checks that the font asset were created correctly. + $this->assertStringEndsWith( $expected_font['fontFace'][ $face_index ]['src'], $installed_font['fontFace'][ $face_index ]['src'], 'The src of the fonts were not updated as expected.' ); + // Removes the src from the response to compare the rest of the data. + unset( $installed_font['fontFace'][ $face_index ]['src'] ); + unset( $expected_font['fontFace'][ $face_index ]['src'] ); + } + } + + // Compares if the rest of the data is the same. + $this->assertEquals( $expected_font, $installed_font, 'The endpoint answer is not as expected.' ); + } + + $uninstall_request = new WP_REST_Request( 'DELETE', '/wp/v2/fonts' ); + $uninstall_request->set_param( 'fontFamilies', $font_families ); + $response = rest_get_server()->dispatch( $uninstall_request ); + $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); + } + + /** + * Data provider for test_install_and_uninstall_fonts + */ + public function data_install_and_uninstall_fonts() { + + $temp_file_path1 = wp_tempnam( 'Piazzola1-' ); + file_put_contents( $temp_file_path1, 'Mocking file content' ); + $temp_file_path2 = wp_tempnam( 'Monteserrat-' ); + file_put_contents( $temp_file_path2, 'Mocking file content' ); + + return array( + + 'google_fonts_to_download' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + ), + ), + ), + array( + 'fontFamily' => 'Montserrat', + 'slug' => 'montserrat', + 'name' => 'Montserrat', + 'fontFace' => array( + array( + 'fontFamily' => 'Montserrat', + 'fontStyle' => 'normal', + 'fontWeight' => '100', + 'src' => 'http://fonts.gstatic.com/s/montserrat/v25/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/montserrat/v25/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf', + ), + ), + ), + ), + 'files' => array(), + 'expected_response' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => '/wp-content/uploads/fonts/piazzolla_normal_400.ttf', + ), + ), + ), + array( + 'fontFamily' => 'Montserrat', + 'slug' => 'montserrat', + 'name' => 'Montserrat', + 'fontFace' => array( + array( + 'fontFamily' => 'Montserrat', + 'fontStyle' => 'normal', + 'fontWeight' => '100', + 'src' => '/wp-content/uploads/fonts/montserrat_normal_100.ttf', + ), + ), + ), + ), + ), + + 'google_fonts_to_use_as_is' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + ), + ), + ), + array( + 'fontFamily' => 'Montserrat', + 'slug' => 'montserrat', + 'name' => 'Montserrat', + 'fontFace' => array( + array( + 'fontFamily' => 'Montserrat', + 'fontStyle' => 'normal', + 'fontWeight' => '100', + 'src' => 'http://fonts.gstatic.com/s/montserrat/v25/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf', + ), + ), + ), + ), + 'files' => array(), + 'expected_response' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + ), + ), + ), + array( + 'fontFamily' => 'Montserrat', + 'slug' => 'montserrat', + 'name' => 'Montserrat', + 'fontFace' => array( + array( + 'fontFamily' => 'Montserrat', + 'fontStyle' => 'normal', + 'fontWeight' => '100', + 'src' => 'http://fonts.gstatic.com/s/montserrat/v25/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf', + + ), + ), + ), + ), + ), + + 'fonts_without_font_faces' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Arial', + 'slug' => 'arial', + 'name' => 'Arial', + ), + ), + 'files' => array(), + 'expected_response' => array( + array( + 'fontFamily' => 'Arial', + 'slug' => 'arial', + 'name' => 'Arial', + ), + ), + ), + + 'fonts_with_local_fonts_assets' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files0', + ), + ), + ), + array( + 'fontFamily' => 'Montserrat', + 'slug' => 'montserrat', + 'name' => 'Montserrat', + 'fontFace' => array( + array( + 'fontFamily' => 'Montserrat', + 'fontStyle' => 'normal', + 'fontWeight' => '100', + 'uploadedFile' => 'files1', + ), + ), + ), + ), + 'files' => array( + 'files0' => array( + 'name' => 'piazzola1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => $temp_file_path1, + 'error' => 0, + 'size' => 123, + ), + 'files1' => array( + 'name' => 'montserrat1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => $temp_file_path2, + 'error' => 0, + 'size' => 123, + ), + ), + 'expected_response' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => '/wp-content/uploads/fonts/piazzolla_normal_400.ttf', + ), + ), + ), + array( + 'fontFamily' => 'Montserrat', + 'slug' => 'montserrat', + 'name' => 'Montserrat', + 'fontFace' => array( + array( + 'fontFamily' => 'Montserrat', + 'fontStyle' => 'normal', + 'fontWeight' => '100', + 'src' => '/wp-content/uploads/fonts/montserrat_normal_100.ttf', + ), + ), + ), + ), + ), + ); + } + + /** + * Tests failure when fonfaces has improper inputs + * + * @covers ::install_fonts + * + * @dataProvider data_install_with_improper_inputs + * + * @param array $font_families Font families to install in theme.json format. + * @param array $files Font files to install. + */ + public function test_install_with_improper_inputs( $font_families, $files = array() ) { + wp_set_current_user( self::$admin_id ); + + $install_request = new WP_REST_Request( 'POST', '/wp/v2/fonts' ); + $font_families_json = json_encode( $font_families ); + $install_request->set_param( 'fontFamilies', $font_families_json ); + $install_request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $install_request ); + $this->assertSame( 400, $response->get_status() ); + } + + /** + * Data provider for test_install_with_improper_inputs + */ + public function data_install_with_improper_inputs() { + $temp_file_path1 = wp_tempnam( 'Piazzola1-' ); + file_put_contents( $temp_file_path1, 'Mocking file content' ); + + return array( + 'not a font families array' => array( + 'font_families' => 'This is not an array', + ), + + 'empty array' => array( + 'font_families' => array(), + ), + + 'without slug' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + ), + ), + ), + + 'with improper font face property' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFace' => 'This is not an array', + ), + ), + ), + + 'with empty font face property' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFace' => array(), + ), + ), + ), + + 'fontface referencing uploaded file without uploaded files' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files0', + ), + ), + ), + ), + 'files' => array(), + ), + + 'fontface referencing uploaded file without uploaded files' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files666', + ), + ), + ), + ), + 'files' => array( + 'files0' => array( + 'name' => 'piazzola1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => $temp_file_path1, + 'error' => 0, + 'size' => 123, + ), + ), + ), + + 'fontface with incompatible properties (downloadFromUrl and uploadedFile together)' => array( + 'font_families' => array( + array( + 'fontFamily' => 'Piazzolla', + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + 'uploadedFile' => 'files0', + ), + ), + ), + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php b/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php new file mode 100644 index 00000000000000..3a1e387c3651b2 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php @@ -0,0 +1,62 @@ +<?php +/** + * Test WP_Font_Family::__construct(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::__construct + */ +class Tests_Fonts_WpFontFamily_Construct extends WP_UnitTestCase { + + public function test_should_initialize_data() { + $property = new ReflectionProperty( WP_Font_Family::class, 'data' ); + $property->setAccessible( true ); + + $font_data = array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + ); + $font_family = new WP_Font_Family( $font_data ); + + $actual = $property->getValue( $font_family ); + $property->setAccessible( false ); + + $this->assertSame( $font_data, $actual ); + } + + /** + * @dataProvider data_should_throw_exception + * + * @param mixed $font_data Data to test. + */ + public function test_should_throw_exception( $font_data ) { + $this->expectException( 'Exception' ); + $this->expectExceptionMessage( 'Font family data is missing the slug.' ); + + new WP_Font_Family( $font_data ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_throw_exception() { + return array( + 'no slug' => array( + array( + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + ), + ), + 'empty array' => array( array() ), + 'boolean instead of array' => array( false ), + 'null instead of array' => array( null ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/base.php b/phpunit/tests/fonts/font-library/wpFontFamily/base.php new file mode 100644 index 00000000000000..9253885c6b3b2d --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/base.php @@ -0,0 +1,75 @@ +<?php +/** + * Test Case for WP_Font_Family tests. + * + * @package WordPress + * @subpackage Font Library + */ +abstract class WP_Font_Family_UnitTestCase extends WP_UnitTestCase { + + /** + * Fonts directory (in uploads). + * + * @var string + */ + protected static $fonts_dir; + + /** + * Merriweather test data shared by tests. + * + * @var array + */ + protected $merriweather = array( + 'font_data' => array(), + 'files_data' => array(), + 'font_filename' => '', + ); + + public static function set_up_before_class() { + parent::set_up_before_class(); + + $uploads_dir = wp_upload_dir(); + static::$fonts_dir = $uploads_dir['basedir'] . '/fonts/'; + } + + public function set_up() { + parent::set_up(); + + $merriweather_tmp_name = wp_tempnam( 'Merriweather-' ); + file_put_contents( $merriweather_tmp_name, 'Mocking file content' ); + $this->merriweather = array( + 'font_data' => array( + 'name' => 'Merriweather', + 'slug' => 'merriweather', + 'fontFamily' => 'Merriweather', + 'fontFace' => array( + array( + 'fontFamily' => 'Merriweather', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files0', + ), + ), + ), + 'files_data' => array( + 'files0' => array( + 'name' => 'merriweather.ttf', + 'type' => 'font/ttf', + 'tmp_name' => $merriweather_tmp_name, + 'error' => 0, + 'size' => 123, + ), + ), + 'font_filename' => static::$fonts_dir . 'merriweather_normal_400.ttf', + ); + } + + public function tear_down() { + // Clean up the /fonts directory. + foreach ( $this->files_in_dir( static::$fonts_dir ) as $file ) { + @unlink( $file ); + } + + parent::tear_down(); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/getData.php b/phpunit/tests/fonts/font-library/wpFontFamily/getData.php new file mode 100644 index 00000000000000..2de88ad3aae375 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/getData.php @@ -0,0 +1,94 @@ +<?php +/** + * Test WP_Font_Family::get_data(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::get_data + */ +class Tests_Fonts_WpFontLibrary_GetData extends WP_UnitTestCase { + + /** + * @dataProvider data_should_get_data + * + * @param array $font_data Font family data in theme.json format. + */ + public function test_should_get_data( $font_data ) { + $font = new WP_Font_Family( $font_data ); + $this->assertSame( $font_data, $font->get_data() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_get_data() { + return array( + 'with one google font face to be downloaded' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + ), + ), + ), + ), + 'with one google font face to not be downloaded' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + ), + ), + ), + ), + 'without font faces' => array( + array( + 'name' => 'Arial', + 'slug' => 'arial', + 'fontFamily' => 'Arial', + 'fontFace' => array(), + ), + ), + 'with local files' => array( + array( + 'name' => 'Inter', + 'slug' => 'inter', + 'fontFamily' => 'Inter', + 'fontFace' => array( + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files0', + ), + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'normal', + 'fontWeight' => '500', + 'uploadedFile' => 'files1', + ), + ), + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/getDataAsJson.php b/phpunit/tests/fonts/font-library/wpFontFamily/getDataAsJson.php new file mode 100644 index 00000000000000..b18b2d0b4f4b38 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/getDataAsJson.php @@ -0,0 +1,67 @@ +<?php +/** + * Test WP_Font_Family::get_data_as_json(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::get_data_as_json + */ +class Tests_Fonts_WpFontFamily_GetDataAsJson extends WP_UnitTestCase { + + /** + * @dataProvider data_should_get_data_as_json + * + * @param array $font_data Font family data in theme.json format. + * @param string $expected Expected result. + */ + public function test_should_get_data_as_json( $font_data, $expected ) { + $font = new WP_Font_Family( $font_data ); + $this->assertSame( $expected, $font->get_data_as_json() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_get_data_as_json() { + return array( + 'piazzolla' => array( + 'font_data' => array( + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'src' => 'https://example.com/fonts/piazzolla_italic_400.ttf', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + ), + ), + ), + 'expected' => '{"slug":"piazzolla","fontFamily":"Piazzolla","name":"Piazzolla","fontFace":[{"fontFamily":"Piazzolla","src":"https:\/\/example.com\/fonts\/piazzolla_italic_400.ttf","fontStyle":"italic","fontWeight":"400"}]}', + ), + 'piazzolla2' => array( + 'font_data' => array( + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'name' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'src' => 'https://example.com/fonts/piazzolla_italic_400.ttf', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + ), + ), + ), + 'expected' => '{"slug":"piazzolla","fontFamily":"Piazzolla","name":"Piazzolla","fontFace":[{"fontFamily":"Piazzolla","src":"https:\/\/example.com\/fonts\/piazzolla_italic_400.ttf","fontStyle":"italic","fontWeight":"400"}]}', + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/getFontPost.php b/phpunit/tests/fonts/font-library/wpFontFamily/getFontPost.php new file mode 100644 index 00000000000000..eb42cc3ee08986 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/getFontPost.php @@ -0,0 +1,42 @@ +<?php +/** + * Test WP_Font_Family::get_font_post(). + * + * @package WordPress + * @subpackage Font Library + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::get_font_post + */ +class Tests_Fonts_WpFontFamily_GetFontPost extends WP_Font_Family_UnitTestCase { + + public function test_should_return_post() { + // Set up the post. + $post = array( + 'post_title' => $this->merriweather['font_data']['name'], + 'post_name' => $this->merriweather['font_data']['slug'], + 'post_type' => 'wp_font_family', + 'post_content' => '', + 'post_status' => 'publish', + ); + $post_id = wp_insert_post( $post ); + $font = new WP_Font_Family( $this->merriweather['font_data'] ); + + // Test. + $actual = $font->get_font_post(); + $this->assertInstanceOf( WP_Post::class, $actual, 'Font post should exist' ); + $this->assertSame( $post_id, $actual->ID, 'Font post ID should match' ); + } + + public function test_should_return_null_when_post_does_not_exist() { + $font = new WP_Font_Family( $this->merriweather['font_data'] ); + + $this->assertNull( $font->get_font_post() ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/hasFontFaces.php b/phpunit/tests/fonts/font-library/wpFontFamily/hasFontFaces.php new file mode 100644 index 00000000000000..0c153d62aa79dc --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/hasFontFaces.php @@ -0,0 +1,78 @@ +<?php +/** + * Test WP_Font_Family::has_font_faces(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::has_font_faces + */ +class Tests_Fonts_WpFontFamily_HasFontFaces extends WP_UnitTestCase { + + public function test_should_return_true_when_check_succeeds() { + $font_data = array( + 'slug' => 'piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + ), + ), + ); + $font = new WP_Font_Family( $font_data ); + $this->assertTrue( $font->has_font_faces() ); + } + + /** + * @dataProvider data_should_return_false_when_check_fails + * + * @param array $font_data Font family data in theme.json format. + */ + public function test_should_return_false_when_check_fails( $font_data ) { + $font = new WP_Font_Family( $font_data ); + $this->assertFalse( $font->has_font_faces() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_return_false_when_check_fails() { + return array( + 'wrong fontFace key' => array( + array( + 'slug' => 'piazzolla', + 'fontFaces' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + ), + ), + ), + ), + 'without font faces' => array( + array( + 'slug' => 'piazzolla', + ), + ), + 'empty array' => array( + array( + 'slug' => 'piazzolla', + 'fontFace' => array(), + ), + ), + 'null' => array( + array( + 'slug' => 'piazzolla', + 'fontFace' => null, + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/install.php b/phpunit/tests/fonts/font-library/wpFontFamily/install.php new file mode 100644 index 00000000000000..b067124aad9e40 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/install.php @@ -0,0 +1,248 @@ +<?php +/** + * Test WP_Font_Family::install(). + * + * @package WordPress + * @subpackage Font Library + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::install + */ +class Tests_Fonts_WpFontFamily_Install extends WP_Font_Family_UnitTestCase { + + /** + * @dataProvider data_should_not_download_when_no_fontface + * + * @param array $font_data Font family data in theme.json format. + */ + public function test_should_not_download_when_no_fontface( $font_data ) { + $font = new WP_Font_Family( $font_data ); + + // Test. + $font->install(); + $this->assertEmpty( $this->files_in_dir( static::$fonts_dir ), 'Font directory should be empty' ); + $this->assertInstanceOf( WP_Post::class, $font->get_font_post(), 'Font post should exist after install' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_not_download_when_no_fontface() { + return array( + 'wrong fontFace key' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFaces' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + ), + ), + ), + ), + 'without font faces' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + ), + ), + 'empty array' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array(), + ), + ), + 'null' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => null, + ), + ), + ); + } + + /** + * @dataProvider data_should_download_fontfaces + * + * @param array $font_data Font family data in theme.json format. + * @param array $expected Expected font filename(s). + */ + public function test_should_download_fontfaces_and_create_post( $font_data, array $expected ) { + // Pre-checks to ensure starting conditions. + foreach ( $expected as $font_file ) { + $font_file = static::$fonts_dir . $font_file; + $this->assertFileDoesNotExist( $font_file, "Font file [{$font_file}] should not exist in the uploads/fonts/ directory after installing" ); + } + $font = new WP_Font_Family( $font_data ); + + // Test. + $font->install(); + foreach ( $expected as $font_file ) { + $font_file = static::$fonts_dir . $font_file; + $this->assertFileExists( $font_file, "Font file [{$font_file}] should exists in the uploads/fonts/ directory after installing" ); + } + $this->assertInstanceOf( WP_Post::class, $font->get_font_post(), 'Font post should exist after install' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_download_fontfaces() { + return array( + '1 font face to download' => array( + 'font_data' => array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', + ), + ), + ), + 'expected' => array( 'piazzolla_italic_400.ttf' ), + ), + '2 font faces to download' => array( + 'font_data' => array( + 'name' => 'Lato', + 'slug' => 'lato', + 'fontFamily' => 'Lato', + 'fontFace' => array( + array( + 'fontFamily' => 'Lato', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHvxk6XweuBCY.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHvxk6XweuBCY.ttf', + ), + array( + 'fontFamily' => 'Lato', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://fonts.gstatic.com/s/lato/v24/S6u8w4BMUTPHjxswWyWrFCbw7A.ttf', + 'downloadFromUrl' => 'http://fonts.gstatic.com/s/lato/v24/S6u8w4BMUTPHjxswWyWrFCbw7A.ttf', + ), + ), + ), + 'expected' => array( 'lato_normal_400.ttf', 'lato_italic_400.ttf' ), + ), + ); + } + + /** + * @dataProvider data_should_move_local_fontfaces + * + * @param array $font_data Font family data in theme.json format. + * @param array $files_data Files data in $_FILES format. + * @param array $expected Expected font filename(s). + */ + public function test_should_move_local_fontfaces( $font_data, array $files_data, array $expected ) { + // Set up the temporary files. + foreach ( $files_data as $file ) { + file_put_contents( $file['tmp_name'], 'Mocking file content' ); + } + + $font = new WP_Font_Family( $font_data ); + $font->install( $files_data ); + + foreach ( $expected as $font_file ) { + $font_file = static::$fonts_dir . $font_file; + $this->assertFileExists( $font_file, "Font file [{$font_file}] should exists in the uploads/fonts/ directory after installing" ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_move_local_fontfaces() { + return array( + '1 local font' => array( + 'font_data' => array( + 'name' => 'Inter', + 'slug' => 'inter', + 'fontFamily' => 'Inter', + 'fontFace' => array( + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'italic', + 'fontWeight' => '900', + 'uploadedFile' => 'files0', + ), + ), + ), + 'files_data' => array( + 'files0' => array( + 'name' => 'inter1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => wp_tempnam( 'Inter-' ), + 'error' => 0, + 'size' => 123, + ), + ), + 'expected' => array( 'inter_italic_900.ttf' ), + ), + '2 local fonts' => array( + 'font_data' => array( + 'name' => 'Lato', + 'slug' => 'lato', + 'fontFamily' => 'Lato', + 'fontFace' => array( + array( + 'fontFamily' => 'Lato', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files1', + ), + array( + 'fontFamily' => 'Lato', + 'fontStyle' => 'normal', + 'fontWeight' => '500', + 'uploadedFile' => 'files2', + ), + ), + ), + 'files_data' => array( + 'files1' => array( + 'name' => 'lato1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => wp_tempnam( 'Lato-' ), + 'error' => 0, + 'size' => 123, + ), + 'files2' => array( + 'name' => 'lato2.ttf', + 'type' => 'font/ttf', + 'tmp_name' => wp_tempnam( 'Lato-' ), + 'error' => 0, + 'size' => 123, + ), + ), + 'expected' => array( 'lato_normal_400.ttf', 'lato_normal_500.ttf' ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/uninstall.php b/phpunit/tests/fonts/font-library/wpFontFamily/uninstall.php new file mode 100644 index 00000000000000..46ebc31a32ef90 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamily/uninstall.php @@ -0,0 +1,194 @@ +<?php +/** + * Test WP_Font_Family::uninstall(). + * + * @package WordPress + * @subpackage Font Library + */ + +require_once __DIR__ . '/base.php'; + +/** + * @group fonts + * @group font-library + * + * @covers WP_Font_Family::uninstall + */ +class Tests_Fonts_WpFontFamily_Uninstall extends WP_Font_Family_UnitTestCase { + + public function test_should_return_error_when_font_not_found() { + // Set up. + $font = new WP_Font_Family( $this->merriweather['font_data'] ); + + // Test. + $actual = $font->uninstall(); + $this->assertWPError( $actual, 'WP_Error should have been returned' ); + $this->assertSame( + array( 'font_family_not_found' => array( 'The font family could not be found.' ) ), + $actual->errors, + 'WP_Error should have "fonts_must_have_same_slug" error' + ); + } + + /** + * @dataProvider data_should_return_error_when_not_able_to_uninstall + * + * @param string $failure_to_mock The filter name to mock the failure. + */ + public function test_should_return_error_when_not_able_to_uninstall( $failure_to_mock ) { + // Set up the font. + add_filter( $failure_to_mock, '__return_empty_string' ); + $font = new WP_Font_Family( $this->merriweather['font_data'] ); + $font->install( $this->merriweather['files_data'] ); + + // Test. + $actual = $font->uninstall(); + $this->assertWPError( $actual, 'WP_Error should be returned' ); + $this->assertSame( + array( 'font_family_not_deleted' => array( 'The font family could not be deleted.' ) ), + $actual->errors, + 'WP_Error should have "font_family_not_deleted" error' + ); + } + + /** + * Data provider. + * + * @return string[][] + */ + public function data_should_return_error_when_not_able_to_uninstall() { + return array( + 'When delete file fails' => array( 'wp_delete_file' ), + 'when delete post fails' => array( 'pre_delete_post' ), + ); + } + + /** + * @dataProvider data_should_uninstall + * + * @param array $font_data Font family data in theme.json format. + * @param array $files_data Files data in $_FILES format. + */ + public function test_should_uninstall( $font_data, array $files_data ) { + // Set up. + foreach ( $files_data as $file ) { + file_put_contents( $file['tmp_name'], 'Mocking file content' ); + } + $font = new WP_Font_Family( $font_data ); + $font->install( $files_data ); + + // Pre-checks to ensure the starting point is as expected. + $this->assertInstanceOf( WP_Post::class, $font->get_font_post(), 'Font post should exist' ); + $this->assertNotEmpty( $this->files_in_dir( static::$fonts_dir ), 'Fonts should be installed' ); + + // Uninstall. + $this->assertTrue( $font->uninstall() ); + + // Test the post and font file(s) were uninstalled. + $this->assertNull( $font->get_font_post(), 'Font post should be deleted after uninstall' ); + $this->assertEmpty( $this->files_in_dir( static::$fonts_dir ), 'Fonts should be uninstalled' ); + } + + /** + * @dataProvider data_should_uninstall + * + * @param array $font_data Font family data in theme.json format. + * @param array $files_data Files data in $_FILES format. + * @param array $files_to_uninstall Files to uninstall. + */ + public function test_should_uninstall_only_its_font_family( $font_data, array $files_data, array $files_to_uninstall ) { + // Set up a different font family instance. This font family should not be uninstalled. + $merriweather = new WP_Font_Family( $this->merriweather['font_data'] ); + $merriweather->install( $this->merriweather['files_data'] ); + + // Set up the font family to be uninstalled. + foreach ( $files_data as $file ) { + file_put_contents( $file['tmp_name'], 'Mocking file content' ); + } + $font = new WP_Font_Family( $font_data ); + $font->install( $files_data ); + + $this->assertTrue( $font->uninstall() ); + + // Check that the files were uninstalled. + foreach ( $files_to_uninstall as $font_file ) { + $font_file = static::$fonts_dir . $font_file; + $this->assertFileDoesNotExist( $font_file, "Font file [{$font_file}] should not exists in the uploads/fonts/ directory after uninstalling" ); + } + // Check that the Merriweather file was not uninstalled. + $this->assertFileExists( $this->merriweather['font_filename'], 'The other font family [Merriweather] should not have been uninstalled.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_uninstall() { + return array( + '1 local font' => array( + 'font_data' => array( + 'name' => 'Inter', + 'slug' => 'inter', + 'fontFamily' => 'Inter', + 'fontFace' => array( + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'italic', + 'fontWeight' => '900', + 'uploadedFile' => 'files0', + ), + ), + ), + 'files_data' => array( + 'files0' => array( + 'name' => 'inter1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => wp_tempnam( 'Inter-' ), + 'error' => 0, + 'size' => 123, + ), + ), + 'files_to_uninstall' => array( 'inter_italic_900.ttf' ), + ), + '2 local fonts' => array( + 'font_data' => array( + 'name' => 'Lato', + 'slug' => 'lato', + 'fontFamily' => 'Lato', + 'fontFace' => array( + array( + 'fontFamily' => 'Lato', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'uploadedFile' => 'files1', + ), + array( + 'fontFamily' => 'Lato', + 'fontStyle' => 'normal', + 'fontWeight' => '500', + 'uploadedFile' => 'files2', + ), + ), + ), + 'files_data' => array( + 'files1' => array( + 'name' => 'lato1.ttf', + 'type' => 'font/ttf', + 'tmp_name' => wp_tempnam( 'Lato-' ), + 'error' => 0, + 'size' => 123, + ), + 'files2' => array( + 'name' => 'lato2.ttf', + 'type' => 'font/ttf', + 'tmp_name' => wp_tempnam( 'Lato-' ), + 'error' => 0, + 'size' => 123, + ), + ), + 'files_to_uninstall' => array( 'lato_normal_400.ttf', 'lato_normal_500.ttf' ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamilyUtils/getFilenameFromFontFace.php b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/getFilenameFromFontFace.php new file mode 100644 index 00000000000000..0bd5b47ea23785 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/getFilenameFromFontFace.php @@ -0,0 +1,64 @@ +<?php +/** + * Test WP_Font_Family_Utils::get_filename_from_font_face(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family_Utils::get_filename_from_font_face + */ +class Tests_Fonts_WpFontsFamilyUtils_GetFilenameFromFontFace extends WP_UnitTestCase { + + /** + * @dataProvider data_should_get_filename + * + * @param string $slug Font slug. + * @param array $font_face Font face data in theme.json format. + * @param string $suffix Suffix added to the resulting filename. + * @param string $expected Expected filename. + */ + public function test_should_get_filename( $slug, $font_face, $suffix, $expected ) { + $this->assertSame( + $expected, + WP_Font_Family_Utils::get_filename_from_font_face( + $slug, + $font_face, + $font_face['src'], + $suffix + ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_get_filename() { + return array( + 'piazzolla' => array( + 'slug' => 'piazzolla', + 'font_face' => array( + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/font_file.ttf', + ), + 'suffix' => '', + 'expected_file_name' => 'piazzolla_italic_400.ttf', + ), + 'inter' => array( + 'slug' => 'inter', + 'font_face' => array( + 'fontStyle' => 'normal', + 'fontWeight' => '600', + 'src' => 'http://example.com/fonts/font_file.otf', + ), + 'suffix' => '', + 'expected_file_name' => 'inter_normal_600.otf', + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamilyUtils/hasFontMimeType.php b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/hasFontMimeType.php new file mode 100644 index 00000000000000..e30c199612b8a9 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/hasFontMimeType.php @@ -0,0 +1,61 @@ +<?php +/** + * Test WP_Font_Family_Utils::has_font_mime_type(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family_Utils::has_font_mime_type + */ +class Tests_Fonts_WpFontsFamilyUtils_HasFontMimeType extends WP_UnitTestCase { + + /** + * @dataProvider data_should_succeed_when_has_mime_type + * + * @param string $font_file Font file path. + */ + public function test_should_succeed_when_has_mime_type( $font_file ) { + $this->assertTrue( WP_Font_Family_Utils::has_font_mime_type( $font_file ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_succeed_when_has_mime_type() { + return array( + 'ttf' => array( '/temp/piazzolla_400_italic.ttf' ), + 'otf' => array( '/temp/piazzolla_400_italic.otf' ), + 'woff' => array( '/temp/piazzolla_400_italic.woff' ), + 'woff2' => array( '/temp/piazzolla_400_italic.woff2' ), + ); + } + + /** + * @dataProvider data_should_fail_when_mime_not_supported + * + * @param string $font_file Font file path. + */ + public function test_should_fail_when_mime_not_supported( $font_file ) { + $this->assertFalse( WP_Font_Family_Utils::has_font_mime_type( $font_file ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_fail_when_mime_not_supported() { + return array( + 'exe' => array( '/temp/test.exe' ), + 'md' => array( '/temp/license.md' ), + 'php' => array( '/temp/test.php' ), + 'txt' => array( '/temp/test.txt' ), + 'zip' => array( '/temp/lato.zip' ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamilyUtils/mergeFontsData.php b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/mergeFontsData.php new file mode 100644 index 00000000000000..b1e9bdc30aec4b --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/mergeFontsData.php @@ -0,0 +1,234 @@ +<?php +/** + * Test WP_Font_Family_Utils::merge_fonts_data(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Family_Utils::merge_fonts_data + */ +class Tests_Fonts_WpFontsFamilyUtils_MergeFontsData extends WP_UnitTestCase { + + /** + * @dataProvider data_should_fail_merge + * + * @param array $font1 First font data in theme.json format. + * @param array $font2 Second font data in theme.json format. + */ + public function test_should_fail_merge( $font1, $font2 ) { + $actual = WP_Font_Family_Utils::merge_fonts_data( $font1, $font2 ); + + $this->assertWPError( $actual, 'WP_Error should have been returned' ); + $this->assertSame( + array( 'fonts_must_have_same_slug' => array( 'Fonts must have the same slug to be merged.' ) ), + $actual->errors, + 'WP_Error should have "fonts_must_have_same_slug" error' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_fail_merge() { + return array( + 'different slugs' => array( + 'font1' => array( + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/piazzolla_400_italic.ttf', + ), + ), + ), + 'font2' => array( + 'slug' => 'inter', + 'fontFamily' => 'Inter', + 'fontFace' => array( + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'normal', + 'fontWeight' => '700', + 'src' => 'http://example.com/fonts/inter_700_normal.ttf', + ), + ), + ), + 'expected_result' => 'WP_Error', + ), + ); + } + + + /** + * @dataProvider data_should_merge + * + * @param array $font1 First font data in theme.json format. + * @param array $font2 Second font data in theme.json format. + * @param array $expected_result Expected result. + */ + public function test_should_merge( array $font1, array $font2, array $expected_result ) { + $actual = WP_Font_Family_Utils::merge_fonts_data( $font1, $font2 ); + + $this->assertSame( $expected_result, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_merge() { + return array( + 'with different font faces' => array( + 'font1' => array( + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/piazzolla_400_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '500', + 'src' => 'http://example.com/fonts/piazzolla_500_italic.ttf', + ), + ), + ), + 'font2' => array( + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '600', + 'src' => 'http://example.com/fonts/piazzolla_600_normal.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '700', + 'src' => 'http://example.com/fonts/piazzolla_700_normal.ttf', + ), + ), + ), + 'expected_result' => array( + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/piazzolla_400_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '500', + 'src' => 'http://example.com/fonts/piazzolla_500_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '600', + 'src' => 'http://example.com/fonts/piazzolla_600_normal.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '700', + 'src' => 'http://example.com/fonts/piazzolla_700_normal.ttf', + ), + ), + ), + ), + + 'repeated font faces' => array( + 'font1' => array( + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/piazzolla_400_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '500', + 'src' => 'http://example.com/fonts/piazzolla_500_italic.ttf', + ), + ), + ), + 'font2' => array( + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '600', + 'src' => 'http://example.com/fonts/piazzolla_600_normal.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/piazzolla_400_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '500', + 'src' => 'http://example.com/fonts/piazzolla_500_italic.ttf', + ), + ), + ), + 'expected_result' => array( + 'slug' => 'piazzolla', + 'name' => 'Piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'http://example.com/fonts/piazzolla_400_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '500', + 'src' => 'http://example.com/fonts/piazzolla_500_italic.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'normal', + 'fontWeight' => '600', + 'src' => 'http://example.com/fonts/piazzolla_600_normal.ttf', + ), + ), + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontsDir.php b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontsDir.php new file mode 100644 index 00000000000000..5d638696352ff3 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontsDir.php @@ -0,0 +1,18 @@ +<?php +/** + * Test WP_Font_Library::get_fonts_dir(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Library::get_fonts_dir + */ +class Tests_Fonts_WpFontLibrary_GetFontsDir extends WP_UnitTestCase { + + public function test_get_fonts_dir() { + $this->assertStringEndsWith( '/wp-content/uploads/fonts', WP_Font_Library::get_fonts_dir() ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/setUploadDir.php b/phpunit/tests/fonts/font-library/wpFontLibrary/setUploadDir.php new file mode 100644 index 00000000000000..6d159d261f2f42 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/setUploadDir.php @@ -0,0 +1,30 @@ +<?php +/** + * Test WP_Font_Library::set_upload_dir(). + * + * @package WordPress + * @subpackage Font Library + * + * @group fonts + * @group font-library + * + * @covers WP_Font_Library::set_upload_dir + */ +class Tests_Fonts_WpFontLibrary_SetUploadDir extends WP_UnitTestCase { + + public function test_should_set_fonts_upload_dir() { + $defaults = array( + 'subdir' => '/abc', + 'basedir' => '/var/www/html/wp-content/uploads', + 'baseurl' => 'http://example.com/wp-content/uploads', + ); + $expected = array( + 'subdir' => '/fonts', + 'basedir' => '/var/www/html/wp-content/uploads', + 'baseurl' => 'http://example.com/wp-content/uploads', + 'path' => '/var/www/html/wp-content/uploads/fonts', + 'url' => 'http://example.com/wp-content/uploads/fonts', + ); + $this->assertSame( $expected, WP_Font_Library::set_upload_dir( $defaults ) ); + } +} diff --git a/schemas/json/block.json b/schemas/json/block.json index 5b92a654fbc4a5..41434f58e4727b 100644 --- a/schemas/json/block.json +++ b/schemas/json/block.json @@ -17,9 +17,9 @@ }, "apiVersion": { "type": "integer", - "description": "The version of the Block API used by the block. The most recent version is 2 and it was introduced in WordPress 5.6.\n\n See the API versions documentation at https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/ for more details.", + "description": "The version of the Block API used by the block. The most recent version is 3 and it was introduced in WordPress 6.3.\n\n See the API versions documentation at https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/ for more details.", "default": 1, - "enum": [ 1, 2 ] + "enum": [ 1, 2, 3 ] }, "name": { "type": "string", @@ -258,6 +258,18 @@ "description": "ARIA-labels let you define an accessible label for elements. This property allows enabling the definition of an aria-label for the block, without exposing a UI field.", "default": false }, + "behaviors": { + "type": "object", + "description": "Behaviors are a way to add additional functionality to a block. They are defined as an object with a name and a set of properties. Curently, only one behavior is supported: lightbox.", + "additionalProperties": false, + "properties": { + "lightbox": { + "type": "boolean", + "description": "This property adds a 'lightbox' behavior to the block. It allows to open the block's content in a lightbox when clicking on it.", + "default": false + } + } + }, "className": { "type": "boolean", "description": "By default, the class .wp-block-your-block-name is added to the root element of your saved markup. This helps having a consistent mechanism for styling blocks that themes and plugins can rely on. If, for whatever reason, a class is not desired on the markup, this functionality can be disabled.", @@ -336,6 +348,116 @@ "description": "By default, all blocks will appear in the inserter, block transforms menu, Style Book, etc. To hide a block from all parts of the user interface so that it can only be inserted programmatically, set inserter to false.", "default": true }, + "layout": { + "default": false, + "description": "This value only applies to blocks that are containers for inner blocks. If set to `true` the layout type will be `flow`. For other layout types it's necessary to set the `type` explicitly inside the `default` object.", + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "default": { + "type": "object", + "description": "Allows setting the `type` property to define what layout type is default for the block, and also default values for any properties inherent to that layout type, e.g., for a `flex` layout, a default value can be set for `flexWrap`.", + "properties": { + "type": { + "type": "string", + "description": "The layout type.", + "enum": [ + "constrained", + "grid", + "flex" + ] + }, + "contentSize": { + "type": "string", + "description": "The content size used on all children." + }, + "wideSize": { + "type": "string", + "description": "The wide size used on alignwide children." + }, + "justifyContent": { + "type": "string", + "description": "Content justification value.", + "enum": [ + "right", + "center", + "space-between", + "left", + "stretch" + ] + }, + "orientation": { + "type": "string", + "description": "The orientation of the layout.", + "enum": [ "horizontal", "vertical" ] + }, + "flexWrap": { + "type": "string", + "description": "The flex wrap value.", + "enum": [ "wrap", "nowrap" ] + }, + "verticalAlignment": { + "type": "string", + "description": "The vertical alignment value.", + "enum": [ + "top", + "center", + "bottom", + "space-between", + "stretch" + ] + }, + "minimumColumnWidth": { + "type": "string", + "description": "The minimum column width value." + }, + "columnCount": { + "type": "number", + "description": "The column count value." + } + } + }, + "allowSwitching": { + "type": "boolean", + "description": "Exposes a switcher control that allows toggling between all existing layout types.", + "default": false + }, + "allowEditing": { + "type": "boolean", + "description": "Determines display of layout controls in the block sidebar. If set to false, layout controls will be hidden.", + "default": true + }, + "allowInheriting": { + "type": "boolean", + "description": "For the `flow` layout type only, determines display of the `Inner blocks use content width` toggle.", + "default": true + }, + "allowSizingOnChildren": { + "type": "boolean", + "description": "For the `flex` layout type only, determines display of sizing controls (Fit/Fill/Fixed) on all child blocks of the flex block.", + "default": false + }, + "allowVerticalAlignment": { + "type": "boolean", + "description": "For the `flex` layout type only, determines display of vertical alignment controls in the block toolbar.", + "default": true + }, + "allowJustification": { + "type": "boolean", + "description": "For the `flex` layout type, determines display of justification controls in the block toolbar and block sidebar. For the `constrained` layout type, determines display of justification control in the block sidebar.", + "default": true + }, + "allowOrientation": { + "type": "boolean", + "description": "For the `flex` layout type only, determines display of the orientation control in the block toolbar.", + "default": true + } + } + } + ] + }, "multiple": { "type": "boolean", "description": "A non-multiple block can be inserted into each post, one time only. For example, the built-in ‘More’ block cannot be inserted again if it already exists in the post being edited. A non-multiple block’s icon is automatically dimmed (unclickable) to prevent multiple instances.", diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 5f9fd13c041575..8cdf2ba520f7ab 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -7,6 +7,29 @@ "createTheme": "https://developer.wordpress.org/block-editor/how-to-guides/themes/create-block-theme/", "reference": "https://developer.wordpress.org/block-editor/how-to-guides/themes/theme-json/" }, + "behaviorsBlocksPropertiesComplete": { + "type": "object", + "properties": { + "lightbox": { + "description": "Settings related to the lightbox behavior.", + "type": "object", + "properties": { + "enabled": { + "description": "Allow users to enable the lightbox behavior.", + "type": "boolean", + "default": false + }, + "animation": { + "description": "Set lightbox animation. Possible values: `fade`, `zoom`, `''` (empty string).", + "type": "string", + "enum": [ "fade", "zoom", "" ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "refComplete": { "type": "object", "properties": { @@ -268,6 +291,11 @@ "wideSize": { "description": "Sets the max-width of wide (`.alignwide`) content. Also used as the maximum viewport when calculating fluid font sizes", "type": "string" + }, + "allowEditing": { + "description": "Disable the layout UI controls.", + "type": "boolean", + "default": true } }, "additionalProperties": false @@ -426,7 +454,15 @@ "type": "object", "properties": { "minFontSize": { - "description": "Allow users to set a global minimum font size boundary in px, rem or em. Custom font sizes below this value will not be clamped, and all calculated minimum font sizes will be, a at minimum, this value.", + "description": "Allow users to set a global minimum font size boundary in px, rem or em. Custom font sizes below this value will not be clamped, and all calculated minimum font sizes will be, at a minimum, this value.", + "type": "string" + }, + "maxViewportWidth": { + "description": "Allow users to set custom a max viewport width in px, rem or em, used to set the maximum size boundary of a fluid font size.", + "type": "string" + }, + "minViewportWidth": { + "description": "Allow users to set a custom min viewport width in px, rem or em, used to set the minimum size boundary of a fluid font size.", "type": "string" } }, @@ -458,6 +494,11 @@ "type": "boolean", "default": true }, + "writingMode": { + "description": "Allow users to set the writing mode.", + "type": "boolean", + "default": false + }, "textTransform": { "description": "Allow users to set custom text transforms.", "type": "boolean", @@ -487,7 +528,7 @@ "type": "string" }, "fluid": { - "description": "Specifics the minimum and maximum font size value of a fluid font size. Set to `false` to bypass fluid calculations and use the static `size` value.", + "description": "Specifies the minimum and maximum font size value of a fluid font size. Set to `false` to bypass fluid calculations and use the static `size` value.", "oneOf": [ { "type": "object", @@ -644,9 +685,27 @@ } } }, + "settingsPropertiesBehaviors": { + "type": "object", + "properties": { + "behaviors": { + "description": "Settings related to behaviors.", + "type": "object", + "properties": { + "lightbox": { + "description": "Allow users to enable/disable lightbox.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + } + }, "settingsProperties": { "allOf": [ { "$ref": "#/definitions/settingsPropertiesAppearanceTools" }, + { "$ref": "#/definitions/settingsPropertiesBehaviors" }, { "$ref": "#/definitions/settingsPropertiesBorder" }, { "$ref": "#/definitions/settingsPropertiesColor" }, { "$ref": "#/definitions/settingsPropertiesDimensions" }, @@ -667,6 +726,7 @@ { "properties": { "appearanceTools": {}, + "behaviors": {}, "border": {}, "color": {}, "dimensions": {}, @@ -685,9 +745,7 @@ "type": "object", "properties": { "core/archives": { - "type": "object", - "description": "Archive block. Display a monthly archive of your posts. This block has no block-level settings", - "additionalProperties": false + "$ref": "#/definitions/settingsPropertiesComplete" }, "core/audio": { "$ref": "#/definitions/settingsPropertiesComplete" @@ -699,35 +757,7 @@ "$ref": "#/definitions/settingsPropertiesComplete" }, "core/button": { - "type": "object", - "allOf": [ - { - "$ref": "#/definitions/settingsPropertiesAppearanceTools" - }, - { - "type": "object", - "properties": { - "border": { - "description": "Settings related to borders.", - "type": "object", - "properties": { - "radius": { - "description": "Allow users to set custom border radius.", - "type": "boolean", - "default": false - } - } - } - } - }, - { "$ref": "#/definitions/settingsPropertiesColor" }, - { "$ref": "#/definitions/settingsPropertiesLayout" }, - { "$ref": "#/definitions/settingsPropertiesSpacing" }, - { - "$ref": "#/definitions/settingsPropertiesTypography" - }, - { "$ref": "#/definitions/settingsPropertiesCustom" } - ] + "$ref": "#/definitions/settingsPropertiesComplete" }, "core/buttons": { "$ref": "#/definitions/settingsPropertiesComplete" @@ -747,6 +777,9 @@ "core/columns": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/comment-author-avatar": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/comment-author-name": { "$ref": "#/definitions/settingsPropertiesComplete" }, @@ -762,15 +795,33 @@ "core/comment-reply-link": { "$ref": "#/definitions/settingsPropertiesComplete" }, - "core/comment-template": { + "core/comments": { "$ref": "#/definitions/settingsPropertiesComplete" }, - "core/comments": { + "core/comments-pagination": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination-next": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination-numbers": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination-previous": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-title": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-template": { "$ref": "#/definitions/settingsPropertiesComplete" }, "core/cover": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/details": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/embed": { "$ref": "#/definitions/settingsPropertiesComplete" }, @@ -807,6 +858,9 @@ "core/list": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/list-item": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/loginout": { "$ref": "#/definitions/settingsPropertiesComplete" }, @@ -825,19 +879,31 @@ "core/navigation-link": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/navigation-submenu": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/nextpage": { "$ref": "#/definitions/settingsPropertiesComplete" }, "core/page-list": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/page-list-item": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/paragraph": { "$ref": "#/definitions/settingsPropertiesComplete" }, "core/post-author": { "$ref": "#/definitions/settingsPropertiesComplete" }, - "core/post-comments": { + "core/post-author-biography": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-author-name": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-comment": { "$ref": "#/definitions/settingsPropertiesComplete" }, "core/post-comments-count": { @@ -870,6 +936,9 @@ "core/post-terms": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/post-time-to-read": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/post-title": { "$ref": "#/definitions/settingsPropertiesComplete" }, @@ -882,6 +951,9 @@ "core/query": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/query-no-results": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/query-pagination": { "$ref": "#/definitions/settingsPropertiesComplete" }, @@ -900,6 +972,9 @@ "core/quote": { "$ref": "#/definitions/settingsPropertiesComplete" }, + "core/read-more": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, "core/rss": { "$ref": "#/definitions/settingsPropertiesComplete" }, @@ -1508,6 +1583,17 @@ } ] }, + "writingMode": { + "description": "Sets the `writing-mode` CSS property.", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/refComplete" + } + ] + }, "textTransform": { "description": "Sets the `text-transform` CSS property.", "oneOf": [ @@ -1773,6 +1859,9 @@ "core/columns": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/comment-author-avatar": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/comment-author-name": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, @@ -1788,15 +1877,33 @@ "core/comment-reply-link": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, - "core/comment-template": { + "core/comments": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, - "core/comments": { + "core/comments-pagination": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination-next": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination-numbers": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination-previous": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-title": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-template": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, "core/cover": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/details": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/embed": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, @@ -1833,6 +1940,9 @@ "core/list": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/list-item": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/loginout": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, @@ -1851,19 +1961,31 @@ "core/navigation-link": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/navigation-submenu": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/nextpage": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, "core/page-list": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/page-list-item": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/paragraph": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, "core/post-author": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, - "core/post-comments": { + "core/post-author-biography": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-author-name": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-comment": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, "core/post-comments-count": { @@ -1896,6 +2018,9 @@ "core/post-terms": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/post-time-to-read": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/post-title": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, @@ -1908,6 +2033,9 @@ "core/query": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/query-no-results": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/query-pagination": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, @@ -1926,6 +2054,9 @@ "core/quote": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + "core/read-more": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, "core/rss": { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, @@ -2049,6 +2180,25 @@ "type": "string", "description": "Title of the global styles variation. If not defined, the file name will be used." }, + "description": { + "type": "string", + "description": "Description of the global styles variation." + }, + "behaviors": { + "description": "A list of blocks that have behaviors. This setting controls the display of the Behaviors UI in the block editor.", + "type": "object", + "properties": { + "blocks": { + "type": "object", + "properties": { + "core/image": { + "$ref": "#/definitions/behaviorsBlocksPropertiesComplete" + } + }, + "additionalProperties": false + } + } + }, "settings": { "description": "Settings for the block editor and individual blocks. These include things like:\n- Which customization options should be available to the user. \n- The default colors, font sizes... available to the user. \n- CSS custom properties and class names used in styles.\n- And the default layout of the editor (widths and available alignments).", "type": "object", diff --git a/storybook/main.js b/storybook/main.js index fa59c739ea3d5d..ad4756fa2d472b 100644 --- a/storybook/main.js +++ b/storybook/main.js @@ -1,21 +1,49 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * WordPress dependencies + */ +const postcssPlugins = require( '@wordpress/postcss-plugins-preset' ); + +const scssLoaders = ( { isLazy } ) => [ + { + loader: 'style-loader', + options: { injectType: isLazy ? 'lazyStyleTag' : 'styleTag' }, + }, + 'css-loader', + { + loader: 'postcss-loader', + options: { + postcssOptions: { + ident: 'postcss', + plugins: postcssPlugins, + }, + }, + }, + 'sass-loader', +]; + const stories = [ - process.env.NODE_ENV !== 'test' && './stories/**/*.@(js|tsx|mdx)', - '../packages/block-editor/src/**/stories/*.@(js|tsx|mdx)', - '../packages/components/src/**/stories/*.@(js|tsx|mdx)', - '../packages/icons/src/**/stories/*.@(js|tsx|mdx)', - '../packages/edit-site/src/**/stories/*.@(js|tsx|mdx)', - '../packages/components/README.mdx', + process.env.NODE_ENV !== 'test' && './stories/**/*.story.@(js|tsx)', + process.env.NODE_ENV !== 'test' && './stories/**/*.mdx', + '../packages/block-editor/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/components/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/icons/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/edit-site/src/**/stories/*.story.@(js|tsx|mdx)', ].filter( Boolean ); module.exports = { core: { - builder: 'webpack5', + disableTelemetry: true, }, stories, addons: [ { name: '@storybook/addon-docs', - options: { configureJSX: true, transcludeMarkdown: true }, + options: { configureJSX: true }, }, '@storybook/addon-controls', '@storybook/addon-viewport', @@ -24,10 +52,47 @@ module.exports = { '@storybook/addon-actions', 'storybook-source-link', ], - framework: '@storybook/react', + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, features: { babelModeV7: true, emotionAlias: false, storyStoreV7: true, }, + docs: { + autodocs: true, + }, + webpackFinal: async ( config ) => { + return { + ...config, + module: { + ...config.module, + rules: [ + ...config.module.rules, + { + // Adds a `sourceLink` parameter to the story metadata, based on the file path + test: /\/stories\/.+\.(j|t)sx?$/, + loader: path.resolve( + __dirname, + './webpack/source-link-loader.js' + ), + enforce: 'post', + }, + { + test: /\.scss$/, + exclude: /\.lazy\.scss$/, + use: scssLoaders( { isLazy: false } ), + include: path.resolve( __dirname ), + }, + { + test: /\.lazy\.scss$/, + use: scssLoaders( { isLazy: true } ), + include: path.resolve( __dirname ), + }, + ], + }, + }; + }, }; diff --git a/storybook/preview.js b/storybook/preview.js index ff73a95fa41313..61bdef8aae5f37 100644 --- a/storybook/preview.js +++ b/storybook/preview.js @@ -1,3 +1,15 @@ +/** + * External dependencies + */ +import { + ArgsTable, + Description, + Primary, + Stories, + Subtitle, + Title, +} from '@storybook/blocks'; + /** * Internal dependencies */ @@ -90,6 +102,21 @@ export const parameters = { controls: { sort: 'requiredFirst', }, + docs: { + // Flips the order of the description and the primary component story + // so the component is always visible before the fold. + page: () => ( + <> + <Title /> + <Subtitle /> + <Primary /> + <Description /> + { /* `story="^"` enables Controls for the primary props table */ } + <ArgsTable story="^" /> + <Stories includePrimary={ false } /> + </> + ), + }, options: { storySort: { order: [ diff --git a/storybook/stories/docs/components/contributing.mdx b/storybook/stories/docs/components/contributing.mdx new file mode 100644 index 00000000000000..7593cdd97a810e --- /dev/null +++ b/storybook/stories/docs/components/contributing.mdx @@ -0,0 +1,6 @@ +import { Meta, Markdown } from '@storybook/blocks'; +import Contributing from '@wordpress/components/CONTRIBUTING.md?raw'; + +<Meta title="Components/Contributing Guidelines" /> + +<Markdown>{Contributing}</Markdown> diff --git a/storybook/stories/docs/components/contributing.story.mdx b/storybook/stories/docs/components/contributing.story.mdx deleted file mode 100644 index 1ffd0c49d4020b..00000000000000 --- a/storybook/stories/docs/components/contributing.story.mdx +++ /dev/null @@ -1,6 +0,0 @@ -import { Meta } from '@storybook/addon-docs'; -import Contributing from '@wordpress/components/CONTRIBUTING.md'; - -<Meta title="Components/Contributing Guidelines" /> - -<Contributing /> diff --git a/storybook/stories/docs/components/readme.mdx b/storybook/stories/docs/components/readme.mdx new file mode 100644 index 00000000000000..61f3c222e9f550 --- /dev/null +++ b/storybook/stories/docs/components/readme.mdx @@ -0,0 +1,6 @@ +import { Meta, Markdown } from '@storybook/blocks'; +import Readme from '@wordpress/components/README.md?raw'; + +<Meta title="Components/Introduction" /> + +<Markdown>{Readme}</Markdown> diff --git a/storybook/stories/docs/components/readme.story.mdx b/storybook/stories/docs/components/readme.story.mdx deleted file mode 100644 index 7996188ffb50b3..00000000000000 --- a/storybook/stories/docs/components/readme.story.mdx +++ /dev/null @@ -1,6 +0,0 @@ -import { Meta } from '@storybook/addon-docs'; -import Readme from '@wordpress/components/README.md'; - -<Meta title="Components/Introduction" /> - -<Readme /> diff --git a/storybook/stories/docs/introduction.mdx b/storybook/stories/docs/introduction.mdx new file mode 100644 index 00000000000000..dfccd276c63032 --- /dev/null +++ b/storybook/stories/docs/introduction.mdx @@ -0,0 +1,42 @@ +import { Meta } from '@storybook/blocks'; +import { InlineIcon } from './inline-icon'; + +<Meta title="Docs/Introduction" /> + +# Introduction + +## Welcome! + +The WordPress Gutenberg project uses Storybook to view and work with the UI components developed in WordPress packages, especially [@wordpress/components](https://github.com/WordPress/gutenberg/tree/trunk/packages/components). + +On this interactive site you can browse individual components, their controls, options, and settings in isolation. You can also modify controls and arguments and see the changes right away. + +The components displayed on this site can be used in your code to build the editor's UI for your custom blocks or other pages. +Import them from the components root directory like in below example: + +```jsx +import { Button } from '@wordpress/components'; + +export default function MyButton() { + return <Button>Click Me!</Button>; +} +``` + +## How this site works + +The site shows the individual components in the sidebar and the Canvas on the right. Select the component you’d like to explore, and you’ll see the display on the **Canvas** tab. If the component also has controls/arguments, you can modify them on the **Controls** tab on the lower half of the screen. + +To view the documentation for each component use the **Docs** menu item in the top toolbar. + +To view the source code for the component and its stories on GitHub, click the <InlineIcon icon="repository" /> View Source Repository button in the top right corner. + +To use it in your local development environment run the following command in the top level Gutenberg directory: + +```bash +npm run storybook:dev +``` + +## Resources to learn more: + +- [Storybook.js.org](https://storybook.js.org/) - Storybook is a frontend workshop for building UI components and pages in isolation. +- [[Package] Components](https://github.com/WordPress/gutenberg/issues?q=is%3Aopen+is%3Aissue+label%3A%22%5BPackage%5D+Components%22) - Open Issue Gutenberg Repo diff --git a/storybook/stories/docs/introduction.story.mdx b/storybook/stories/docs/introduction.story.mdx deleted file mode 100644 index 46079eafe47b43..00000000000000 --- a/storybook/stories/docs/introduction.story.mdx +++ /dev/null @@ -1,42 +0,0 @@ -import { Meta } from '@storybook/addon-docs'; -import { InlineIcon } from './inline-icon'; - -<Meta title="Docs/Introduction" /> - -# Introduction - -## Welcome! - -The WordPress Gutenberg project uses Storybook to view and work with the UI components developed in WordPress packages, especially [@wordpress/components](https://github.com/WordPress/gutenberg/tree/trunk/packages/components). - -On this interactive site you can browse individual components, their controls, options, and settings in isolation. You can also modify controls and arguments and see the changes right away. - -The components displayed on this site can be used in your code to build the editor's UI for your custom blocks or other pages. -Import them from the components root directory like in below example: - -```jsx -import { Button } from '@wordpress/components'; - -export default function MyButton() { - return <Button>Click Me!</Button>; -} -``` - -## How this site works - -The site shows the individual components in the sidebar and the Canvas on the right. Select the component you’d like to explore, and you’ll see the display on the **Canvas** tab. If the component also has controls/arguments, you can modify them on the **Controls** tab on the lower half of the screen. - -To view the documentation for each component use the **Docs** menu item in the top toolbar. - -To view the source code for the component and its stories on GitHub, click the <InlineIcon icon="repository" /> View Source Repository button in the top right corner. - -To use it in your local development environment run the following command in the top level Gutenberg directory: - -```bash -npm run storybook:dev -``` - -## Resources to learn more: - -- [Storybook.js.org](https://storybook.js.org/) - Storybook is a frontend workshop for building UI components and pages in isolation. -- [[Package] Components](https://github.com/WordPress/gutenberg/issues?q=is%3Aopen+is%3Aissue+label%3A%22%5BPackage%5D+Components%22) - Open Issue Gutenberg Repo diff --git a/storybook/stories/playground/index.js b/storybook/stories/playground/index.js deleted file mode 100644 index 92ccd78ae58b74..00000000000000 --- a/storybook/stories/playground/index.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * WordPress dependencies - */ -import { useEffect, useState } from '@wordpress/element'; -import { - BlockEditorKeyboardShortcuts, - BlockEditorProvider, - BlockList, - BlockTools, - BlockInspector, - WritingFlow, - ObserveTyping, -} from '@wordpress/block-editor'; -import { Popover, SlotFillProvider } from '@wordpress/components'; -import { registerCoreBlocks } from '@wordpress/block-library'; -import { ShortcutProvider } from '@wordpress/keyboard-shortcuts'; -import '@wordpress/format-library'; - -/** - * Internal dependencies - */ -import styles from './style.lazy.scss'; - -function App() { - const [ blocks, updateBlocks ] = useState( [] ); - - useEffect( () => { - registerCoreBlocks(); - }, [] ); - - // Ensures that the CSS intended for the playground (especially the style resets) - // are only loaded for the playground and don't leak into other stories. - useEffect( () => { - styles.use(); - - return styles.unuse; - } ); - - return ( - <div className="playground"> - <ShortcutProvider> - <SlotFillProvider> - <BlockEditorProvider - value={ blocks } - onInput={ updateBlocks } - onChange={ updateBlocks } - > - <div className="playground__sidebar"> - <BlockInspector /> - </div> - <div className="playground__content"> - <BlockTools> - <div className="editor-styles-wrapper"> - <BlockEditorKeyboardShortcuts.Register /> - <WritingFlow> - <ObserveTyping> - <BlockList /> - </ObserveTyping> - </WritingFlow> - </div> - </BlockTools> - </div> - <Popover.Slot /> - </BlockEditorProvider> - </SlotFillProvider> - </ShortcutProvider> - </div> - ); -} - -export default { - title: 'Playground/Block Editor', - parameters: { - sourceLink: 'storybook/stories/playground', - }, -}; - -export const _default = () => { - return <App />; -}; diff --git a/storybook/stories/playground/index.story.js b/storybook/stories/playground/index.story.js new file mode 100644 index 00000000000000..380a95f4b79a99 --- /dev/null +++ b/storybook/stories/playground/index.story.js @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { + BlockEditorProvider, + BlockList, + BlockTools, + BlockInspector, + WritingFlow, +} from '@wordpress/block-editor'; +import { registerCoreBlocks } from '@wordpress/block-library'; +import { ShortcutProvider } from '@wordpress/keyboard-shortcuts'; +import '@wordpress/format-library'; + +/** + * Internal dependencies + */ +import styles from './style.lazy.scss'; + +function App() { + const [ blocks, updateBlocks ] = useState( [] ); + + useEffect( () => { + registerCoreBlocks(); + }, [] ); + + // Ensures that the CSS intended for the playground (especially the style resets) + // are only loaded for the playground and don't leak into other stories. + useEffect( () => { + styles.use(); + + return styles.unuse; + } ); + + return ( + <div className="playground"> + <ShortcutProvider> + <BlockEditorProvider + value={ blocks } + onInput={ updateBlocks } + onChange={ updateBlocks } + > + <div className="playground__sidebar"> + <BlockInspector /> + </div> + <div className="playground__content"> + <BlockTools> + <div className="editor-styles-wrapper"> + <WritingFlow> + <BlockList /> + </WritingFlow> + </div> + </BlockTools> + </div> + </BlockEditorProvider> + </ShortcutProvider> + </div> + ); +} + +export default { + title: 'Playground/Block Editor', + parameters: { + sourceLink: 'storybook/stories/playground', + }, +}; + +export const _default = () => { + return <App />; +}; diff --git a/storybook/webpack.config.js b/storybook/webpack.config.js deleted file mode 100644 index 1747cc2f9caa0a..00000000000000 --- a/storybook/webpack.config.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * External dependencies - */ -const path = require( 'path' ); - -/** - * WordPress dependencies - */ -const postcssPlugins = require( '@wordpress/postcss-plugins-preset' ); - -const scssLoaders = ( { isLazy } ) => [ - { - loader: 'style-loader', - options: { injectType: isLazy ? 'lazyStyleTag' : 'styleTag' }, - }, - 'css-loader', - { - loader: 'postcss-loader', - options: { - postcssOptions: { - ident: 'postcss', - plugins: postcssPlugins, - }, - }, - }, - 'sass-loader', -]; - -module.exports = ( { config } ) => { - config.module.rules.push( - { - // Currently does not work with our tsx stories - // See https://github.com/storybookjs/storybook/issues/17275 - test: /\/stories\/.+\.(j|t)sx?$/, - loader: require.resolve( '@storybook/source-loader' ), - enforce: 'pre', - }, - { - // Allows a story description to be written as a doc comment above the exported story - test: /\/stories\/.+\.(j|t)sx?$/, - loader: path.resolve( - __dirname, - './webpack/description-loader.js' - ), - enforce: 'post', - }, - { - // Adds a `sourceLink` parameter to the story metadata, based on the file path - test: /\/stories\/.+\.(j|t)sx?$/, - loader: path.resolve( - __dirname, - './webpack/source-link-loader.js' - ), - enforce: 'post', - }, - { - test: /\.scss$/, - exclude: /\.lazy\.scss$/, - use: scssLoaders( { isLazy: false } ), - include: path.resolve( __dirname ), - }, - { - test: /\.lazy\.scss$/, - use: scssLoaders( { isLazy: true } ), - include: path.resolve( __dirname ), - } - ); - - return config; -}; diff --git a/storybook/webpack/description-loader.js b/storybook/webpack/description-loader.js deleted file mode 100644 index 93d9fabfb09ff6..00000000000000 --- a/storybook/webpack/description-loader.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * External dependencies - */ -const babel = require( '@babel/core' ); - -/** - * Allows a story description to be written as a doc comment above the exported story. - * - * Based on https://github.com/izhan/storybook-description-loader - * - * @example - * ```jsx - * // This comment will become the description for the story in the generated docs. - * export const MyStory = Template.bind({}); - * ``` - */ -function annotateDescriptionPlugin() { - return { - visitor: { - ExportNamedDeclaration( path ) { - if ( ! path.node.leadingComments ) { - return; - } - - const commentValues = path.node.leadingComments.map( - ( node ) => { - if ( node.type === 'CommentLine' ) { - return node.value.trimLeft(); - } - // else, node.type === 'CommentBlock' - return node.value - .split( '\n' ) - .map( ( line ) => { - // stripping out the whitespace and * from comment blocks - return line.replace( - /^(\s+)?(\*+)?(\s+)?/, - '' - ); - } ) - .join( '\n' ) - .trim(); - } - ); - const description = commentValues.join( '\n' ); - const storyId = path.node.declaration.declarations[ 0 ].id.name; - - path.container.push( - ...babel.template.ast` - ${ storyId }.parameters ??= {}; - ${ storyId }.parameters.docs ??= {}; - ${ storyId }.parameters.docs.description ??= {}; - ${ storyId }.parameters.docs.description.story ??= ${ JSON.stringify( - description - ) }; - ` - ); - }, - }, - }; -} - -module.exports = function ( source ) { - const output = babel.transform( source, { - plugins: [ annotateDescriptionPlugin ], - filename: __filename, - sourceType: 'module', - } ); - return output.code; -}; diff --git a/storybook/webpack/source-link-loader.js b/storybook/webpack/source-link-loader.js index 702e2882916f2d..f8702c1c367174 100644 --- a/storybook/webpack/source-link-loader.js +++ b/storybook/webpack/source-link-loader.js @@ -58,10 +58,10 @@ function alterParameters( properties, componentPath ) { ]; } -module.exports = function ( source, { sources } ) { +module.exports = function ( source ) { const output = babel.transform( source, { plugins: [ addSourceLinkPlugin ], - filename: sources[ 0 ], + filename: this.resourcePath, sourceType: 'module', } ); return output.code; diff --git a/test/e2e/assets/3200x2400_e2e_test_image_responsive_lightbox.jpeg b/test/e2e/assets/3200x2400_e2e_test_image_responsive_lightbox.jpeg new file mode 100644 index 00000000000000..620ba3599922cd Binary files /dev/null and b/test/e2e/assets/3200x2400_e2e_test_image_responsive_lightbox.jpeg differ diff --git a/test/e2e/config/global-setup.ts b/test/e2e/config/global-setup.ts index 10f2822fdfe1ae..787488ac72fcab 100644 --- a/test/e2e/config/global-setup.ts +++ b/test/e2e/config/global-setup.ts @@ -25,6 +25,19 @@ async function globalSetup( config: FullConfig ) { // Authenticate and save the storageState to disk. await requestUtils.setupRest(); + // Reset the test environment before running the tests. + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + // Disable this test plugin as it's conflicting with some of the tests. + // We already have reduced motion enabled and Playwright will wait for most of the animations anyway. + requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-disables-the-css-animations' + ), + requestUtils.deleteAllPosts(), + requestUtils.deleteAllBlocks(), + requestUtils.resetPreferences(), + ] ); + await requestContext.dispose(); } diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index e1724a61d61263..2e117b9745840d 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -17,7 +17,7 @@ const config = defineConfig( { forbidOnly: !! process.env.CI, workers: 1, retries: process.env.CI ? 2 : 0, - timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. + timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 200_000, // Defaults to 200 seconds. // Don't report slow test "files", as we will be running our tests in serial. reportSlowTests: null, testDir: fileURLToPath( new URL( './specs', 'file:' + __filename ).href ), diff --git a/test/e2e/specs/editor/blocks/avatar.spec.js b/test/e2e/specs/editor/blocks/avatar.spec.js index bbce1eede94da3..8bf39a7a60dbac 100644 --- a/test/e2e/specs/editor/blocks/avatar.spec.js +++ b/test/e2e/specs/editor/blocks/avatar.spec.js @@ -37,7 +37,7 @@ test.describe( 'Avatar', () => { const username = 'Gravatar Gravatar'; - const avatarBlock = page.locator( + const avatarBlock = editor.canvas.locator( 'role=document[name="Block: Avatar"i]' ); diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index 13d5759ee7db9c..0341e3dfc63426 100644 --- a/test/e2e/specs/editor/blocks/buttons.spec.js +++ b/test/e2e/specs/editor/blocks/buttons.spec.js @@ -10,6 +10,9 @@ test.describe( 'Buttons', () => { test( 'has focus on button content', async ( { editor, page } ) => { await editor.insertBlock( { name: 'core/buttons' } ); + await expect( + editor.canvas.locator( 'role=textbox[name="Button text"i]' ) + ).toBeFocused(); await page.keyboard.type( 'Content' ); // Check the content. @@ -27,7 +30,7 @@ test.describe( 'Buttons', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/buttons' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'Content' ); @@ -50,13 +53,16 @@ test.describe( 'Buttons', () => { } ) => { // Regression: https://github.com/WordPress/gutenberg/pull/19885 await editor.insertBlock( { name: 'core/buttons' } ); + await expect( + editor.canvas.locator( 'role=textbox[name="Button text"i]' ) + ).toBeFocused(); await pageUtils.pressKeys( 'primary+k' ); await expect( - page.locator( 'role=combobox[name="URL"i]' ) + page.locator( 'role=combobox[name="Link"i]' ) ).toBeFocused(); await page.keyboard.press( 'Escape' ); await expect( - page.locator( 'role=textbox[name="Button text"i]' ) + editor.canvas.locator( 'role=textbox[name="Button text"i]' ) ).toBeFocused(); await page.keyboard.type( 'WordPress' ); @@ -78,9 +84,12 @@ test.describe( 'Buttons', () => { } ) => { // Regression: https://github.com/WordPress/gutenberg/issues/34307 await editor.insertBlock( { name: 'core/buttons' } ); + await expect( + editor.canvas.locator( 'role=textbox[name="Button text"i]' ) + ).toBeFocused(); await pageUtils.pressKeys( 'primary+k' ); await expect( - page.locator( 'role=combobox[name="URL"i]' ) + page.locator( 'role=combobox[name="Link"i]' ) ).toBeFocused(); await page.keyboard.type( 'https://example.com' ); await page.keyboard.press( 'Enter' ); @@ -91,7 +100,7 @@ test.describe( 'Buttons', () => { // Focus should move from the link control to the button block's text. await expect( - page.locator( 'role=textbox[name="Button text"i]' ) + editor.canvas.locator( 'role=textbox[name="Button text"i]' ) ).toBeFocused(); // The link control should still be visible when a URL is set. @@ -107,9 +116,12 @@ test.describe( 'Buttons', () => { } ) => { // Regression: https://github.com/WordPress/gutenberg/issues/34307 await editor.insertBlock( { name: 'core/buttons' } ); + await expect( + editor.canvas.locator( 'role=textbox[name="Button text"i]' ) + ).toBeFocused(); await pageUtils.pressKeys( 'primary+k' ); - const urlInput = page.locator( 'role=combobox[name="URL"i]' ); + const urlInput = page.locator( 'role=combobox[name="Link"i]' ); await expect( urlInput ).toBeFocused(); await page.keyboard.type( 'example.com' ); @@ -194,8 +206,8 @@ test.describe( 'Buttons', () => { const content = await editor.getEditedPostContent(); expect( content ).toBe( `<!-- wp:buttons --> -<div class=\"wp-block-buttons\"><!-- wp:button {\"backgroundColor\":\"vivid-red\",\"textColor\":\"cyan-bluish-gray\"} --> -<div class=\"wp-block-button\"><a class=\"wp-block-button__link has-cyan-bluish-gray-color has-vivid-red-background-color has-text-color has-background wp-element-button\">Content</a></div> +<div class=\"wp-block-buttons\"><!-- wp:button {\"backgroundColor\":\"vivid-red\",\"textColor\":\"cyan-bluish-gray\",\"style\":{\"elements\":{\"link\":{\"color\":{\"text\":\"var:preset|color|cyan-bluish-gray\"}}}}} --> +<div class=\"wp-block-button\"><a class=\"wp-block-button__link has-cyan-bluish-gray-color has-vivid-red-background-color has-text-color has-background has-link-color wp-element-button\">Content</a></div> <!-- /wp:button --></div> <!-- /wp:buttons -->` ); @@ -226,8 +238,8 @@ test.describe( 'Buttons', () => { const content = await editor.getEditedPostContent(); expect( content ).toBe( `<!-- wp:buttons --> -<div class=\"wp-block-buttons\"><!-- wp:button {\"style\":{\"color\":{\"text\":\"#ff0000\",\"background\":\"#00ff00\"}}} --> -<div class=\"wp-block-button\"><a class=\"wp-block-button__link has-text-color has-background wp-element-button\" style=\"color:#ff0000;background-color:#00ff00\">Content</a></div> +<div class=\"wp-block-buttons\"><!-- wp:button {\"style\":{\"color\":{\"text\":\"#ff0000\",\"background\":\"#00ff00\"},\"elements\":{\"link\":{\"color\":{\"text\":\"#ff0000\"}}}}} --> +<div class=\"wp-block-button\"><a class=\"wp-block-button__link has-text-color has-background has-link-color wp-element-button\" style=\"color:#ff0000;background-color:#00ff00\">Content</a></div> <!-- /wp:button --></div> <!-- /wp:buttons -->` ); diff --git a/test/e2e/specs/editor/blocks/classic.spec.js b/test/e2e/specs/editor/blocks/classic.spec.js index cba1472caf916d..4403706444a7e9 100644 --- a/test/e2e/specs/editor/blocks/classic.spec.js +++ b/test/e2e/specs/editor/blocks/classic.spec.js @@ -18,8 +18,15 @@ test.use( { } ); test.describe( 'Classic', () => { - test.beforeEach( async ( { admin } ) => { + test.beforeEach( async ( { admin, page } ) => { await admin.createNewPost(); + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); } ); test.afterAll( async ( { requestUtils } ) => { @@ -126,6 +133,14 @@ test.describe( 'Classic', () => { await page.reload(); await page.unroute( '**' ); + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); + const errors = []; page.on( 'pageerror', ( exception ) => { errors.push( exception ); diff --git a/test/e2e/specs/editor/blocks/code.spec.js b/test/e2e/specs/editor/blocks/code.spec.js index 80f41779b9131e..c4037d50b7dd51 100644 --- a/test/e2e/specs/editor/blocks/code.spec.js +++ b/test/e2e/specs/editor/blocks/code.spec.js @@ -12,7 +12,7 @@ test.describe( 'Code', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '```' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '<?php' ); diff --git a/test/e2e/specs/editor/blocks/columns.spec.js b/test/e2e/specs/editor/blocks/columns.spec.js index 247a49becc3735..23443059c15224 100644 --- a/test/e2e/specs/editor/blocks/columns.spec.js +++ b/test/e2e/specs/editor/blocks/columns.spec.js @@ -18,7 +18,9 @@ test.describe( 'Columns', () => { } ) => { // Open Columns await editor.insertBlock( { name: 'core/columns' } ); - await page.locator( '[aria-label="Two columns; equal split"]' ).click(); + await editor.canvas + .locator( '[aria-label="Two columns; equal split"]' ) + .click(); // Open List view toggle await page.locator( 'role=button[name="Document Overview"i]' ).click(); @@ -51,13 +53,15 @@ test.describe( 'Columns', () => { } ) => { // Open Columns await editor.insertBlock( { name: 'core/columns' } ); - await page + await editor.canvas .locator( '[aria-label="Three columns; equal split"]' ) .click(); // Lock last column block await editor.selectBlocks( - page.locator( 'role=document[name="Block: Column (3 of 3)"i]' ) + editor.canvas.locator( + 'role=document[name="Block: Column (3 of 3)"i]' + ) ); await editor.clickBlockToolbarButton( 'Options' ); await page.click( 'role=menuitem[name="Lock"i]' ); @@ -66,7 +70,7 @@ test.describe( 'Columns', () => { // Select columns block await editor.selectBlocks( - page.locator( 'role=document[name="Block: Columns"i]' ) + editor.canvas.locator( 'role=document[name="Block: Columns"i]' ) ); await editor.openDocumentSettingsSidebar(); @@ -118,4 +122,193 @@ test.describe( 'Columns', () => { }, ] ); } ); + + test( 'can exit on Enter', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + ], + }, + { + name: 'core/column', + }, + ], + } ); + + await editor.selectBlocks( + editor.canvas.locator( 'role=document[name="Paragraph block"i]' ) + ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + ], + }, + { + name: 'core/column', + }, + ], + }, + { + name: 'core/paragraph', + attributes: { content: '2' }, + }, + ] ); + } ); + + test( 'should not split in middle', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '2' }, + }, + ], + }, + { + name: 'core/column', + }, + ], + } ); + + await editor.selectBlocks( + editor.canvas.locator( + 'role=document[name="Paragraph block"i] >> text="1"' + ) + ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '3' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '' }, + }, + { + name: 'core/paragraph', + attributes: { content: '3' }, + }, + { + name: 'core/paragraph', + attributes: { content: '2' }, + }, + ], + }, + { + name: 'core/column', + }, + ], + }, + ] ); + } ); + + test.describe( 'following paragraph', () => { + const columnsBlock = { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + ], + }, + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '2' }, + }, + ], + }, + ], + }; + + test( 'should be deleted on Backspace when empty', async ( { + editor, + page, + } ) => { + await editor.insertBlock( columnsBlock ); + await editor.insertBlock( { name: 'core/paragraph' } ); + + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + columnsBlock, + ] ); + + // Ensure focus is on the columns block. + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [] ); + } ); + + test( 'should only select Columns on Backspace when non-empty', async ( { + editor, + page, + } ) => { + const paragraphBlock = { + name: 'core/paragraph', + attributes: { content: 'a' }, + }; + await editor.insertBlock( columnsBlock ); + await editor.insertBlock( paragraphBlock ); + + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + columnsBlock, + paragraphBlock, + ] ); + + // Ensure focus is on the columns block. + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + paragraphBlock, + ] ); + } ); + } ); } ); diff --git a/test/e2e/specs/editor/blocks/comments.spec.js b/test/e2e/specs/editor/blocks/comments.spec.js index 55a4cdca8c1c0c..9a8c02c39d398c 100644 --- a/test/e2e/specs/editor/blocks/comments.spec.js +++ b/test/e2e/specs/editor/blocks/comments.spec.js @@ -169,7 +169,7 @@ test.describe( 'Comments', () => { 'role=button[name="Switch to editable mode"i]' ); - const commentTemplate = block.locator( + const commentTemplate = editor.canvas.locator( 'role=document[name="Block: Comment Template"i]' ); await expect( block ).toHaveClass( /has-vivid-purple-color/ ); @@ -313,8 +313,14 @@ test.describe( 'Post Comments', () => { ).toBeVisible(); // Check the block definition has changed. - const content = await editor.getEditedPostContent(); - expect( content ).toBe( '<!-- wp:comments {"legacy":true} /-->' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/comments', + attributes: { + legacy: true, + }, + }, + ] ); // Visit post await page.goto( `/?p=${ postId }` ); diff --git a/test/e2e/specs/editor/blocks/cover.spec.js b/test/e2e/specs/editor/blocks/cover.spec.js index 02176167813450..90555eca548c2b 100644 --- a/test/e2e/specs/editor/blocks/cover.spec.js +++ b/test/e2e/specs/editor/blocks/cover.spec.js @@ -25,12 +25,11 @@ test.describe( 'Cover', () => { } ); test( 'can set overlay color using color picker on block placeholder', async ( { - page, editor, coverBlockUtils, } ) => { await editor.insertBlock( { name: 'core/cover' } ); - const coverBlock = page.getByRole( 'document', { + const coverBlock = editor.canvas.getByRole( 'document', { name: 'Block: Cover', } ); @@ -56,12 +55,11 @@ test.describe( 'Cover', () => { } ); test( 'can set background image using image upload on block placeholder', async ( { - page, editor, coverBlockUtils, } ) => { await editor.insertBlock( { name: 'core/cover' } ); - const coverBlock = page.getByRole( 'document', { + const coverBlock = editor.canvas.getByRole( 'document', { name: 'Block: Cover', } ); @@ -80,12 +78,11 @@ test.describe( 'Cover', () => { } ); test( 'dims background image down by 50% by default', async ( { - page, editor, coverBlockUtils, } ) => { await editor.insertBlock( { name: 'core/cover' } ); - const coverBlock = page.getByRole( 'document', { + const coverBlock = editor.canvas.getByRole( 'document', { name: 'Block: Cover', } ); @@ -104,11 +101,11 @@ test.describe( 'Cover', () => { expect( backgroundDimOpacity ).toBe( '0.5' ); } ); - test( 'can have the title edited', async ( { page, editor } ) => { + test( 'can have the title edited', async ( { editor } ) => { const titleText = 'foo'; await editor.insertBlock( { name: 'core/cover' } ); - const coverBlock = page.getByRole( 'document', { + const coverBlock = editor.canvas.getByRole( 'document', { name: 'Block: Cover', } ); @@ -134,7 +131,7 @@ test.describe( 'Cover', () => { test( 'can be resized using drag & drop', async ( { page, editor } ) => { await editor.insertBlock( { name: 'core/cover' } ); - const coverBlock = page.getByRole( 'document', { + const coverBlock = editor.canvas.getByRole( 'document', { name: 'Block: Cover', } ); await coverBlock @@ -205,13 +202,12 @@ test.describe( 'Cover', () => { } ); test( 'dims the background image down by 50% when transformed from the Image block', async ( { - page, editor, coverBlockUtils, } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.getByRole( 'document', { + const imageBlock = editor.canvas.getByRole( 'document', { name: 'Block: Image', } ); @@ -220,14 +216,14 @@ test.describe( 'Cover', () => { ); await expect( - page + editor.canvas .getByRole( 'document', { name: 'Block: Image' } ) .locator( 'img' ) ).toBeVisible(); await editor.transformBlockTo( 'core/cover' ); - const coverBlock = page.getByRole( 'document', { + const coverBlock = editor.canvas.getByRole( 'document', { name: 'Block: Cover', } ); diff --git a/test/e2e/specs/editor/blocks/gallery.spec.js b/test/e2e/specs/editor/blocks/gallery.spec.js index 1a71d3e46e6dd4..f950036539c11b 100644 --- a/test/e2e/specs/editor/blocks/gallery.spec.js +++ b/test/e2e/specs/editor/blocks/gallery.spec.js @@ -51,10 +51,10 @@ test.describe( 'Gallery', () => { plainText: `[gallery ids="${ uploadedMedia.id }"]`, } ); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await pageUtils.pressKeys( 'primary+v' ); - const img = page.locator( + const img = editor.canvas.locator( 'role=document[name="Block: Image"i] >> role=img' ); @@ -87,12 +87,11 @@ test.describe( 'Gallery', () => { test( 'can be created using uploaded images', async ( { admin, editor, - page, galleryBlockUtils, } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'core/gallery' } ); - const galleryBlock = page.locator( + const galleryBlock = editor.canvas.locator( 'role=document[name="Block: Gallery"i]' ); await expect( galleryBlock ).toBeVisible(); @@ -132,7 +131,9 @@ test.describe( 'Gallery', () => { ], } ); - const gallery = page.locator( 'role=document[name="Block: Gallery"i]' ); + const gallery = editor.canvas.locator( + 'role=document[name="Block: Gallery"i]' + ); await expect( gallery ).toBeVisible(); await editor.selectBlocks( gallery ); @@ -173,7 +174,7 @@ test.describe( 'Gallery', () => { ], } ); - const galleryImage = page.locator( + const galleryImage = editor.canvas.locator( 'role=document[name="Block: Gallery"i] >> role=document[name="Block: Image"i]' ); const imageCaption = galleryImage.locator( @@ -203,7 +204,7 @@ test.describe( 'Gallery', () => { } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'core/gallery' } ); - await page.click( 'role=button[name="Media Library"i]' ); + await editor.canvas.click( 'role=button[name="Media Library"i]' ); const mediaLibrary = page.locator( 'role=dialog[name="Create gallery"i]' diff --git a/test/e2e/specs/editor/blocks/group.spec.js b/test/e2e/specs/editor/blocks/group.spec.js index 4cf284a0cdf69a..2de22245eb5b08 100644 --- a/test/e2e/specs/editor/blocks/group.spec.js +++ b/test/e2e/specs/editor/blocks/group.spec.js @@ -29,7 +29,7 @@ test.describe( 'Group', () => { ); // Select the default, selected Group layout from the variation picker. - await page.click( + await editor.canvas.click( 'role=button[name="Group: Gather blocks in a container."i]' ); @@ -40,7 +40,7 @@ test.describe( 'Group', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/group' ); await expect( page.locator( 'role=option[name="Group"i][selected]' ) @@ -48,7 +48,7 @@ test.describe( 'Group', () => { await page.keyboard.press( 'Enter' ); // Select the default, selected Group layout from the variation picker. - await page.click( + await editor.canvas.click( 'role=button[name="Group: Gather blocks in a container."i]' ); @@ -60,10 +60,10 @@ test.describe( 'Group', () => { page, } ) => { await editor.insertBlock( { name: 'core/group' } ); - await page.click( + await editor.canvas.click( 'button[aria-label="Group: Gather blocks in a container."]' ); - await page.click( 'role=button[name="Add block"i]' ); + await editor.canvas.click( 'role=button[name="Add block"i]' ); await page.click( 'role=listbox[name="Blocks"i] >> role=option[name="Paragraph"i]' ); diff --git a/test/e2e/specs/editor/blocks/heading.spec.js b/test/e2e/specs/editor/blocks/heading.spec.js index e1b97d780f2929..1413b4cad9e9d3 100644 --- a/test/e2e/specs/editor/blocks/heading.spec.js +++ b/test/e2e/specs/editor/blocks/heading.spec.js @@ -12,7 +12,7 @@ test.describe( 'Heading', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '### 3' ); await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -27,7 +27,7 @@ test.describe( 'Heading', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '4' ); await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.type( '#### ' ); @@ -44,7 +44,7 @@ test.describe( 'Heading', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '## 1. H' ); await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -59,7 +59,7 @@ test.describe( 'Heading', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '## `code`' ); await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -115,7 +115,7 @@ test.describe( 'Heading', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '### Heading' ); await editor.openDocumentSettingsSidebar(); @@ -147,7 +147,7 @@ test.describe( 'Heading', () => { } ); test( 'should correctly apply named colors', async ( { editor, page } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '## Heading' ); await editor.openDocumentSettingsSidebar(); @@ -185,7 +185,7 @@ test.describe( 'Heading', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '## Heading' ); // Change text alignment @@ -216,7 +216,7 @@ test.describe( 'Heading', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Paragraph' ); // Change text alignment @@ -247,7 +247,7 @@ test.describe( 'Heading', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '## Heading' ); // Change text alignment diff --git a/test/e2e/specs/editor/blocks/html.spec.js b/test/e2e/specs/editor/blocks/html.spec.js index 77e9d2a9186a75..99a875f0810183 100644 --- a/test/e2e/specs/editor/blocks/html.spec.js +++ b/test/e2e/specs/editor/blocks/html.spec.js @@ -10,7 +10,7 @@ test.describe( 'HTML block', () => { test( 'can be created by typing "/html"', async ( { editor, page } ) => { // Create a Custom HTML block with the slash shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/html' ); await expect( page.locator( 'role=option[name="Custom HTML"i][selected]' ) @@ -33,7 +33,7 @@ test.describe( 'HTML block', () => { test( 'should not encode <', async ( { editor, page } ) => { // Create a Custom HTML block with the slash shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/html' ); await expect( page.locator( 'role=option[name="Custom HTML"i][selected]' ) @@ -42,8 +42,9 @@ test.describe( 'HTML block', () => { await page.keyboard.type( '1 < 2' ); await editor.publishPost(); await page.reload(); + await page.waitForSelector( '[name="editor-canvas"]' ); await expect( - page.locator( '[data-type="core/html"] textarea' ) + editor.canvas.locator( '[data-type="core/html"] textarea' ) ).toBeVisible(); } ); } ); diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 123d734f58279d..c9d7bbe464428a 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -32,10 +32,13 @@ test.describe( 'Image', () => { await requestUtils.deleteAllMedia(); } ); - test( 'can be inserted', async ( { editor, page, imageBlockUtils } ) => { + test( 'can be inserted via image upload', async ( { + editor, + imageBlockUtils, + } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); await expect( imageBlock ).toBeVisible(); @@ -56,92 +59,6 @@ test.describe( 'Image', () => { expect( await editor.getEditedPostContent() ).toMatch( regex ); } ); - test( 'should replace, reset size, and keep selection', async ( { - editor, - page, - imageBlockUtils, - } ) => { - await editor.insertBlock( { name: 'core/image' } ); - - const imageBlock = page.locator( - 'role=document[name="Block: Image"i]' - ); - const image = imageBlock.locator( 'role=img' ); - - const filename = await imageBlockUtils.upload( - imageBlock.locator( 'data-testid=form-file-upload-input' ) - ); - - { - await expect( image ).toBeVisible(); - await expect( image ).toHaveAttribute( - 'src', - new RegExp( filename ) - ); - - const regex = new RegExp( - `<!-- wp:image {"id":(\\d+),"sizeSlug":"full","linkDestination":"none"} --> -<figure class="wp-block-image size-full"><img src="[^"]+\\/${ filename }\\.png" alt="" class="wp-image-\\1"/></figure> -<!-- \\/wp:image -->` - ); - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - await editor.openDocumentSettingsSidebar(); - await page.click( - 'role=group[name="Image size presets"i] >> role=button[name="25%"i]' - ); - - await expect( image ).toHaveCSS( 'width', '3px' ); - await expect( image ).toHaveCSS( 'height', '3px' ); - - const regex = new RegExp( - `<!-- wp:image {"id":(\\d+),"width":3,"height":3,"sizeSlug":"full","linkDestination":"none"} --> -<figure class="wp-block-image size-full is-resized"><img src="[^"]+\\/${ filename }\\.png" alt="" class="wp-image-\\1" width="3" height="3"\\/><\\/figure> -<!-- /wp:image -->` - ); - - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - await editor.showBlockToolbar(); - await page.click( 'role=button[name="Replace"i]' ); - - const replacedFilename = await imageBlockUtils.upload( - page - // Ideally the menu should have the name of "Replace" but is currently missing. - // Hence, we fallback to using CSS classname instead. - .locator( '.block-editor-media-replace-flow__options' ) - .locator( 'data-testid=form-file-upload-input' ) - ); - - await expect( image ).toHaveAttribute( - 'src', - new RegExp( replacedFilename ) - ); - await expect( image ).toBeVisible(); - - const regex = new RegExp( - `<!-- wp:image {"id":(\\d+),"sizeSlug":"full","linkDestination":"none"} --> -<figure class="wp-block-image size-full"><img src="[^"]+\\/${ replacedFilename }\\.png" alt="" class="wp-image-\\1"/></figure> -<!-- \\/wp:image -->` - ); - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - // Focus outside the block to avoid the image caption being selected - // It can happen on CI specially. - await page.click( 'role=textbox[name="Add title"i]' ); - await image.click(); - await page.keyboard.press( 'Backspace' ); - - expect( await editor.getEditedPostContent() ).toBe( '' ); - } - } ); - test( 'should place caret on caption when clicking to add one', async ( { editor, page, @@ -149,7 +66,7 @@ test.describe( 'Image', () => { } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -165,7 +82,9 @@ test.describe( 'Image', () => { await page.keyboard.type( '2' ); expect( - await page.evaluate( () => document.activeElement.innerHTML ) + await editor.canvas.evaluate( + () => document.activeElement.innerHTML + ) ).toBe( '12' ); } ); @@ -176,7 +95,7 @@ test.describe( 'Image', () => { } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -193,7 +112,9 @@ test.describe( 'Image', () => { await page.keyboard.press( 'Enter' ); expect( - await page.evaluate( () => document.activeElement.innerHTML ) + await editor.canvas.evaluate( + () => document.activeElement.innerHTML + ) ).toBe( '1<br data-rich-text-line-break="true">2' ); } ); @@ -205,7 +126,7 @@ test.describe( 'Image', () => { } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -245,7 +166,9 @@ test.describe( 'Image', () => { await page.keyboard.press( 'ArrowRight' ); expect( - await page.evaluate( () => document.activeElement.innerHTML ) + await editor.canvas.evaluate( + () => document.activeElement.innerHTML + ) ).toBe( '<strong>a</strong>' ); } ); @@ -256,7 +179,7 @@ test.describe( 'Image', () => { } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -300,7 +223,7 @@ test.describe( 'Image', () => { // Insert the block, upload a file and crop. await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -339,7 +262,7 @@ test.describe( 'Image', () => { // Wait for the cropping tools to disappear. await expect( - page.locator( 'role=button[name="Apply"i]' ) + page.locator( 'role=button[name="Save"i]' ) ).toBeHidden(); // Assert that the image is edited. @@ -366,7 +289,7 @@ test.describe( 'Image', () => { // Insert the block, upload a file and crop. await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -396,7 +319,7 @@ test.describe( 'Image', () => { // Wait for the cropping tools to disappear. await expect( - page.locator( 'role=button[name="Apply"i]' ) + page.locator( 'role=button[name="Save"i]' ) ).toBeHidden(); // Assert that the image is edited. @@ -423,7 +346,7 @@ test.describe( 'Image', () => { // Insert the block, upload a file and crop. await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -441,7 +364,7 @@ test.describe( 'Image', () => { // Wait for the cropping tools to disappear. await expect( - page.locator( 'role=button[name="Apply"i]' ) + page.locator( 'role=button[name="Save"i]' ) ).toBeHidden(); // Assert that the image is edited. @@ -452,74 +375,14 @@ test.describe( 'Image', () => { ).toMatchSnapshot(); } ); - test( 'Should reset dimensions on change URL', async ( { - editor, - page, - imageBlockUtils, - } ) => { - await editor.insertBlock( { name: 'core/image' } ); - - const imageBlock = page.locator( - 'role=document[name="Block: Image"i]' - ); - const image = imageBlock.locator( 'role=img' ); - - { - // Upload an initial image. - const filename = await imageBlockUtils.upload( - imageBlock.locator( 'data-testid=form-file-upload-input' ) - ); - await expect( image ).toHaveAttribute( - 'src', - new RegExp( filename ) - ); - - // Resize the Uploaded Image. - await editor.openDocumentSettingsSidebar(); - await page.click( - 'role=group[name="Image size presets"i] >> role=button[name="25%"i]' - ); - - const regex = new RegExp( - `<!-- wp:image {"id":(\\d+),"width":3,"height":3,"sizeSlug":"full","linkDestination":"none"} --> -<figure class="wp-block-image size-full is-resized"><img src="[^"]+/${ filename }\\.png" alt="" class="wp-image-\\1" width="3" height="3"/></figure> -<!-- /wp:image -->` - ); - - // Check if dimensions are changed. - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - const imageUrl = '/wp-includes/images/w-logo-blue.png'; - - // Replace uploaded image with an URL. - await editor.clickBlockToolbarButton( 'Replace' ); - await page.click( 'role=button[name="Edit"i]' ); - // Replace the url. - await page.fill( 'role=combobox[name="URL"i]', imageUrl ); - await page.click( 'role=button[name="Apply"i]' ); - - const regex = new RegExp( - `<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} --> -<figure class="wp-block-image size-large"><img src="${ imageUrl }" alt=""/></figure> -<!-- /wp:image -->` - ); - - // Check if dimensions are reset. - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - } ); - test( 'should undo without broken temporary state', async ( { editor, - page, pageUtils, imageBlockUtils, } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); const image = imageBlock.locator( 'role=img' ); @@ -529,7 +392,7 @@ test.describe( 'Image', () => { ); await expect( image ).toHaveAttribute( 'src', new RegExp( filename ) ); - await page.focus( '.wp-block-image' ); + await editor.canvas.focus( '.wp-block-image' ); await pageUtils.pressKeys( 'primary+z' ); // Expect an empty image block (placeholder) rather than one with a @@ -543,8 +406,15 @@ test.describe( 'Image', () => { page, editor, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.getByRole( 'document', { + const imageBlock = editor.canvas.getByRole( 'document', { name: 'Block: Image', } ); const blockLibrary = page.getByRole( 'region', { @@ -637,7 +507,7 @@ test.describe( 'Image', () => { editor, } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.getByRole( 'document', { + const imageBlock = editor.canvas.getByRole( 'document', { name: 'Block: Image', } ); @@ -692,13 +562,13 @@ test.describe( 'Image', () => { ); } ); - test( 'should appear in the frontend published post content', async ( { + test( 'image inserted via upload should appear in the frontend published post content', async ( { editor, imageBlockUtils, page, } ) => { await editor.insertBlock( { name: 'core/image' } ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); await expect( imageBlock ).toBeVisible(); @@ -727,195 +597,777 @@ test.describe( 'Image', () => { new RegExp( filename ) ); } ); -} ); -test.describe( 'Image - interactivity', () => { - let filename = null; - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.deleteAllMedia(); - } ); + test( 'image inserted via link should appear in the frontend published post content', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.deleteAllMedia(); - } ); + await imageBlock + .getByRole( 'button' ) + .filter( { hasText: 'Insert from URL' } ) + .click(); - test.beforeEach( async ( { admin, page, editor, imageBlockUtils } ) => { - await admin.visitAdminPage( - '/admin.php', - 'page=gutenberg-experiments' + // This form lacks distinguishing qualities other than the + // class name, so we use page.locator() instead of page.getByRole() + const form = page.locator( + 'form.block-editor-media-placeholder__url-input-form' ); - await page - .locator( `#gutenberg-interactivity-api-core-blocks` ) - .setChecked( true ); + const imgUrl = + 'https://wp20.wordpress.net/wp-content/themes/twentyseventeen-wp20/images/wp20-logo-white.svg'; - await page.locator( `input[name="submit"]` ).click(); - await page.waitForLoadState(); + await form.getByLabel( 'URL' ).fill( imgUrl ); + await form.getByRole( 'button', { name: 'Apply' } ).click(); - await admin.createNewPost(); - await editor.insertBlock( { name: 'core/image' } ); + const imageInEditor = imageBlock.locator( 'role=img' ); + await expect( imageInEditor ).toBeVisible(); + await expect( imageInEditor ).toHaveAttribute( 'src', imgUrl ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const figureDom = page.getByRole( 'figure' ); + await expect( figureDom ).toBeVisible(); - const imageBlock = page.locator( + const imageDom = figureDom.locator( 'img' ); + await expect( imageDom ).toBeVisible(); + await expect( imageDom ).toHaveAttribute( 'src', imgUrl ); + } ); + + test( 'adding a link should reflect configuration in published post content', async ( { + editor, + page, + imageBlockUtils, + } ) => { + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); await expect( imageBlock ).toBeVisible(); - filename = await imageBlockUtils.upload( + await imageBlockUtils.upload( imageBlock.locator( 'data-testid=form-file-upload-input' ) ); - const image = imageBlock.locator( 'role=img' ); - await expect( image ).toBeVisible(); - await expect( image ).toHaveAttribute( 'src', new RegExp( filename ) ); - await editor.openDocumentSettingsSidebar(); - } ); + await page + .getByLabel( 'Block tools' ) + .getByLabel( 'Insert link' ) + .click(); - test.afterEach( async ( { requestUtils, admin, page } ) => { - await requestUtils.deleteAllMedia(); + // This form lacks distinguishing qualities other than the + // class name, so we use page.locator() instead of page.getByRole() + const form = page.locator( '.block-editor-url-popover__link-editor' ); - await admin.visitAdminPage( - '/admin.php', - 'page=gutenberg-experiments' - ); + const url = 'https://wordpress.org'; - await page - .locator( `#gutenberg-interactivity-api-core-blocks` ) - .setChecked( false ); + await form.getByLabel( 'URL' ).fill( url ); - await page.locator( `input[name="submit"]` ).click(); + await form.getByRole( 'button', { name: 'Apply' } ).click(); - await page.waitForLoadState(); - } ); + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); - test( 'should toggle "lightbox" in saved attributes', async ( { - editor, - page, - } ) => { - await page.getByRole( 'button', { name: 'Advanced' } ).click(); - await page - .getByRole( 'combobox', { name: 'Behaviors' } ) - .selectOption( 'lightbox' ); + const figureDom = page.getByRole( 'figure' ); + await expect( figureDom ).toBeVisible(); - let blocks = await editor.getBlocks(); - expect( blocks[ 0 ].attributes ).toMatchObject( { - behaviors: { lightbox: true }, - linkDestination: 'none', - } ); - expect( blocks[ 0 ].attributes.url ).toContain( filename ); + const linkDom = figureDom.locator( 'a' ); + await expect( linkDom ).toBeVisible(); + await expect( linkDom ).toHaveAttribute( 'href', url ); + } ); - await page.getByLabel( 'Behaviors' ).selectOption( '' ); - blocks = await editor.getBlocks(); - expect( blocks[ 0 ].attributes ).toMatchObject( { - behaviors: { lightbox: false }, - linkDestination: 'none', + test( 'should upload external image', async ( { editor } ) => { + await editor.insertBlock( { + name: 'core/image', + attributes: { + url: 'https://cldup.com/cXyG__fTLN.jpg', + }, } ); - expect( blocks[ 0 ].attributes.url ).toContain( filename ); + + await editor.clickBlockToolbarButton( 'Upload external image' ); + + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + const image = imageBlock.locator( 'img[src^="http"]' ); + const src = await image.getAttribute( 'src' ); + + expect( src ).toMatch( /\/wp-content\/uploads\// ); } ); - test( 'should open and close the image in a lightbox using the mouse', async ( { + test( 'should upload through prepublish panel', async ( { editor, page, } ) => { - await page.getByRole( 'button', { name: 'Advanced' } ).click(); + await editor.insertBlock( { + name: 'core/image', + attributes: { + url: 'https://cldup.com/cXyG__fTLN.jpg', + }, + } ); + await page - .getByRole( 'combobox', { name: 'Behaviors' } ) - .selectOption( 'lightbox' ); + .getByRole( 'button', { name: 'Publish', exact: true } ) + .click(); + await page.getByRole( 'button', { name: 'Upload all' } ).click(); - const postId = await editor.publishPost(); - await page.goto( `/?p=${ postId }` ); + await expect( page.locator( '.components-spinner' ) ).toHaveCount( 0 ); - const lightbox = page.locator( '.wp-lightbox-overlay' ); - await expect( lightbox ).toBeHidden(); + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + const image = imageBlock.locator( 'img[src^="http"]' ); + const src = await image.getAttribute( 'src' ); - const image = lightbox.locator( 'img' ); - await expect( image ).toHaveAttribute( 'src', new RegExp( filename ) ); + expect( src ).toMatch( /\/wp-content\/uploads\// ); + } ); +} ); - await page - .getByRole( 'button', { name: 'Open image lightbox' } ) - .click(); +test.describe( 'Image - interactivity', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); - await expect( lightbox ).toBeVisible(); + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); - const closeButton = page.getByRole( 'button', { - name: 'Close lightbox', - } ); - await closeButton.click(); + test.beforeEach( async ( { admin, editor } ) => { + await admin.createNewPost(); + await editor.insertBlock( { name: 'core/image' } ); + } ); - await expect( lightbox ).toBeHidden(); + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); } ); - test.describe( 'keyboard navigation', () => { - let openLightboxButton; - let lightbox; - let closeButton; + test.describe( 'tests using uploaded image', () => { + let filename = null; + + test( 'should toggle "lightbox" in saved attributes', async ( { + editor, + page, + imageBlockUtils, + } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); - test.beforeEach( async ( { page, editor } ) => { await page.getByRole( 'button', { name: 'Advanced' } ).click(); await page .getByRole( 'combobox', { name: 'Behaviors' } ) .selectOption( 'lightbox' ); - const postId = await editor.publishPost(); - await page.goto( `/?p=${ postId }` ); + let blocks = await editor.getBlocks(); + expect( blocks[ 0 ].attributes ).toMatchObject( { + behaviors: { + lightbox: { + animation: 'zoom', + enabled: true, + }, + }, + linkDestination: 'none', + } ); + expect( blocks[ 0 ].attributes.url ).toContain( filename ); + + await page.getByLabel( 'Behaviors' ).selectOption( '' ); + blocks = await editor.getBlocks(); + expect( blocks[ 0 ].attributes ).toMatchObject( { + behaviors: { + lightbox: { + animation: '', + enabled: false, + }, + }, + linkDestination: 'none', + } ); + expect( blocks[ 0 ].attributes.url ).toContain( filename ); + } ); + + test.describe( 'should open and close the image in a lightbox when using a mouse and dynamically load src', () => { + test( 'zoom animation', async ( { + editor, + page, + imageBlockUtils, + } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ), + '3200x2400_e2e_test_image_responsive_lightbox.jpeg' + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ), + { timeout: 10_000 } + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + await page + .getByRole( 'combobox', { name: 'Behaviors' } ) + .selectOption( 'lightbox' ); + + await page + .getByRole( 'combobox', { name: 'Animation' } ) + .selectOption( 'zoom' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // getByRole() doesn't work for the image here for + // some reason, so let's use locators instead + const contentFigure = page.locator( '.entry-content figure' ); + const contentImage = page.locator( + '.entry-content figure img' + ); + + const wpContext = await contentFigure.getAttribute( + 'data-wp-context' + ); + + const imageUploadedSrc = + JSON.parse( wpContext ).core.image.imageUploadedSrc; + + const contentImageCurrentSrc = await contentImage.evaluate( + ( img ) => img.currentSrc + ); + + const lightbox = page.locator( '.wp-lightbox-overlay' ); + await expect( lightbox ).toBeHidden(); + const responsiveImage = lightbox.locator( + '.responsive-image img' + ); + const enlargedImage = lightbox.locator( '.enlarged-image img' ); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); + await expect( enlargedImage ).toHaveAttribute( 'src', '' ); + + await page + .getByRole( 'button', { name: 'Enlarge image' } ) + .click(); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); + await expect( enlargedImage ).toHaveAttribute( + 'src', + imageUploadedSrc + ); + + await expect( lightbox ).toBeVisible(); + + // Use page.evaluate to get the content of the style tag + const styleTagContent = await page.evaluate( () => { + const styleTag = document.querySelector( + 'style#wp-lightbox-styles' + ); + return styleTag ? styleTag.textContent : ''; + } ); - openLightboxButton = page.getByRole( 'button', { - name: 'Open image lightbox', + // Define the keys you want to check for + const keysToCheck = [ + '--wp--lightbox-initial-top-position', + '--wp--lightbox-initial-left-position', + '--wp--lightbox-container-width', + '--wp--lightbox-container-height', + '--wp--lightbox-image-width', + '--wp--lightbox-image-height', + '--wp--lightbox-scale', + ]; + + // Check if all the keys are present in the style tag's content + const keysPresent = keysToCheck.every( ( key ) => + styleTagContent.includes( key ) + ); + + expect( keysPresent ).toBe( true ); + + const closeButton = lightbox.getByRole( 'button', { + name: 'Close', + } ); + await closeButton.click(); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); + await expect( enlargedImage ).toHaveAttribute( + 'src', + imageUploadedSrc + ); + + await expect( lightbox ).toBeHidden(); } ); - lightbox = page.getByRole( 'dialog' ); - closeButton = lightbox.getByRole( 'button', { - name: 'Close lightbox', + + test( 'fade animation', async ( { + editor, + page, + imageBlockUtils, + } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ), + '3200x2400_e2e_test_image_responsive_lightbox.jpeg' + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ), + { timeout: 10_000 } + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + await page + .getByRole( 'combobox', { name: 'Behaviors' } ) + .selectOption( 'lightbox' ); + await page + .getByRole( 'combobox', { name: 'Animation' } ) + .selectOption( 'fade' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // getByRole() doesn't work for the image here for + // some reason, so let's use locators instead + const contentFigure = page.locator( '.entry-content figure' ); + const contentImage = page.locator( + '.entry-content figure img' + ); + + const wpContext = await contentFigure.getAttribute( + 'data-wp-context' + ); + + const imageUploadedSrc = + JSON.parse( wpContext ).core.image.imageUploadedSrc; + + const contentImageCurrentSrc = await contentImage.evaluate( + ( img ) => img.currentSrc + ); + + const lightbox = page.locator( '.wp-lightbox-overlay' ); + await expect( lightbox ).toBeHidden(); + const responsiveImage = lightbox.locator( + '.responsive-image img' + ); + const enlargedImage = lightbox.locator( '.enlarged-image img' ); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); + await expect( enlargedImage ).toHaveAttribute( 'src', '' ); + + await page + .getByRole( 'button', { name: 'Enlarge image' } ) + .click(); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); + await expect( enlargedImage ).toHaveAttribute( + 'src', + imageUploadedSrc + ); + + await expect( lightbox ).toBeVisible(); + + const closeButton = lightbox.getByRole( 'button', { + name: 'Close', + } ); + await closeButton.click(); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); + await expect( enlargedImage ).toHaveAttribute( + 'src', + imageUploadedSrc + ); + + await expect( lightbox ).toBeHidden(); } ); } ); - test( 'should open and focus appropriately using enter key', async ( { + test( 'lightbox should be overriden when link is configured for image', async ( { + editor, page, + imageBlockUtils, } ) => { - // Open and close lightbox using the close button - await openLightboxButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( lightbox ).toBeVisible(); - await expect( closeButton ).toBeFocused(); + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const behaviorSelect = page.getByRole( 'combobox', { + name: 'Behaviors', + } ); + await behaviorSelect.selectOption( 'lightbox' ); + + await page + .getByLabel( 'Block tools' ) + .getByLabel( 'Insert link' ) + .click(); + + const form = page.locator( + '.block-editor-url-popover__link-editor' + ); + + const url = 'https://wordpress.org'; + + await form.getByLabel( 'URL' ).fill( url ); + + await form.getByRole( 'button', { name: 'Apply' } ).click(); + await expect( behaviorSelect ).toBeDisabled(); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // The lightbox markup should not appear in the DOM at all + await expect( + page.getByRole( 'button', { name: 'Enlarge image' } ) + ).not.toBeInViewport(); } ); - test( 'should close and focus appropriately using enter key on close button', async ( { + test( 'markup should not appear if Lightbox is disabled', async ( { + editor, page, + imageBlockUtils, } ) => { - // Open and close lightbox using the close button - await openLightboxButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( lightbox ).toBeVisible(); - await expect( closeButton ).toBeFocused(); - await page.keyboard.press( 'Enter' ); - await expect( lightbox ).toBeHidden(); - await expect( openLightboxButton ).toBeFocused(); + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const behaviorSelect = page.getByRole( 'combobox', { + name: 'Behaviors', + } ); + await behaviorSelect.selectOption( '' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // The lightbox markup should not appear in the DOM at all + await expect( + page.getByRole( 'button', { name: 'Enlarge image' } ) + ).not.toBeInViewport(); } ); - test( 'should close and focus appropriately using escape key', async ( { - page, - } ) => { - await openLightboxButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( lightbox ).toBeVisible(); - await expect( closeButton ).toBeFocused(); - await page.keyboard.press( 'Escape' ); - await expect( lightbox ).toBeHidden(); - await expect( openLightboxButton ).toBeFocused(); + test.describe( 'Animation Select visibility', () => { + test( 'Animation selector should appear if Behavior is Lightbox', async ( { + editor, + page, + imageBlockUtils, + } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const behaviorSelect = page.getByRole( 'combobox', { + name: 'Behaviors', + } ); + await behaviorSelect.selectOption( 'lightbox' ); + await expect( + page.getByRole( 'combobox', { + name: 'Animation', + } ) + ).toBeVisible(); + } ); + test( 'Animation selector should NOT appear if Behavior is None', async ( { + page, + editor, + imageBlockUtils, + } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const behaviorSelect = page.getByRole( 'combobox', { + name: 'Behaviors', + } ); + await behaviorSelect.selectOption( '' ); + await expect( + page.getByRole( 'combobox', { + name: 'Animation', + } ) + ).not.toBeVisible(); + } ); + test( 'Animation selector should NOT appear if Behavior is Default', async ( { + page, + editor, + imageBlockUtils, + } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + const behaviorSelect = page.getByRole( 'combobox', { + name: 'Behaviors', + } ); + await behaviorSelect.selectOption( 'default' ); + await expect( + page.getByRole( 'combobox', { + name: 'Animation', + } ) + ).not.toBeVisible(); + } ); } ); - // TO DO: Add these tests, which will involve adding a caption - // to uploaded test images - // test( 'should trap focus appropriately when using tab', async ( { - // page, - // } ) => { + test.describe( 'keyboard navigation', () => { + let openLightboxButton; + let lightbox; + let closeButton; + + test.beforeEach( async ( { page, editor, imageBlockUtils } ) => { + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + const image = imageBlock.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + await editor.openDocumentSettingsSidebar(); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + await page + .getByRole( 'combobox', { name: 'Behaviors' } ) + .selectOption( 'lightbox' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + openLightboxButton = page.getByRole( 'button', { + name: 'Enlarge image', + } ); + lightbox = page.getByRole( 'dialog' ); + closeButton = lightbox.getByRole( 'button', { + name: 'Close', + } ); + } ); + + test( 'should open and focus appropriately using enter key', async ( { + page, + } ) => { + // Open and close lightbox using the close button + await openLightboxButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( lightbox ).toBeVisible(); + await expect( closeButton ).toBeFocused(); + } ); + + test( 'should close and focus appropriately using enter key on close button', async ( { + page, + } ) => { + // Open and close lightbox using the close button + await openLightboxButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( lightbox ).toBeVisible(); + await expect( closeButton ).toBeFocused(); + await page.keyboard.press( 'Enter' ); + await expect( lightbox ).toBeHidden(); + await expect( openLightboxButton ).toBeFocused(); + } ); + + test( 'should close and focus appropriately using escape key', async ( { + page, + } ) => { + await openLightboxButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( lightbox ).toBeVisible(); + await expect( closeButton ).toBeFocused(); + await page.keyboard.press( 'Escape' ); + await expect( lightbox ).toBeHidden(); + await expect( openLightboxButton ).toBeFocused(); + } ); + + // TO DO: Add these tests, which will involve adding a caption + // to uploaded test images + // test( 'should trap focus appropriately when using tab', async ( { + // page, + // } ) => { + + // } ); + + // test( 'should trap focus appropriately using shift+tab', async ( { + // page, + // } ) => { + + // } ); + } ); + } ); + + test( 'lightbox should work as expected when inserting image from URL', async ( { + editor, + page, + } ) => { + await editor.openDocumentSettingsSidebar(); + + const imageBlockFromUrl = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlockFromUrl ).toBeVisible(); + + await imageBlockFromUrl + .getByRole( 'button' ) + .filter( { hasText: 'Insert from URL' } ) + .click(); + + const form = page.locator( + '.block-editor-media-placeholder__url-input-form' + ); + + const imgUrl = + 'https://wp20.wordpress.net/wp-content/themes/twentyseventeen-wp20/images/wp20-logo-white.svg'; + + await form.getByLabel( 'URL' ).fill( imgUrl ); + + await form.getByRole( 'button', { name: 'Apply' } ).click(); + + const image = imageBlockFromUrl.locator( 'role=img' ); + await expect( image ).toBeVisible(); + await expect( image ).toHaveAttribute( 'src', imgUrl ); + + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + await page + .getByRole( 'combobox', { name: 'Behaviors' } ) + .selectOption( 'lightbox' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const lightbox = page.locator( '.wp-lightbox-overlay' ); + const responsiveImage = lightbox.locator( '.responsive-image img' ); + const enlargedImage = lightbox.locator( '.enlarged-image img' ); + + await expect( responsiveImage ).toHaveAttribute( + 'src', + new RegExp( imgUrl ) + ); + await expect( enlargedImage ).toHaveAttribute( 'src', '' ); + + await page.getByRole( 'button', { name: 'Enlarge image' } ).click(); - // } ); + await expect( responsiveImage ).toHaveAttribute( 'src', imgUrl ); + await expect( enlargedImage ).toHaveAttribute( 'src', imgUrl ); - // test( 'should trap focus appropriately using shift+tab', async ( { - // page, - // } ) => { + await page.getByRole( 'button', { name: 'Close' } ).click(); - // } ); + await expect( responsiveImage ).toHaveAttribute( 'src', imgUrl ); + await expect( enlargedImage ).toHaveAttribute( 'src', imgUrl ); } ); } ); @@ -923,24 +1375,24 @@ class ImageBlockUtils { constructor( { page } ) { /** @type {Page} */ this.page = page; + this.basePath = path.join( __dirname, '..', '..', '..', 'assets' ); this.TEST_IMAGE_FILE_PATH = path.join( - __dirname, - '..', - '..', - '..', - 'assets', + this.basePath, '10x10_e2e_test_image_z9T8jK.png' ); } - async upload( inputElement ) { + async upload( inputElement, customFile = null ) { const tmpDirectory = await fs.mkdtemp( path.join( os.tmpdir(), 'gutenberg-test-image-' ) ); const filename = uuid(); const tmpFileName = path.join( tmpDirectory, filename + '.png' ); - await fs.copyFile( this.TEST_IMAGE_FILE_PATH, tmpFileName ); + const filepath = customFile + ? path.join( this.basePath, customFile ) + : this.TEST_IMAGE_FILE_PATH; + await fs.copyFile( filepath, tmpFileName ); await inputElement.setInputFiles( tmpFileName ); diff --git a/test/e2e/specs/editor/blocks/links.spec.js b/test/e2e/specs/editor/blocks/links.spec.js new file mode 100644 index 00000000000000..b84f954566fd55 --- /dev/null +++ b/test/e2e/specs/editor/blocks/links.spec.js @@ -0,0 +1,236 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Links', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( `can be created by selecting text and using keyboard shortcuts`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Press Cmd+K to insert a link. + await pageUtils.pressKeys( 'primary+K' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Ensure that the contents of the post have not been changed, since at + // this point the link is still not inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'This is Gutenberg' }, + }, + ] ); + + await page.keyboard.press( 'Enter' ); + + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Edit link. + await page.getByRole( 'button', { name: 'Edit' } ).click(); + + // Open settings. + await page + .getByRole( 'region', { + name: 'Editor content', + } ) + .getByRole( 'button', { + name: 'Advanced', + } ) + .click(); + + // Navigate to and toggle the "Open in new tab" checkbox. + const checkbox = page.getByLabel( 'Open in new tab' ); + await checkbox.click(); + + // Toggle should still have focus and be checked. + await expect( checkbox ).toBeChecked(); + await expect( checkbox ).toBeFocused(); + + // Tab back to the Submit and apply the link. + await page + //TODO: change to a better selector when https://github.com/WordPress/gutenberg/issues/51060 is resolved. + .locator( '.block-editor-link-control' ) + .getByRole( 'button', { name: 'Save' } ) + .click(); + + // The link should have been inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is <a href="https://wordpress.org/gutenberg" target="_blank" rel="noreferrer noopener">Gutenberg</a>', + }, + }, + ] ); + } ); + + test( 'can update the url of an existing link', async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is WordPress' ); + // Select "WordPress". + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+k' ); + await page.keyboard.type( 'w.org' ); + + await page.keyboard.press( 'Enter' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is <a href="http://w.org">WordPress</a>', + }, + }, + ] ); + + // Move caret back into the link. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Edit link. + await pageUtils.pressKeys( 'primary+k' ); + await page.getByPlaceholder( 'Search or type url' ).fill( '' ); + await page.keyboard.type( 'wordpress.org' ); + + // Update the link. + await page + //TODO: change to a better selector when https://github.com/WordPress/gutenberg/issues/51060 is resolved. + .locator( '.block-editor-link-control' ) + .getByRole( 'button', { name: 'Save' } ) + .click(); + + // Navigate back to the popover. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Navigate back to inputs to verify appears as changed. + await pageUtils.pressKeys( 'primary+k' ); + const urlInputValue = await page + .getByPlaceholder( 'Search or type url' ) + .inputValue(); + expect( urlInputValue ).toContain( 'wordpress.org' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is <a href="http://wordpress.org">WordPress</a>', + }, + }, + ] ); + } ); + + test( 'toggle state of advanced link settings is preserved across editing links', async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg WordPress' ); + + // Select "WordPress". + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Create a link. + await pageUtils.pressKeys( 'primary+k' ); + await page.keyboard.type( 'w.org' ); + await page.keyboard.press( 'Enter' ); + + // Move to edge of text "Gutenberg". + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); // If you just use Alt here it won't work on windows. + await pageUtils.pressKeys( 'ArrowLeft' ); + await pageUtils.pressKeys( 'ArrowLeft' ); + + // Select "Gutenberg". + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Create a link. + await pageUtils.pressKeys( 'primary+k' ); + await page.keyboard.type( 'https://wordpress.org/plugins/gutenberg/' ); + await page.keyboard.press( 'Enter' ); + + // Move back into the link. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+k' ); + + // Toggle the Advanced settings to be open. + // This should set the editor preference to persist this + // UI state. + await page + .getByRole( 'region', { + name: 'Editor content', + } ) + .getByRole( 'button', { + name: 'Advanced', + } ) + .click(); + + // Move focus out of Link UI and into Paragraph block. + await pageUtils.pressKeys( 'Escape' ); + + // Move caret back into the "WordPress" link to trigger + // the Link UI for that link. + await pageUtils.pressKeys( 'Alt+ArrowRight' ); + await pageUtils.pressKeys( 'ArrowRight' ); + await pageUtils.pressKeys( 'ArrowRight' ); + + // Switch Link UI to "edit" mode. + await page.getByRole( 'button', { name: 'Edit' } ).click(); + + // Check that the Advanced settings are still expanded/open + // and I can see the open in new tab checkbox. This verifies + // that the editor preference was persisted. + await expect( page.getByLabel( 'Open in new tab' ) ).toBeVisible(); + + // Toggle the Advanced settings back to being closed. + await page + .getByRole( 'region', { + name: 'Editor content', + } ) + .getByRole( 'button', { + name: 'Advanced', + } ) + .click(); + + // Move focus out of Link UI and into Paragraph block. + await pageUtils.pressKeys( 'Escape' ); + + // Move caret back into the "Gutenberg" link and open + // the Link UI for that link. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+k' ); + + // Check that the Advanced settings are still closed. + // This verifies that the editor preference was persisted. + await expect( page.getByLabel( 'Open in new tab' ) ).not.toBeVisible(); + } ); +} ); diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index a4af98f0ba0578..f51b559e61384a 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -13,7 +13,7 @@ test.describe( 'List (@firefox)', () => { page, } ) => { // Create a block with some text that will trigger a list creation. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* A list item' ); // Create a second list item. @@ -38,7 +38,7 @@ test.describe( 'List (@firefox)', () => { pageUtils, } ) => { // Create a list with the slash block shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'test' ); await pageUtils.pressKeys( 'ArrowLeft', { times: 4 } ); await page.keyboard.type( '* ' ); @@ -56,7 +56,7 @@ test.describe( 'List (@firefox)', () => { page, } ) => { // Create a block with some text that will trigger a list creation. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1) A list item' ); await expect.poll( editor.getEditedPostContent ).toBe( @@ -73,7 +73,7 @@ test.describe( 'List (@firefox)', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1. ' ); await pageUtils.pressKeys( 'primary+z' ); @@ -88,7 +88,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* ' ); await page.keyboard.press( 'Backspace' ); @@ -103,9 +103,11 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* ' ); - await expect( page.locator( '[data-type="core/list"]' ) ).toBeVisible(); + await expect( + editor.canvas.locator( '[data-type="core/list"]' ) + ).toBeVisible(); await page.keyboard.press( 'Backspace' ); await expect.poll( editor.getEditedPostContent ).toBe( @@ -119,7 +121,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* ' ); await editor.showBlockToolbar(); await page.keyboard.press( 'Backspace' ); @@ -135,10 +137,12 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.evaluate( () => delete window.requestIdleCallback ); await page.keyboard.type( '* ' ); - await expect( page.locator( '[data-type="core/list"]' ) ).toBeVisible(); + await expect( + editor.canvas.locator( '[data-type="core/list"]' ) + ).toBeVisible(); await page.keyboard.press( 'Backspace' ); await expect.poll( editor.getEditedPostContent ).toBe( @@ -152,7 +156,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* ' ); await page.keyboard.press( 'Escape' ); @@ -167,7 +171,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* a' ); await page.keyboard.press( 'Backspace' ); await page.keyboard.press( 'Backspace' ); @@ -179,9 +183,11 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* ' ); - await expect( page.locator( '[data-type="core/list"]' ) ).toBeVisible(); + await expect( + editor.canvas.locator( '[data-type="core/list"]' ) + ).toBeVisible(); // Wait until the automatic change is marked as "final", which is done // with an idle callback, see __unstableMarkAutomaticChange. await page.evaluate( () => new Promise( window.requestIdleCallback ) ); @@ -194,7 +200,7 @@ test.describe( 'List (@firefox)', () => { test( 'can be created by typing "/list"', async ( { editor, page } ) => { // Create a list with the slash block shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/list' ); await expect( page.locator( 'role=option[name="List"i][selected]' ) @@ -215,7 +221,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'test' ); await editor.transformBlockTo( 'core/list' ); @@ -232,12 +238,12 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'one' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'two' ); await page.keyboard.down( 'Shift' ); - await page.click( '[data-type="core/paragraph"] >> nth=0' ); + await editor.canvas.click( '[data-type="core/paragraph"] >> nth=0' ); await page.keyboard.up( 'Shift' ); await editor.transformBlockTo( 'core/list' ); @@ -259,7 +265,7 @@ test.describe( 'List (@firefox)', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'one' ); await pageUtils.pressKeys( 'shift+Enter' ); await page.keyboard.type( 'two' ); @@ -283,14 +289,14 @@ test.describe( 'List (@firefox)', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'one' ); await pageUtils.pressKeys( 'shift+Enter' ); await page.keyboard.type( '...' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'two' ); await page.keyboard.down( 'Shift' ); - await page.click( '[data-type="core/paragraph"] >> nth=0' ); + await editor.canvas.click( '[data-type="core/paragraph"] >> nth=0' ); await page.keyboard.up( 'Shift' ); await editor.transformBlockTo( 'core/list' ); @@ -555,7 +561,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1. one' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'two' ); @@ -729,6 +735,13 @@ test.describe( 'List (@firefox)', () => { } ); test( 'should indent and outdent level 2', async ( { editor, page } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.insertBlock( { name: 'core/list' } ); await page.keyboard.type( 'a' ); await page.keyboard.press( 'Enter' ); @@ -897,7 +910,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* 1' ); // Should be at level 0. await page.keyboard.press( 'Enter' ); @@ -1011,7 +1024,7 @@ test.describe( 'List (@firefox)', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* 1' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( ' a' ); @@ -1042,7 +1055,7 @@ test.describe( 'List (@firefox)', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* 1' ); await page.keyboard.press( 'Enter' ); @@ -1065,7 +1078,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); // Tests the shortcut with a non breaking space. await page.keyboard.type( '*\u00a0' ); @@ -1081,7 +1094,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); // Tests the shortcut with a non breaking space. await page.keyboard.type( '* 1' ); @@ -1145,7 +1158,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* 1' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '2' ); @@ -1170,7 +1183,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* 1' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '2' ); @@ -1200,7 +1213,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1. a' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'b' ); @@ -1228,17 +1241,19 @@ test.describe( 'List (@firefox)', () => { test( 'can be created by pasting an empty list (-firefox)', async ( { editor, + page, pageUtils, } ) => { // Open code editor await pageUtils.pressKeys( 'secondary+M' ); // Emulates CTRL+Shift+Alt + M => toggle code editor - // Paste empty list block - pageUtils.setClipboardData( { - plainText: - '<!-- wp:list -->\n<ul><li></li></ul>\n<!-- /wp:list -->', - } ); - await pageUtils.pressKeys( 'primary+v' ); + // Add empty list block + await page.getByPlaceholder( 'Start writing with text or HTML' ) + .fill( `<!-- wp:list --> +<ul><!-- wp:list-item --> +<li></li> +<!-- /wp:list-item --></ul> +<!-- /wp:list -->` ); // Go back to normal editor await pageUtils.pressKeys( 'secondary+M' ); // Emulates CTRL+Shift+Alt + M => toggle code editor @@ -1255,7 +1270,7 @@ test.describe( 'List (@firefox)', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* a' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'b' ); @@ -1298,7 +1313,7 @@ test.describe( 'List (@firefox)', () => { } ); test( 'can be exited to selected paragraph', async ( { editor, page } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '* ' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '1' ); @@ -1324,7 +1339,7 @@ test.describe( 'List (@firefox)', () => { } ); await editor.selectBlocks( - page.locator( 'role=document[name="Block: List"i]' ) + editor.canvas.locator( 'role=document[name="Block: List"i]' ) ); await page.getByRole( 'button', { name: 'List', exact: true } ).click(); @@ -1352,4 +1367,85 @@ test.describe( 'List (@firefox)', () => { <!-- /wp:list-item --></ul> <!-- /wp:list -->` ); } ); + + test( 'should merge two list items with nested lists', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: '1' }, + innerBlocks: [ + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: 'a' }, + }, + ], + }, + ], + }, + { + name: 'core/list-item', + attributes: { content: '2' }, + innerBlocks: [ + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: 'b' }, + }, + ], + }, + ], + }, + ], + } ); + + // Navigate to the third item. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + + await page.keyboard.press( 'Backspace' ); + + // Test caret position. + await page.keyboard.type( '‸' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: '1' }, + innerBlocks: [ + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: 'a‸2' }, + }, + { + name: 'core/list-item', + attributes: { content: 'b' }, + }, + ], + }, + ], + }, + ], + }, + ] ); + } ); } ); diff --git a/test/e2e/specs/editor/blocks/navigation-colors.spec.js b/test/e2e/specs/editor/blocks/navigation-colors.spec.js new file mode 100644 index 00000000000000..2763b1c208cd00 --- /dev/null +++ b/test/e2e/specs/editor/blocks/navigation-colors.spec.js @@ -0,0 +1,503 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Navigation colors', () => { + test.beforeAll( async ( { requestUtils } ) => { + // We want emptytheme because it doesn't have any styles. + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllMenus(), + requestUtils.deleteAllPages(), + ] ); + } ); + + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + const { id: pageId } = await requestUtils.createPage( { + title: 'Test Page', + status: 'publish', + } ); + + const { id: menuId } = await requestUtils.createNavigationMenu( { + title: 'Colored menu', + content: `<!-- wp:navigation-submenu {"label":"Custom Link","type":"custom","url":"https://wordpress.org","kind":"custom"} --><!-- wp:navigation-link {"label":"Submenu Link","type":"custom","url":"https://wordpress.org","kind":"custom"} /--><!-- /wp:navigation-submenu --><!-- wp:navigation-link {"label":"Page Link","type":"page","id": ${ pageId },"url":"http://localhost:8889/?page_id=${ pageId }","kind":"post-type"} /-->`, + attributes: { openSubmenusOnClick: true }, + } ); + + await admin.createNewPost(); + + await editor.insertBlock( { + name: 'core/navigation', + attributes: { + ref: menuId, + }, + } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.deleteAllMenus(), + requestUtils.deleteAllPages(), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + ] ); + } ); + + test.use( { + colorControl: async ( { admin, editor, page, pageUtils }, use ) => { + await use( new ColorControl( { admin, editor, page, pageUtils } ) ); + }, + } ); + + test( 'All navigation links should default to the body color and submenus and mobile overlay should default to a white background with black text', async ( { + editor, + page, + colorControl, + } ) => { + const expectedNavigationColors = { + textColor: colorControl.black, + backgroundColor: colorControl.transparent, // There should be no background color set even though the body background is black. + submenuTextColor: colorControl.black, + submenuBackgroundColor: colorControl.white, + }; + + await colorControl.testEditorColors( expectedNavigationColors ); + + // Check the colors of the links on the frontend. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await colorControl.testFrontendColors( expectedNavigationColors ); + } ); + + test( 'Top level navigation links inherit the text color from the theme/group but do not apply to the submenu or overlay text', async ( { + page, + editor, + colorControl, + } ) => { + // Set the text and background styles for the group. The text color should apply to the top level links but not the submenu or overlay. + // We wrap the nav block inside a group block. + await page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Group' } ).click(); + + // In the sidebar inspector we add a link color and link hover color to the group block. + await editor.openDocumentSettingsSidebar(); + await page.getByRole( 'tab', { name: 'Styles' } ).click(); + await page.getByRole( 'button', { name: 'Color Text styles' } ).click(); + await page + .getByRole( 'button', { name: 'Color: White' } ) + .click( { force: true } ); + + await page + .getByRole( 'button', { name: 'Color Background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Black' } ) + .click( { force: true } ); + + // Close the sidebar so our selectors don't accidentally select the sidebar links instead of the editor canvas. + await page + .getByRole( 'button', { name: 'Close Settings' } ) + .click( { force: true } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .click(); + + const expectedNavigationColors = { + textColor: colorControl.white, + backgroundColor: colorControl.transparent, // There should be no background color set even though the body background is black. + submenuTextColor: colorControl.black, + submenuBackgroundColor: colorControl.white, + }; + + await colorControl.testEditorColors( expectedNavigationColors ); + + // Check the colors of the links on the frontend. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await colorControl.testFrontendColors( expectedNavigationColors ); + } ); + + test( 'Navigation text does not inherit the link color from the theme/group', async ( { + page, + editor, + colorControl, + } ) => { + // Wrap the nav block inside a group block. + await page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Group' } ).click(); + + // In the sidebar inspector we add a link color and link hover color to the group block. + await editor.openDocumentSettingsSidebar(); + await page.getByRole( 'tab', { name: 'Styles' } ).click(); + await page.getByRole( 'button', { name: 'Color options' } ).click(); + await page + .getByRole( 'menuitemcheckbox', { name: 'Show Link' } ) + .click(); + await page.getByRole( 'tab', { name: 'Styles' } ).click(); + await page.getByRole( 'button', { name: 'Color Link styles' } ).click(); + // rga(207, 46 ,46) is the color of the "vivid red" color preset. + await page + .getByRole( 'button', { name: 'Color: Vivid red' } ) + .click( { force: true } ); + await page.getByRole( 'tab', { name: 'Hover' } ).click(); + // rgb(155, 81, 224) is the color of the "vivid purple" color preset. + await page + .getByRole( 'button', { name: 'Color: Vivid purple' } ) + .click( { force: true } ); + + // Close the sidebar so our selectors don't accidentally select the sidebar links instead of the editor canvas. + await page + .getByRole( 'button', { name: 'Close Settings' } ) + .click( { force: true } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .click(); + + const expectedNavigationColors = { + textColor: colorControl.black, + backgroundColor: colorControl.transparent, + submenuTextColor: colorControl.black, + submenuBackgroundColor: colorControl.white, + }; + + await colorControl.testEditorColors( expectedNavigationColors ); + + // Check the colors of the links on the frontend. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await colorControl.testFrontendColors( expectedNavigationColors ); + } ); + + test( 'The navigation text color should apply to all navigation links including submenu and overlay text', async ( { + page, + editor, + colorControl, + } ) => { + await editor.openDocumentSettingsSidebar(); + + // In the inspector sidebar, we change the nav block colors. + await page.getByRole( 'tab', { name: 'Styles' } ).click(); + // Pale pink for the text color. + await page.getByRole( 'button', { name: 'Text', exact: true } ).click(); + // 247, 141, 167 is the color of the "Pale pink" color preset. + const palePink = 'rgb(247, 141, 167)'; + await page + .getByRole( 'button', { name: 'Color: Pale pink' } ) + .click( { force: true } ); + + // Close the sidebar so our selectors don't accidentally select the sidebar links instead of the editor canvas. + await page + .getByRole( 'button', { name: 'Close Settings' } ) + .click( { force: true } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .click(); + + const expectedNavigationColors = { + textColor: palePink, + backgroundColor: colorControl.transparent, // There should be no background color set. + submenuTextColor: palePink, + submenuBackgroundColor: colorControl.white, + }; + + await colorControl.testEditorColors( expectedNavigationColors ); + + // Check the colors of the links on the frontend. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await colorControl.testFrontendColors( expectedNavigationColors ); + } ); + + test( 'The navigation background color should apply to all navigation links including submenu and overlay backgrounds', async ( { + page, + editor, + colorControl, + } ) => { + await editor.openDocumentSettingsSidebar(); + + // In the inspector sidebar, we change the nav block colors. + await page.getByRole( 'tab', { name: 'Styles' } ).click(); + // Pale pink for the text color. + await page + .getByRole( 'button', { name: 'Background', exact: true } ) + .click(); + // 142, 209, 252 is the color of the "Pale cyan blue" color preset. + const paleCyan = 'rgb(142, 209, 252)'; + await page + .getByRole( 'button', { name: 'Color: Pale cyan blue' } ) + .click( { force: true } ); + + // Close the sidebar so our selectors don't accidentally select the sidebar links instead of the editor canvas. + await page + .getByRole( 'button', { name: 'Close Settings' } ) + .click( { force: true } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .click(); + + // The navigation background, submenu background and overlay background should all be paleCyan. + const expectedNavigationColors = { + textColor: colorControl.black, + backgroundColor: paleCyan, + submenuTextColor: colorControl.black, + submenuBackgroundColor: paleCyan, + }; + + await colorControl.testEditorColors( expectedNavigationColors ); + + // Check the colors of the links on the frontend. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await colorControl.testFrontendColors( expectedNavigationColors ); + } ); + + test( 'As a user I expect my navigation to use the colors I selected for it', async ( { + editor, + page, + colorControl, + } ) => { + await editor.openDocumentSettingsSidebar(); + + // In the inspector sidebar, we change the nav block colors. + await page.getByRole( 'tab', { name: 'Styles' } ).click(); + // Pale pink for the text color. + await page.getByRole( 'button', { name: 'Text', exact: true } ).click(); + // 247, 141, 167 is the color of the "Pale pink" color preset. + const palePink = 'rgb(247, 141, 167)'; + await page + .getByRole( 'button', { name: 'Color: Pale pink' } ) + .click( { force: true } ); + // Pale cyan blue for the background color. + await page + .getByRole( 'button', { name: 'Background', exact: true } ) + .click(); + // 142, 209, 252 is the color of the "Pale cyan blue" color preset. + const paleCyan = 'rgb(142, 209, 252)'; + await page + .getByRole( 'button', { name: 'Color: Pale cyan blue' } ) + .click( { force: true } ); + // Cyan bluish gray for the submenu and overlay text color. + await page + .getByRole( 'button', { name: 'Submenu & overlay text' } ) + .click(); + // 171, 184, 195 is the color of the "Cyan bluish gray" color preset. + const cyanBluishGray = 'rgb(171, 184, 195)'; + await page + .getByRole( 'button', { name: 'Color: Cyan bluish gray' } ) + .click( { force: true } ); + // Luminous vivid amber for the submenu and overlay background color. + await page + .getByRole( 'button', { name: 'Submenu & overlay background' } ) + .click(); + // 252, 185, 0 is the color of the "Luminous vivid amber" color preset. + const vividAmber = 'rgb(252, 185, 0)'; + await page + .getByRole( 'button', { name: 'Color: Luminous vivid amber' } ) + .click( { force: true } ); + + // Close the sidebar so our selectors don't accidentally select the sidebar links instead of the editor canvas. + await page + .getByRole( 'button', { name: 'Close Settings' } ) + .click( { force: true } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .click(); + + const expectedNavigationColors = { + textColor: palePink, + backgroundColor: paleCyan, // There should be no background color set. + submenuTextColor: cyanBluishGray, + submenuBackgroundColor: vividAmber, + }; + + await colorControl.testEditorColors( expectedNavigationColors ); + + // Check the colors of the links on the frontend. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await colorControl.testFrontendColors( expectedNavigationColors ); + } ); +} ); + +class ColorControl { + constructor( { admin, editor, page, pageUtils } ) { + this.admin = admin; + this.editor = editor; + this.page = page; + this.pageUtils = pageUtils; + + // Colors for readability. + this.black = 'rgb(0, 0, 0)'; + this.white = 'rgb(255, 255, 255)'; + // If there is no background color set, it will not have any background, which computes to 'rgab(0, 0, 0, 0)'. + this.transparent = 'rgba(0, 0, 0, 0)'; + } + + async testEditorColors( { + textColor, + backgroundColor, + submenuTextColor, + submenuBackgroundColor, + } ) { + // Editor elements. + const customLink = this.editor.canvas + .locator( 'a' ) + .filter( { hasText: 'Custom Link' } ); + const pageLink = this.editor.canvas + .locator( 'a' ) + .filter( { hasText: 'Page Link' } ); + + await expect( customLink ).toHaveCSS( 'color', textColor ); + await expect( pageLink ).toHaveCSS( 'color', textColor ); + // Navigation background. + const navigationWrapper = this.editor.canvas.getByRole( 'document', { + name: 'Block: Navigation', + } ); + await expect( navigationWrapper ).toHaveCSS( + 'background-color', + backgroundColor + ); + + await customLink.click(); + + // Submenu elements. + const submenuLink = this.editor.canvas + .locator( 'a' ) + .filter( { hasText: 'Submenu Link' } ); + const submenuWrapper = this.editor.canvas + .getByRole( 'document', { name: 'Block: Custom Link' } ) + .filter( { has: submenuLink } ); + + // Submenu link color. + await expect( submenuLink ).toHaveCSS( 'color', submenuTextColor ); + + // Submenu background color. + await expect( submenuWrapper ).toHaveCSS( + 'background-color', + submenuBackgroundColor + ); + + // Switch to mobile view for the rest of the editor color tests. + // Focus the navigation block. + await this.editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .click(); + await this.editor.openDocumentSettingsSidebar(); + // Switch to settings tab. + await this.page.getByRole( 'tab', { name: 'Settings' } ).click(); + // Set it to always be the mobile view, but don't save this setting so we can still check all the frontend colors. + await this.page.getByRole( 'radio', { name: 'Always' } ).click(); + await this.editor.canvas + .getByRole( 'button', { name: 'Open menu' } ) + .click(); + + const overlay = this.editor.canvas + .locator( '.wp-block-navigation__responsive-container' ) + .filter( { hasText: 'Submenu Link' } ); + + // All of the mobile menu navigation links should be the same color as the submenuTextColor. + await expect( customLink ).toHaveCSS( 'color', submenuTextColor ); + await expect( submenuLink ).toHaveCSS( 'color', submenuTextColor ); + await expect( pageLink ).toHaveCSS( 'color', submenuTextColor ); + + // The mobile menu background should be the same color as the submenu background. + await expect( overlay ).toHaveCSS( + 'background-color', + submenuBackgroundColor + ); + + // Set the mobile view option back to mobile + await this.page.getByRole( 'radio', { name: 'Mobile' } ).click(); + } + + async testFrontendColors( { + textColor, + backgroundColor, + submenuTextColor, + submenuBackgroundColor, + } ) { + // Top level link elements. + const customLink = this.page + .locator( 'a' ) + .filter( { hasText: 'Custom Link' } ); + const pageLink = this.page + .locator( 'a' ) + .filter( { hasText: 'Page Link' } ); + + // Top level link colors. + await expect( customLink ).toHaveCSS( 'color', textColor ); + await expect( pageLink ).toHaveCSS( 'color', textColor ); + + // Navigation background. + const menuWrapperFront = this.page + .getByRole( 'navigation', { name: 'Colored menu' } ) + .getByRole( 'list' ); + await expect( menuWrapperFront ).toHaveCSS( + 'background-color', + backgroundColor + ); + + await customLink.hover(); + + // Submenu elements. + const submenuLink = this.page + .locator( 'a' ) + .filter( { hasText: 'Submenu Link' } ); + const submenuWrapper = this.page + .locator( '.wp-block-navigation__submenu-container' ) + .filter( { has: submenuLink } ); + + // Submenu link color. + await expect( submenuLink ).toHaveCSS( 'color', submenuTextColor ); + + // Submenu background color. + await expect( submenuWrapper ).toHaveCSS( + 'background-color', + submenuBackgroundColor + ); + + // Open the frontend overlay so we can test the colors. + await this.pageUtils.setBrowserViewport( { width: 599, height: 700 } ); + await this.page.getByRole( 'button', { name: 'Open menu' } ).click(); + + // All of the mobile menu navigation links should be the same color as the submenuTextColor. + await expect( customLink ).toHaveCSS( 'color', submenuTextColor ); + await expect( submenuLink ).toHaveCSS( 'color', submenuTextColor ); + await expect( pageLink ).toHaveCSS( 'color', submenuTextColor ); + + const overlayFront = this.page + .locator( '.wp-block-navigation__responsive-container' ) + .filter( { hasText: 'Submenu Link' } ); + + // The mobile menu background should be the same color as the submenu background. + await expect( overlayFront ).toHaveCSS( + 'background-color', + submenuBackgroundColor + ); + + // We need to reset the overlay to the default viewport if something runs after these tests. + await this.pageUtils.setBrowserViewport( 'large' ); + } +} diff --git a/test/e2e/specs/editor/blocks/navigation-frontend-interactivity.spec.js b/test/e2e/specs/editor/blocks/navigation-frontend-interactivity.spec.js new file mode 100644 index 00000000000000..d084ea15eae85d --- /dev/null +++ b/test/e2e/specs/editor/blocks/navigation-frontend-interactivity.spec.js @@ -0,0 +1,324 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Navigation block - Frontend interactivity', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + await requestUtils.deleteAllPages(); + await requestUtils.deleteAllMenus(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + await requestUtils.deleteAllPages(); + await requestUtils.deleteAllMenus(); + } ); + + test.describe( 'Overlay menu', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + <!-- wp:navigation-link {"label":"Item 1","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- wp:navigation-link {"label":"Item 2","type":"custom","url":"http://www.wordpress.org/"} /--> + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'always' }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'Overlay menu interactions', async ( { page, pageUtils } ) => { + await page.goto( '/' ); + const overlayMenuFirstElement = page.getByRole( 'link', { + name: 'Item 1', + } ); + const openMenuButton = page.getByRole( 'button', { + name: 'Open menu', + } ); + + const closeMenuButton = page.getByRole( 'button', { + name: 'Close menu', + } ); + + // Test: overlay menu opens on click on open menu button + await expect( overlayMenuFirstElement ).toBeHidden(); + await openMenuButton.click(); + await expect( overlayMenuFirstElement ).toBeVisible(); + + // Test: overlay menu focuses on first element after opening + await expect( overlayMenuFirstElement ).toBeFocused(); + + // Test: overlay menu traps focus + await pageUtils.pressKeys( 'Tab', { times: 2, delay: 50 } ); + await expect( closeMenuButton ).toBeFocused(); + await pageUtils.pressKeys( 'Shift+Tab', { times: 2, delay: 50 } ); + await expect( overlayMenuFirstElement ).toBeFocused(); + + // Test: overlay menu closes on click on close menu button + await closeMenuButton.click(); + await expect( overlayMenuFirstElement ).toBeHidden(); + + // Test: overlay menu closes on ESC key + await openMenuButton.click(); + await expect( overlayMenuFirstElement ).toBeVisible(); + await pageUtils.pressKeys( 'Escape' ); + await expect( overlayMenuFirstElement ).toBeHidden(); + await expect( openMenuButton ).toBeFocused(); + } ); + } ); + + test.describe( 'Submenu mouse and keyboard interactions', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + <!-- wp:navigation-link {"label":"Link 1","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- wp:navigation-submenu {"label":"Simple Submenu","type":"internal","url":"#heading","kind":"custom"} --> + <!-- wp:navigation-link {"label":"Simple Submenu Link 1","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- /wp:navigation-submenu --> + <!-- wp:navigation-submenu {"label":"Complex Submenu","type":"internal","url":"#heading","kind":"custom"} --> + <!-- wp:navigation-link {"label":"Complex Submenu Link 1","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- wp:navigation-submenu {"label":"Nested Submenu","type":"internal","url":"#heading","kind":"custom"} --> + <!-- wp:navigation-link {"label":"Nested Submenu Link 1","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- /wp:navigation-submenu --> + <!-- wp:navigation-link {"label":"Complex Submenu Link 2","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- /wp:navigation-submenu --> + <!-- wp:navigation-link {"label":"Link 2","type":"custom","url":"http://www.wordpress.org/"} /--> + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'off', openSubmenusOnClick: true }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'Submenu interactions', async ( { page, pageUtils } ) => { + await page.goto( '/' ); + const simpleSubmenuButton = page.getByRole( 'button', { + name: 'Simple Submenu', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Simple Submenu Link 1', + } ); + const complexSubmenuButton = page.getByRole( 'button', { + name: 'Complex Submenu', + } ); + const nestedSubmenuButton = page.getByRole( 'button', { + name: 'Nested Submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Complex Submenu Link 1', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Submenu Link 1', + } ); + + // Test: submenu opens on click + await expect( innerElement ).toBeHidden(); + await simpleSubmenuButton.click(); + await expect( innerElement ).toBeVisible(); + + // Test: submenu closes on click outside submenu + await page.click( 'body' ); + await expect( innerElement ).toBeHidden(); + + // Test: nested submenu opens on click + await complexSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + + await nestedSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + + // Test: nested submenus close on click outside submenu + await page.click( 'body' ); + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + + // Test: submenu opens on Enter keypress + await simpleSubmenuButton.focus(); + await pageUtils.pressKeys( 'Enter' ); + await expect( innerElement ).toBeVisible(); + + // Test: submenu closes on ESC key and focuses parent link + await pageUtils.pressKeys( 'Escape' ); + await expect( innerElement ).toBeHidden(); + await expect( simpleSubmenuButton ).toBeFocused(); + + // Test: submenu closes on tab outside submenu + await simpleSubmenuButton.focus(); + await pageUtils.pressKeys( 'Enter' ); + await expect( innerElement ).toBeVisible(); + // Tab to first element, then tab outside the submenu. + await pageUtils.pressKeys( 'Tab', { times: 2, delay: 50 } ); + await expect( innerElement ).toBeHidden(); + await expect( complexSubmenuButton ).toBeFocused(); + + // Test: only nested submenu closes on tab outside + await complexSubmenuButton.focus(); + await pageUtils.pressKeys( 'Enter' ); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + + await nestedSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + + // Tab to nested submenu first element, then tab outside the nested + // submenu. + await pageUtils.pressKeys( 'Tab', { times: 2, delay: 50 } ); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + // Tab outside the complex submenu. + await page.keyboard.press( 'Tab' ); + await expect( firstLevelElement ).toBeHidden(); + } ); + } ); + + test.describe( 'Submenus (Arrow setting)', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + <!-- wp:navigation-submenu {"label":"Submenu","type":"internal","url":"#heading","kind":"custom"} --> + <!-- wp:navigation-link {"label":"Submenu Link","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- wp:navigation-submenu {"label":"Nested Menu","type":"internal","url":"#heading","kind":"custom"} --> + <!-- wp:navigation-link {"label":"Nested Menu Link","type":"custom","url":"http://www.wordpress.org/"} /--> + <!-- /wp:navigation-submenu --> + <!-- /wp:navigation-submenu --> + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'off' }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'submenu opens on click in the arrow', async ( { page } ) => { + await page.goto( '/' ); + const arrowButton = page.getByRole( 'button', { + name: 'Submenu submenu', + } ); + const nestedSubmenuArrowButton = page.getByRole( 'button', { + name: 'Nested Menu submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Submenu Link', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Menu Link', + } ); + + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + await arrowButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + await nestedSubmenuArrowButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + await page.click( 'body' ); + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + } ); + } ); + + test.describe( 'Page list block', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + const parentPage = await requestUtils.createPage( { + title: 'Parent Page', + status: 'publish', + } ); + + await requestUtils.createPage( { + title: 'Subpage', + status: 'publish', + parent: parentPage.id, + } ); + + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Page list menu', + content: ` + <!-- wp:page-list /--> + <!-- wp:navigation-link {"label":"Link","type":"custom","url":"http://www.wordpress.org/"} /--> + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'off', openSubmenusOnClick: true }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'page-list submenu user interactions', async ( { + page, + pageUtils, + } ) => { + await page.goto( '/' ); + const submenuButton = page.getByRole( 'button', { + name: 'Parent Page', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Subpage', + } ); + await expect( innerElement ).toBeHidden(); + + // page-list submenu opens on click + await submenuButton.click(); + await expect( innerElement ).toBeVisible(); + + // page-list submenu closes on click outside + await page.click( 'body' ); + await expect( innerElement ).toBeHidden(); + + // page-list submenu opens on enter keypress + await submenuButton.focus(); + await pageUtils.pressKeys( 'Enter' ); + await expect( innerElement ).toBeVisible(); + + // page-list submenu closes on ESC key and focuses submenu button + await pageUtils.pressKeys( 'Escape' ); + await expect( innerElement ).toBeHidden(); + await expect( submenuButton ).toBeFocused(); + + // page-list submenu closes on tab outside submenu + await pageUtils.pressKeys( 'Enter', { delay: 50 } ); + // Tab to first element, then tab outside the submenu. + await pageUtils.pressKeys( 'Tab', { times: 2, delay: 50 } ); + await expect( innerElement ).toBeHidden(); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/blocks/navigation-list-view.spec.js b/test/e2e/specs/editor/blocks/navigation-list-view.spec.js new file mode 100644 index 00000000000000..77039281faded5 --- /dev/null +++ b/test/e2e/specs/editor/blocks/navigation-list-view.spec.js @@ -0,0 +1,581 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Navigation block - List view editing', () => { + const navMenuBlocksFixture = { + title: 'Test Menu', + content: `<!-- wp:navigation-link {"label":"Top Level Item 1","type":"page","id":250,"url":"http://localhost:8888/quod-error-esse-nemo-corporis-rerum-repellendus/","kind":"post-type"} /--> + <!-- wp:navigation-submenu {"label":"Top Level Item 2","type":"page","id":250,"url":"http://localhost:8888/quod-error-esse-nemo-corporis-rerum-repellendus/","kind":"post-type"} --> + <!-- wp:navigation-link {"label":"Test Submenu Item","type":"page","id":270,"url":"http://localhost:8888/et-aspernatur-recusandae-non-sint/","kind":"post-type"} /--> + <!-- /wp:navigation-submenu -->`, + }; + + test.beforeAll( async ( { requestUtils } ) => { + // We need pages to be published so the Link Control can return pages + await requestUtils.createPage( { + title: 'Test Page 1', + status: 'publish', + } ); + await requestUtils.createPage( { + title: 'Test Page 2', + status: 'publish', + } ); + await requestUtils.createPage( { + title: 'Test Page 3', + status: 'publish', + } ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.deleteAllPages(), + requestUtils.deleteAllPosts(), + requestUtils.deleteAllMenus(), + ] ); + } ); + + test.use( { + linkControl: async ( { page }, use ) => { + await use( new LinkControl( { page } ) ); + }, + } ); + + test( 'show a list view in the inspector controls', async ( { + page, + editor, + requestUtils, + } ) => { + await requestUtils.createNavigationMenu( navMenuBlocksFixture ); + + await editor.insertBlock( { name: 'core/navigation' } ); + + await editor.openDocumentSettingsSidebar(); + + await expect( + page.getByRole( 'tab', { + name: 'List View', + } ) + ).toBeVisible(); + + const listViewPanel = page.getByRole( 'tabpanel', { + name: 'List View', + } ); + + await expect( listViewPanel ).toBeVisible(); + + await expect( + listViewPanel.getByRole( 'heading', { + name: 'Menu', + } ) + ).toBeVisible(); + + await expect( + listViewPanel.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ) + ).toBeVisible(); + } ); + + test( `list view should correctly reflect navigation items' structure`, async ( { + page, + editor, + requestUtils, + } ) => { + await requestUtils.createNavigationMenu( navMenuBlocksFixture ); + + await editor.insertBlock( { name: 'core/navigation' } ); + + await editor.openDocumentSettingsSidebar(); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ); + + // Check the structure of the individual menu items matches the one that was created. + await expect( + listView + .getByRole( 'gridcell', { + name: 'Page Link', + } ) + .filter( { + hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. + } ) + .getByText( 'Top Level Item 1' ) + ).toBeVisible(); + + await expect( + listView + .getByRole( 'gridcell', { + name: 'Submenu', + } ) + .filter( { + hasText: 'Block 2 of 2, Level 1', // proxy for filtering by description. + } ) + .getByText( 'Top Level Item 2' ) + ).toBeVisible(); + + await expect( + listView + .getByRole( 'gridcell', { + name: 'Page Link', + } ) + .filter( { + hasText: 'Block 1 of 1, Level 2', // proxy for filtering by description. + } ) + .getByText( 'Test Submenu Item' ) + ).toBeVisible(); + } ); + + test( `can add new menu items`, async ( { + page, + editor, + requestUtils, + linkControl, + } ) => { + const { id: menuId } = await requestUtils.createNavigationMenu( + navMenuBlocksFixture + ); + + // Insert x2 blocks as a stress test as several bugs have been found with inserting + // blocks into the navigation block when there are multiple blocks referencing the + // **same** menu. + await editor.insertBlock( { + name: 'core/navigation', + attributes: { + ref: menuId, + }, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { + ref: menuId, + }, + } ); + + await editor.openDocumentSettingsSidebar(); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ); + + const appender = listView.getByRole( 'button', { + name: 'Add block', + } ); + + await expect( appender ).toBeVisible(); + + await appender.click(); + + // Expect to see the block inserter. + await expect( + page.getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ) + ).toBeFocused(); + + const blockResults = page.getByRole( 'listbox', { + name: 'Blocks', + } ); + + await expect( blockResults ).toBeVisible(); + + const blockResultOptions = blockResults.getByRole( 'option' ); + + // Expect to see the Page Link and Custom Link blocks as the nth(0) and nth(1) results. + // This is important for usability as the Page Link block is the most likely to be used. + await expect( blockResultOptions.nth( 0 ) ).toHaveText( 'Page Link' ); + await expect( blockResultOptions.nth( 1 ) ).toHaveText( 'Custom Link' ); + + // Select the Page Link option. + const pageLinkResult = blockResultOptions.nth( 0 ); + await pageLinkResult.click(); + + // Expect to see the Link creation UI be focused. + const linkUIInput = linkControl.getSearchInput(); + + // Coverage for bug whereby Link UI input would be incorrectly prepopulated. + // It should: + // - be focused - should not be in "preview" mode but rather ready to accept input. + // - be empty - not pre-populated + // See: https://github.com/WordPress/gutenberg/issues/50733 + await expect( linkUIInput ).toBeFocused(); + await expect( linkUIInput ).toBeEmpty(); + + const firstResult = await linkControl.getNthSearchResult( 0 ); + + // Grab the text from the first result so we can check (later on) that it was inserted. + const firstResultText = await linkControl.getSearchResultText( + firstResult + ); + + // Create the link. + await firstResult.click(); + + // Check the new menu item was inserted at the end of the existing menu. + await expect( + listView + .getByRole( 'gridcell', { + name: 'Page Link', + } ) + .filter( { + hasText: 'Block 3 of 3, Level 1', // proxy for filtering by description. + } ) + .getByText( firstResultText ) + ).toBeVisible(); + } ); + + test( `can remove menu items`, async ( { page, editor, requestUtils } ) => { + await requestUtils.createNavigationMenu( navMenuBlocksFixture ); + + await editor.insertBlock( { name: 'core/navigation' } ); + + await editor.openDocumentSettingsSidebar(); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ); + + const submenuOptions = listView.getByRole( 'button', { + name: 'Options for Submenu', + } ); + + // Open the options menu. + await submenuOptions.click(); + + // usage of `page` is required because the options menu is rendered into a slot + // outside of the treegrid. + const removeBlockOption = page + .getByRole( 'menu', { + name: 'Options for Submenu', + } ) + .getByRole( 'menuitem', { + name: 'Remove Top Level Item 2', + } ); + + await removeBlockOption.click(); + + // Check the menu item was removed. + await expect( + listView + .getByRole( 'gridcell', { + name: 'Submenu', + } ) + .filter( { + hasText: 'Block 2 of 2, Level 1', // proxy for filtering by description. + } ) + .getByText( 'Top Level Item 2' ) + ).not.toBeVisible(); + } ); + + test( `can edit menu items`, async ( { page, editor, requestUtils } ) => { + await requestUtils.createNavigationMenu( navMenuBlocksFixture ); + + await editor.insertBlock( { name: 'core/navigation' } ); + + await editor.openDocumentSettingsSidebar(); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ); + + // Click on the first menu item to open its settings. + const firstMenuItemAnchor = listView + .getByRole( 'link', { + name: 'Page', + includeHidden: true, + } ) + .getByText( 'Top Level Item 1' ); + await firstMenuItemAnchor.click(); + + // Get the settings panel. + const blockSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + + await expect( blockSettings ).toBeVisible(); + + await expect( + blockSettings.getByRole( 'heading', { + name: 'Page Link', + } ) + ).toBeVisible(); + + await expect( + blockSettings.getByRole( 'tab', { + name: 'Settings', + selected: true, + } ) + ).toBeVisible(); + + await expect( + blockSettings + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .getByRole( 'heading', { + name: 'Settings', + } ) + ).toBeVisible(); + + const labelInput = blockSettings.getByRole( 'textbox', { + name: 'Label', + } ); + + await expect( labelInput ).toHaveValue( 'Top Level Item 1' ); + + await labelInput.focus(); + + await page.keyboard.type( 'Changed label' ); + + // Click the back button to go back to the Nav block. + await blockSettings + .getByRole( 'button', { + name: 'Go to parent Navigation block', + } ) + .click(); + + // Check we're back on the Nav block list view. + const listViewPanel = page.getByRole( 'tabpanel', { + name: 'List View', + } ); + + await expect( listViewPanel ).toBeVisible(); + + // Check the label was updated. + await expect( + listViewPanel + .getByRole( 'gridcell', { + name: 'Page Link', + } ) + .filter( { + hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. + } ) + .getByText( 'Changed label' ) // new label text + ).toBeVisible(); + } ); + + test( `can add submenus`, async ( { + page, + editor, + requestUtils, + linkControl, + } ) => { + await requestUtils.createNavigationMenu( navMenuBlocksFixture ); + + await editor.insertBlock( { name: 'core/navigation' } ); + + await editor.openDocumentSettingsSidebar(); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ); + + // click on options menu for the first menu item and select remove. + const firstMenuItem = listView + .getByRole( 'gridcell', { + name: 'Page Link', + } ) + .filter( { + hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. + } ); + + // The options menu button is a sibling of the menu item gridcell. + const firstItemOptions = firstMenuItem + .locator( '..' ) // parent selector. + .getByRole( 'button', { + name: 'Options for Page Link', + } ); + + // Open the options menu. + await firstItemOptions.click(); + + // Add the submenu. + // usage of `page` is required because the options menu is rendered into a slot + // outside of the treegrid. + const addSubmenuOption = page + .getByRole( 'menu', { + name: 'Options for Page Link', + } ) + .getByRole( 'menuitem', { + name: 'Add submenu', + } ); + + await addSubmenuOption.click(); + + await linkControl.searchFor( 'https://wordpress.org' ); + + await page.keyboard.press( 'Enter' ); + + // Check the new item was inserted in the correct place. + await expect( + listView + .getByRole( 'gridcell', { + name: 'Custom Link', + } ) + .filter( { + hasText: 'Block 1 of 1, Level 2', // proxy for filtering by description. + } ) + .getByText( 'wordpress.org' ) + ).toBeVisible(); + + // Check that the original item is still there but that it is now + // a submenu item. + await expect( + listView + .getByRole( 'gridcell', { + name: 'Submenu', + } ) + .filter( { + hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. + } ) + .getByText( 'Top Level Item 1' ) + ).toBeVisible(); + } ); + + test( `does not display link interface for blocks that have not just been inserted`, async ( { + page, + editor, + requestUtils, + linkControl, + } ) => { + // Provides coverage for a bug whereby the Link UI would be unexpectedly displayed for the last + // inserted block even if the block had been deselected and then reselected. + // See: https://github.com/WordPress/gutenberg/issues/50601 + + const { id: menuId } = await requestUtils.createNavigationMenu( + navMenuBlocksFixture + ); + + // Insert x2 blocks as a stress test as several bugs have been found with inserting + // blocks into the navigation block when there are multiple blocks referencing the + // **same** menu. + await editor.insertBlock( { + name: 'core/navigation', + attributes: { + ref: menuId, + }, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { + ref: menuId, + }, + } ); + + await editor.openDocumentSettingsSidebar(); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + description: 'Structure for navigation menu: Test Menu', + } ); + + await listView + .getByRole( 'button', { + name: 'Add block', + } ) + .click(); + + const blockResults = page.getByRole( 'listbox', { + name: 'Blocks', + } ); + + await expect( blockResults ).toBeVisible(); + + const blockResultOptions = blockResults.getByRole( 'option' ); + + // Select the Page Link option. + await blockResultOptions.nth( 0 ).click(); + + // Immediately dismiss the Link UI thereby not populating the `url` attribute + // of the block. + await page.keyboard.press( 'Escape' ); + + // Get the Inspector Tabs. + const blockSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + + // Trigger "unmount" of the List View. + await blockSettings + .getByRole( 'tab', { + name: 'Settings', + } ) + .click(); + + // "Remount" the List View. + // this is where the bug previously occurred. + await blockSettings + .getByRole( 'tab', { + name: 'List View', + } ) + .click(); + + // Check that despite being the last inserted block, the Link UI is not displayed + // in this scenario because it was not **just** inserted into the List View (i.e. + // we have unmounted the list view and then remounted it). + await expect( linkControl.getSearchInput() ).not.toBeVisible(); + } ); +} ); + +class LinkControl { + constructor( { page } ) { + this.page = page; + } + + getSearchInput() { + return this.page.getByRole( 'combobox', { + name: 'Link', + } ); + } + + async getSearchResults() { + const searchInput = this.getSearchInput(); + + const resultsRef = await searchInput.getAttribute( 'aria-owns' ); + + const linkUIResults = this.page.locator( `#${ resultsRef }` ); + + await expect( linkUIResults ).toBeVisible(); + + return linkUIResults.getByRole( 'option' ); + } + + async getNthSearchResult( index = 0 ) { + const results = await this.getSearchResults(); + return results.nth( index ); + } + + async searchFor( searchTerm = 'https://wordpress.org' ) { + const input = this.getSearchInput(); + + await expect( input ).toBeFocused(); + + await this.page.keyboard.type( searchTerm ); + + await expect( input ).toHaveValue( searchTerm ); + + return input; + } + + async getSearchResultText( result ) { + await expect( result ).toBeVisible(); + + return result + .locator( + '.components-menu-item__info-wrapper .components-menu-item__item' + ) // this is the only way to get the label text without the URL. + .innerText(); + } +} diff --git a/test/e2e/specs/editor/blocks/navigation.spec.js b/test/e2e/specs/editor/blocks/navigation.spec.js index 835b05e570e992..4757ccbb4a00f5 100644 --- a/test/e2e/specs/editor/blocks/navigation.spec.js +++ b/test/e2e/specs/editor/blocks/navigation.spec.js @@ -4,25 +4,19 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'Navigation block', () => { - test.beforeAll( async ( { requestUtils } ) => { - //TT3 is preferable to emptytheme because it already has the navigation block on its templates. - await requestUtils.activateTheme( 'twentytwentythree' ); - } ); - test.beforeEach( async ( { requestUtils } ) => { - await Promise.all( [ requestUtils.deleteAllMenus() ] ); + await requestUtils.deleteAllMenus(); } ); test.afterAll( async ( { requestUtils } ) => { - await Promise.all( [ - requestUtils.deleteAllMenus(), - requestUtils.activateTheme( 'twentytwentyone' ), - ] ); + await requestUtils.deleteAllMenus(); } ); test.afterEach( async ( { requestUtils } ) => { - await requestUtils.deleteAllPosts(); - await requestUtils.deleteAllMenus(); + await Promise.all( [ + requestUtils.deleteAllPosts(), + requestUtils.deleteAllMenus(), + ] ); } ); test.describe( 'As a user I want the navigation block to fallback to the best possible default', () => { @@ -62,7 +56,7 @@ test.describe( 'Navigation block', () => { const createdMenu = await requestUtils.createNavigationMenu( { title: 'Test Menu 1', content: - '<!-- wp:navigation-link {"label":"WordPress","type":"custom","url":"http://www.wordpress.org/","kind":"custom","isTopLevelLink":true} /-->', + '<!-- wp:navigation-link {"label":"WordPress","type":"custom","url":"http://www.wordpress.org/","kind":"custom"} /-->', } ); await editor.insertBlock( { name: 'core/navigation' } ); @@ -74,18 +68,19 @@ test.describe( 'Navigation block', () => { ) ).toBeVisible(); + const postId = await editor.publishPost(); + // Check the markup of the block is correct. - await editor.publishPost(); await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/navigation', attributes: { ref: createdMenu.id }, }, ] ); - await page.locator( 'role=button[name="Close panel"i]' ).click(); // Check the block in the frontend. - await page.goto( '/' ); + await page.goto( `/?p=${ postId }` ); + await expect( page.locator( `role=navigation >> role=link[name="WordPress"i]` @@ -118,8 +113,9 @@ test.describe( 'Navigation block', () => { ) ).toBeVisible( { timeout: 10000 } ); // allow time for network request. + const postId = await editor.publishPost(); // Check the block in the frontend. - await page.goto( '/' ); + await page.goto( `/?p=${ postId }` ); await expect( page.locator( @@ -138,7 +134,7 @@ test.describe( 'Navigation block', () => { await requestUtils.createNavigationMenu( { title: 'Test Menu 1', content: - '<!-- wp:navigation-link {"label":"Menu 1 Link","type":"custom","url":"http://localhost:8889/#menu-1-link","kind":"custom","isTopLevelLink":true} /-->', + '<!-- wp:navigation-link {"label":"Menu 1 Link","type":"custom","url":"http://localhost:8889/#menu-1-link","kind":"custom"} /-->', } ); //FIXME this is needed because if the two menus are created at the same time, the API will return them in the wrong order. @@ -148,20 +144,19 @@ test.describe( 'Navigation block', () => { const latestMenu = await requestUtils.createNavigationMenu( { title: 'Test Menu 2', content: - '<!-- wp:navigation-link {"label":"Menu 2 Link","type":"custom","url":"http://localhost:8889/#menu-2-link","kind":"custom","isTopLevelLink":true} /-->', + '<!-- wp:navigation-link {"label":"Menu 2 Link","type":"custom","url":"http://localhost:8889/#menu-2-link","kind":"custom"} /-->', } ); await editor.insertBlock( { name: 'core/navigation' } ); // Check the markup of the block is correct. - await editor.publishPost(); + const postId = await editor.publishPost(); await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/navigation', attributes: { ref: latestMenu.id }, }, ] ); - await page.locator( 'role=button[name="Close panel"i]' ).click(); // Check the block in the canvas. await expect( @@ -171,7 +166,8 @@ test.describe( 'Navigation block', () => { ).toBeVisible(); // Check the block in the frontend. - await page.goto( '/' ); + await page.goto( `/?p=${ postId }` ); + await expect( page.locator( `role=navigation >> role=link[name="Menu 2 Link"i]` @@ -181,26 +177,6 @@ test.describe( 'Navigation block', () => { } ); test.describe( 'As a user I want to create submenus using the navigation block', () => { - test.beforeAll( async ( { requestUtils } ) => { - //TT3 is preferable to emptytheme because it already has the navigation block on its templates. - await requestUtils.activateTheme( 'twentytwentythree' ); - } ); - - test.beforeEach( async ( { requestUtils } ) => { - await Promise.all( [ requestUtils.deleteAllMenus() ] ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await Promise.all( [ - requestUtils.deleteAllMenus(), - requestUtils.activateTheme( 'twentytwentyone' ), - ] ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await requestUtils.deleteAllPosts(); - } ); - test( 'create a submenu', async ( { admin, page, @@ -228,11 +204,9 @@ test.describe( 'Navigation block', () => { } ); await addSubmenuButton.click(); - await editor.publishPost(); - - await page.locator( 'role=button[name="Close panel"i]' ).click(); + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); - await page.goto( '/' ); await expect( page.locator( `role=navigation >> role=button[name="example.com submenu "i]` @@ -250,7 +224,7 @@ test.describe( 'Navigation block', () => { await requestUtils.createNavigationMenu( { title: 'Test Menu', content: - '<!-- wp:navigation-submenu {"label":"WordPress","type":"custom","url":"http://www.wordpress.org/","kind":"custom","isTopLevelLink":true} --><!-- wp:navigation-link {"label":"WordPress Child","type":"custom","url":"http://www.wordpress.org/","kind":"custom","isTopLevelLink":true} /--><!-- /wp:navigation-submenu -->', + '<!-- wp:navigation-submenu {"label":"WordPress","type":"custom","url":"http://www.wordpress.org/","kind":"custom"} --><!-- wp:navigation-link {"label":"WordPress Child","type":"custom","url":"http://www.wordpress.org/","kind":"custom"} /--><!-- /wp:navigation-submenu -->', } ); await editor.insertBlock( { name: 'core/navigation' } ); @@ -286,1029 +260,97 @@ test.describe( 'Navigation block', () => { } ); } ); - test.describe( 'As a user I want to see a warning if the menu referenced by a navigation block is not available', () => { - test.beforeEach( async ( { admin } ) => { - await admin.createNewPost(); - } ); - - test( 'warning message shows when given an unknown ref', async ( { - editor, - } ) => { - await editor.insertBlock( { - name: 'core/navigation', - attributes: { - ref: 1, - }, - } ); - - // Check the markup of the block is correct. - await editor.publishPost(); - - await expect.poll( editor.getBlocks ).toMatchObject( [ - { - name: 'core/navigation', - attributes: { ref: 1 }, - }, - ] ); - - // Find the warning message - const warningMessage = editor.canvas - .getByRole( 'document', { name: 'Block: Navigation' } ) - .getByText( - 'Navigation menu has been deleted or is unavailable.' - ); - await expect( warningMessage ).toBeVisible(); - } ); - } ); - - test.describe( 'Existing blocks', () => { - test( 'adding new links to a block with existing inner blocks triggers creation of a single Navigation Menu', async ( { - admin, - page, - editor, - requestUtils, - } ) => { - // As this test depends on there being no menus, - // we need to delete any existing menus as an explicit - // precondition rather than rely on global test setup. - await requestUtils.deleteAllMenus(); - - // Ensure that there are no menus before beginning the test. - expect( - await requestUtils.getNavigationMenus( { - status: [ 'publish', 'draft' ], - } ) - ).toHaveLength( 0 ); - - await admin.createNewPost(); - - await editor.insertBlock( { - name: 'core/navigation', - attributes: {}, - innerBlocks: [ - { - name: 'core/page-list', - }, - ], - } ); - - const navBlock = editor.canvas.getByRole( 'document', { - name: 'Block: Navigation', - } ); - - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Page List', - } ) - ).toBeVisible(); - - await expect( navBlock ).toBeVisible(); - - await editor.selectBlocks( navBlock ); - - await navBlock.getByRole( 'button', { name: 'Add block' } ).click(); - - // This relies on network so allow additional time for - // the request to complete. - await expect( - page.getByRole( 'button', { - name: 'Dismiss this notice', - text: 'Navigation Menu successfully created', - } ) - ).toBeVisible( { timeout: 10000 } ); - - // The creattion Navigaiton Menu will be a draft - // so we need to check for both publish and draft. - expect( - await requestUtils.getNavigationMenus( { - status: [ 'publish', 'draft' ], - } ) - ).toHaveLength( 1 ); - } ); - } ); - - test.describe( 'List view editing', () => { - const navMenuBlocksFixture = { - title: 'Test Menu', - content: `<!-- wp:navigation-link {"label":"Top Level Item 1","type":"page","id":250,"url":"http://localhost:8888/quod-error-esse-nemo-corporis-rerum-repellendus/","kind":"post-type"} /--> - <!-- wp:navigation-submenu {"label":"Top Level Item 2","type":"page","id":250,"url":"http://localhost:8888/quod-error-esse-nemo-corporis-rerum-repellendus/","kind":"post-type"} --> - <!-- wp:navigation-link {"label":"Test Submenu Item","type":"page","id":270,"url":"http://localhost:8888/et-aspernatur-recusandae-non-sint/","kind":"post-type"} /--> - <!-- /wp:navigation-submenu -->`, - }; + test( 'As a user I want to see a warning if the menu referenced by a navigation block is not available', async ( { + admin, + editor, + } ) => { + await admin.createNewPost(); - test.beforeAll( async ( { requestUtils } ) => { - // We need pages to be published so the Link Control can return pages - await requestUtils.createPage( { - title: 'Test Page 1', - status: 'publish', - } ); - await requestUtils.createPage( { - title: 'Test Page 2', - status: 'publish', - } ); - await requestUtils.createPage( { - title: 'Test Page 3', - status: 'publish', - } ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.deleteAllPages(); - } ); - - test.use( { - linkControl: async ( { page }, use ) => { - await use( new LinkControl( { page } ) ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { + ref: 1, }, } ); - test( 'show a list view in the inspector controls', async ( { - admin, - page, - editor, - requestUtils, - } ) => { - await admin.createNewPost(); - await requestUtils.createNavigationMenu( navMenuBlocksFixture ); - - await editor.insertBlock( { name: 'core/navigation' } ); - - await editor.openDocumentSettingsSidebar(); - - await expect( - page.getByRole( 'tab', { - name: 'List View', - } ) - ).toBeVisible(); - - const listViewPanel = page.getByRole( 'tabpanel', { - name: 'List View', - } ); - - await expect( listViewPanel ).toBeVisible(); + // Check the markup of the block is correct. + await editor.publishPost(); - await expect( - listViewPanel.getByRole( 'heading', { - name: 'Menu', - } ) - ).toBeVisible(); - - await expect( - listViewPanel.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ) - ).toBeVisible(); - } ); - - test( `list view should correctly reflect navigation items' structure`, async ( { - admin, - page, - editor, - requestUtils, - } ) => { - await admin.createNewPost(); - await requestUtils.createNavigationMenu( navMenuBlocksFixture ); - - await editor.insertBlock( { name: 'core/navigation' } ); - - await editor.openDocumentSettingsSidebar(); - - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ); - - // Check the structure of the individual menu items matches the one that was created. - await expect( - listView - .getByRole( 'gridcell', { - name: 'Page Link', - } ) - .filter( { - hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. - } ) - .getByText( 'Top Level Item 1' ) - ).toBeVisible(); - - await expect( - listView - .getByRole( 'gridcell', { - name: 'Submenu', - } ) - .filter( { - hasText: 'Block 2 of 2, Level 1', // proxy for filtering by description. - } ) - .getByText( 'Top Level Item 2' ) - ).toBeVisible(); - - await expect( - listView - .getByRole( 'gridcell', { - name: 'Page Link', - } ) - .filter( { - hasText: 'Block 1 of 1, Level 2', // proxy for filtering by description. - } ) - .getByText( 'Test Submenu Item' ) - ).toBeVisible(); - } ); - - test( `can add new menu items`, async ( { - admin, - page, - editor, - requestUtils, - linkControl, - } ) => { - await admin.createNewPost(); - const { id: menuId } = await requestUtils.createNavigationMenu( - navMenuBlocksFixture - ); - - // Insert x2 blocks as a stress test as several bugs have been found with inserting - // blocks into the navigation block when there are multiple blocks referencing the - // **same** menu. - await editor.insertBlock( { - name: 'core/navigation', - attributes: { - ref: menuId, - }, - } ); - await editor.insertBlock( { + await expect.poll( editor.getBlocks ).toMatchObject( [ + { name: 'core/navigation', - attributes: { - ref: menuId, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ); - - const appender = listView.getByRole( 'button', { - name: 'Add block', - } ); - - await expect( appender ).toBeVisible(); - - await appender.click(); - - // Expect to see the block inserter. - await expect( - page.getByRole( 'searchbox', { - name: 'Search for blocks and patterns', - } ) - ).toBeFocused(); - - const blockResults = page.getByRole( 'listbox', { - name: 'Blocks', - } ); - - await expect( blockResults ).toBeVisible(); - - const blockResultOptions = blockResults.getByRole( 'option' ); - - // Expect to see the Page Link and Custom Link blocks as the nth(0) and nth(1) results. - // This is important for usability as the Page Link block is the most likely to be used. - await expect( blockResultOptions.nth( 0 ) ).toHaveText( - 'Page Link' - ); - await expect( blockResultOptions.nth( 1 ) ).toHaveText( - 'Custom Link' - ); - - // Select the Page Link option. - const pageLinkResult = blockResultOptions.nth( 0 ); - await pageLinkResult.click(); - - // Expect to see the Link creation UI be focused. - const linkUIInput = linkControl.getSearchInput(); - - // Coverage for bug whereby Link UI input would be incorrectly prepopulated. - // It should: - // - be focused - should not be in "preview" mode but rather ready to accept input. - // - be empty - not pre-populated - // See: https://github.com/WordPress/gutenberg/issues/50733 - await expect( linkUIInput ).toBeFocused(); - await expect( linkUIInput ).toBeEmpty(); - - const firstResult = await linkControl.getNthSearchResult( 0 ); - - // Grab the text from the first result so we can check (later on) that it was inserted. - const firstResultText = await linkControl.getSearchResultText( - firstResult - ); - - // Create the link. - await firstResult.click(); - - // Check the new menu item was inserted at the end of the existing menu. - await expect( - listView - .getByRole( 'gridcell', { - name: 'Page Link', - } ) - .filter( { - hasText: 'Block 3 of 3, Level 1', // proxy for filtering by description. - } ) - .getByText( firstResultText ) - ).toBeVisible(); - } ); - - test( `can remove menu items`, async ( { - admin, - page, - editor, - requestUtils, - } ) => { - await admin.createNewPost(); - await requestUtils.createNavigationMenu( navMenuBlocksFixture ); - - await editor.insertBlock( { name: 'core/navigation' } ); - - await editor.openDocumentSettingsSidebar(); - - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ); - - const submenuOptions = listView.getByRole( 'button', { - name: 'Options for Submenu', - } ); - - // Open the options menu. - await submenuOptions.click(); - - // usage of `page` is required because the options menu is rendered into a slot - // outside of the treegrid. - const removeBlockOption = page - .getByRole( 'menu', { - name: 'Options for Submenu', - } ) - .getByRole( 'menuitem', { - name: 'Remove Top Level Item 2', - } ); - - await removeBlockOption.click(); - - // Check the menu item was removed. - await expect( - listView - .getByRole( 'gridcell', { - name: 'Submenu', - } ) - .filter( { - hasText: 'Block 2 of 2, Level 1', // proxy for filtering by description. - } ) - .getByText( 'Top Level Item 2' ) - ).not.toBeVisible(); - } ); - - test( `can edit menu items`, async ( { - admin, - page, - editor, - requestUtils, - } ) => { - await admin.createNewPost(); - await requestUtils.createNavigationMenu( navMenuBlocksFixture ); - - await editor.insertBlock( { name: 'core/navigation' } ); - - await editor.openDocumentSettingsSidebar(); - - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ); - - // Click on the first menu item to open its settings. - const firstMenuItemAnchor = listView - .getByRole( 'link', { - name: 'Page', - includeHidden: true, - } ) - .getByText( 'Top Level Item 1' ); - await firstMenuItemAnchor.click(); - - // Get the settings panel. - const blockSettings = page.getByRole( 'region', { - name: 'Editor settings', - } ); - - await expect( blockSettings ).toBeVisible(); - - await expect( - blockSettings.getByRole( 'heading', { - name: 'Page Link', - } ) - ).toBeVisible(); - - await expect( - blockSettings.getByRole( 'tab', { - name: 'Settings', - selected: true, - } ) - ).toBeVisible(); - - await expect( - blockSettings - .getByRole( 'tabpanel', { - name: 'Settings', - } ) - .getByRole( 'heading', { - name: 'Settings', - } ) - ).toBeVisible(); - - const labelInput = blockSettings.getByRole( 'textbox', { - name: 'Label', - } ); - - await expect( labelInput ).toHaveValue( 'Top Level Item 1' ); - - await labelInput.focus(); - - await page.keyboard.type( 'Changed label' ); - - // Click the back button to go back to the Nav block. - await blockSettings - .getByRole( 'button', { - name: 'Go to parent Navigation block', - } ) - .click(); - - // Check we're back on the Nav block list view. - const listViewPanel = page.getByRole( 'tabpanel', { - name: 'List View', - } ); - - await expect( listViewPanel ).toBeVisible(); - - // Check the label was updated. - await expect( - listViewPanel - .getByRole( 'gridcell', { - name: 'Page Link', - } ) - .filter( { - hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. - } ) - .getByText( 'Changed label' ) // new label text - ).toBeVisible(); - } ); - - test( `can add submenus`, async ( { - admin, - page, - editor, - requestUtils, - linkControl, - } ) => { - await admin.createNewPost(); - await requestUtils.createNavigationMenu( navMenuBlocksFixture ); - - await editor.insertBlock( { name: 'core/navigation' } ); - - await editor.openDocumentSettingsSidebar(); - - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ); - - // click on options menu for the first menu item and select remove. - const firstMenuItem = listView - .getByRole( 'gridcell', { - name: 'Page Link', - } ) - .filter( { - hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. - } ); - - // The options menu button is a sibling of the menu item gridcell. - const firstItemOptions = firstMenuItem - .locator( '..' ) // parent selector. - .getByRole( 'button', { - name: 'Options for Page Link', - } ); - - // Open the options menu. - await firstItemOptions.click(); - - // Add the submenu. - // usage of `page` is required because the options menu is rendered into a slot - // outside of the treegrid. - const addSubmenuOption = page - .getByRole( 'menu', { - name: 'Options for Page Link', - } ) - .getByRole( 'menuitem', { - name: 'Add submenu', - } ); - - await addSubmenuOption.click(); - - await linkControl.searchFor( 'https://wordpress.org' ); - - await page.keyboard.press( 'Enter' ); - - // Check the new item was inserted in the correct place. - await expect( - listView - .getByRole( 'gridcell', { - name: 'Custom Link', - } ) - .filter( { - hasText: 'Block 1 of 1, Level 2', // proxy for filtering by description. - } ) - .getByText( 'wordpress.org' ) - ).toBeVisible(); - - // Check that the original item is still there but that it is now - // a submenu item. - await expect( - listView - .getByRole( 'gridcell', { - name: 'Submenu', - } ) - .filter( { - hasText: 'Block 1 of 2, Level 1', // proxy for filtering by description. - } ) - .getByText( 'Top Level Item 1' ) - ).toBeVisible(); - } ); - - test( `does not display link interface for blocks that have not just been inserted`, async ( { - admin, - page, - editor, - requestUtils, - linkControl, - } ) => { - // Provides coverage for a bug whereby the Link UI would be unexpectedly displayed for the last - // inserted block even if the block had been deselected and then reselected. - // See: https://github.com/WordPress/gutenberg/issues/50601 - - await admin.createNewPost(); - const { id: menuId } = await requestUtils.createNavigationMenu( - navMenuBlocksFixture - ); - - // Insert x2 blocks as a stress test as several bugs have been found with inserting - // blocks into the navigation block when there are multiple blocks referencing the - // **same** menu. - await editor.insertBlock( { - name: 'core/navigation', - attributes: { - ref: menuId, - }, - } ); - await editor.insertBlock( { - name: 'core/navigation', - attributes: { - ref: menuId, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - description: 'Structure for navigation menu: Test Menu', - } ); - - await listView - .getByRole( 'button', { - name: 'Add block', - } ) - .click(); - - const blockResults = page.getByRole( 'listbox', { - name: 'Blocks', - } ); - - await expect( blockResults ).toBeVisible(); - - const blockResultOptions = blockResults.getByRole( 'option' ); - - // Select the Page Link option. - await blockResultOptions.nth( 0 ).click(); - - // Immediately dismiss the Link UI thereby not populating the `url` attribute - // of the block. - await linkControl.pressCancel(); - - // Get the Inspector Tabs. - const blockSettings = page.getByRole( 'region', { - name: 'Editor settings', - } ); - - // Trigger "unmount" of the List View. - await blockSettings - .getByRole( 'tab', { - name: 'Settings', - } ) - .click(); - - // "Remount" the List View. - // this is where the bug previously occurred. - await blockSettings - .getByRole( 'tab', { - name: 'List View', - } ) - .click(); - - // Check that despite being the last inserted block, the Link UI is not displayed - // in this scenario because it was not **just** inserted into the List View (i.e. - // we have unmounted the list view and then remounted it). - await expect( linkControl.getSearchInput() ).not.toBeVisible(); - } ); - } ); -} ); - -test.describe( 'Navigation block - Frontend interactivity', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'emptytheme' ); - await requestUtils.deleteAllTemplates( 'wp_template_part' ); - await requestUtils.deleteAllPages(); - await requestUtils.deleteAllMenus(); - } ); + attributes: { ref: 1 }, + }, + ] ); - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); + // Find the warning message + const warningMessage = editor.canvas + .getByRole( 'document', { name: 'Block: Navigation' } ) + .getByText( 'Navigation menu has been deleted or is unavailable.' ); + await expect( warningMessage ).toBeVisible(); } ); - test.afterEach( async ( { requestUtils } ) => { - await requestUtils.deleteAllTemplates( 'wp_template_part' ); - await requestUtils.deleteAllPages(); + test( 'Adding new links to a navigation block with existing inner blocks triggers creation of a single Navigation Menu', async ( { + admin, + page, + editor, + requestUtils, + } ) => { + // As this test depends on there being no menus, + // we need to delete any existing menus as an explicit + // precondition rather than rely on global test setup. await requestUtils.deleteAllMenus(); - } ); - - test.describe( 'Overlay menu', () => { - test.beforeEach( async ( { admin, editor, requestUtils } ) => { - await admin.visitSiteEditor( { - postId: 'emptytheme//header', - postType: 'wp_template_part', - } ); - await editor.canvas.click( 'body' ); - await requestUtils.createNavigationMenu( { - title: 'Hidden menu', - content: ` - <!-- wp:navigation-link {"label":"Item 1","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- wp:navigation-link {"label":"Item 2","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - `, - } ); - await editor.insertBlock( { - name: 'core/navigation', - attributes: { overlayMenu: 'always' }, - } ); - await editor.saveSiteEditorEntities(); - } ); - test( 'Overlay menu interactions', async ( { page } ) => { - await page.goto( '/' ); - const overlayMenuFirstElement = page.getByRole( 'link', { - name: 'Item 1', - } ); - const openMenuButton = page.getByRole( 'button', { - name: 'Open menu', - } ); - - const closeMenuButton = page.getByRole( 'button', { - name: 'Close menu', - } ); + // Ensure that there are no menus before beginning the test. + expect( + await requestUtils.getNavigationMenus( { + status: [ 'publish', 'draft' ], + } ) + ).toHaveLength( 0 ); - // Test: overlay menu opens on click on open menu button - await expect( overlayMenuFirstElement ).toBeHidden(); - await openMenuButton.click(); - await expect( overlayMenuFirstElement ).toBeVisible(); - - // Test: overlay menu focuses on first element after opening - await expect( overlayMenuFirstElement ).toBeFocused(); - - // Test: overlay menu traps focus - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - await expect( closeMenuButton ).toBeFocused(); - await page.keyboard.press( 'Shift+Tab' ); - await page.keyboard.press( 'Shift+Tab' ); - await expect( overlayMenuFirstElement ).toBeFocused(); - - // Test: overlay menu closes on click on close menu button - await closeMenuButton.click(); - await expect( overlayMenuFirstElement ).toBeHidden(); - - // Test: overlay menu closes on ESC key - await openMenuButton.click(); - await expect( overlayMenuFirstElement ).toBeVisible(); - await page.keyboard.press( 'Escape' ); - await expect( overlayMenuFirstElement ).toBeHidden(); - await expect( openMenuButton ).toBeFocused(); - } ); - } ); + await admin.createNewPost(); - test.describe( 'Submenu mouse and keyboard interactions', () => { - test.beforeEach( async ( { admin, editor, requestUtils } ) => { - await admin.visitSiteEditor( { - postId: 'emptytheme//header', - postType: 'wp_template_part', - } ); - await editor.canvas.click( 'body' ); - await requestUtils.createNavigationMenu( { - title: 'Hidden menu', - content: ` - <!-- wp:navigation-link {"label":"Link 1","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- wp:navigation-submenu {"label":"Simple Submenu","type":"internal","url":"#heading","kind":"custom"} --> - <!-- wp:navigation-link {"label":"Simple Submenu Link 1","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- /wp:navigation-submenu --> - <!-- wp:navigation-submenu {"label":"Complex Submenu","type":"internal","url":"#heading","kind":"custom"} --> - <!-- wp:navigation-link {"label":"Complex Submenu Link 1","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- wp:navigation-submenu {"label":"Nested Submenu","type":"internal","url":"#heading","kind":"custom"} --> - <!-- wp:navigation-link {"label":"Nested Submenu Link 1","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- /wp:navigation-submenu --> - <!-- wp:navigation-link {"label":"Complex Submenu Link 2","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- /wp:navigation-submenu --> - <!-- wp:navigation-link {"label":"Link 2","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - `, - } ); - await editor.insertBlock( { - name: 'core/navigation', - attributes: { overlayMenu: 'off', openSubmenusOnClick: true }, - } ); - await editor.saveSiteEditorEntities(); - } ); - - test( 'Submenu interactions', async ( { page } ) => { - await page.goto( '/' ); - const simpleSubmenuButton = page.getByRole( 'button', { - name: 'Simple Submenu', - } ); - const innerElement = page.getByRole( 'link', { - name: 'Simple Submenu Link 1', - } ); - const complexSubmenuButton = page.getByRole( 'button', { - name: 'Complex Submenu', - } ); - const nestedSubmenuButton = page.getByRole( 'button', { - name: 'Nested Submenu', - } ); - const firstLevelElement = page.getByRole( 'link', { - name: 'Complex Submenu Link 1', - } ); - const secondLevelElement = page.getByRole( 'link', { - name: 'Nested Submenu Link 1', - } ); - - // Test: submenu opens on click - await expect( innerElement ).toBeHidden(); - await simpleSubmenuButton.click(); - await expect( innerElement ).toBeVisible(); - - // Test: submenu closes on click outside submenu - await page.click( 'body' ); - await expect( innerElement ).toBeHidden(); - - // Test: nested submenu opens on click - await complexSubmenuButton.click(); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeHidden(); - - await nestedSubmenuButton.click(); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeVisible(); - - // Test: nested submenus close on click outside submenu - await page.click( 'body' ); - await expect( firstLevelElement ).toBeHidden(); - await expect( secondLevelElement ).toBeHidden(); - - // Test: submenu opens on Enter keypress - await simpleSubmenuButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( innerElement ).toBeVisible(); - - // Test: submenu closes on ESC key and focuses parent link - await page.keyboard.press( 'Escape' ); - await expect( innerElement ).toBeHidden(); - await expect( simpleSubmenuButton ).toBeFocused(); - - // Test: submenu closes on tab outside submenu - await simpleSubmenuButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( innerElement ).toBeVisible(); - // Tab to first element. - await page.keyboard.press( 'Tab' ); - // Tab outside the submenu. - await page.keyboard.press( 'Tab' ); - await expect( innerElement ).toBeHidden(); - await expect( complexSubmenuButton ).toBeFocused(); - - // Test: only nested submenu closes on tab outside - await complexSubmenuButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeHidden(); - - await nestedSubmenuButton.click(); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeVisible(); - - // Tab to nested submenu first element. - await page.keyboard.press( 'Tab' ); - // Tab outside the nested submenu. - await page.keyboard.press( 'Tab' ); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeHidden(); - // Tab outside the complex submenu. - await page.keyboard.press( 'Tab' ); - await expect( firstLevelElement ).toBeHidden(); - } ); - } ); - - test.describe( 'Submenus (Arrow setting)', () => { - test.beforeEach( async ( { admin, editor, requestUtils } ) => { - await admin.visitSiteEditor( { - postId: 'emptytheme//header', - postType: 'wp_template_part', - } ); - await editor.canvas.click( 'body' ); - await requestUtils.createNavigationMenu( { - title: 'Hidden menu', - content: ` - <!-- wp:navigation-submenu {"label":"Submenu","type":"internal","url":"#heading","kind":"custom"} --> - <!-- wp:navigation-link {"label":"Submenu Link","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- wp:navigation-submenu {"label":"Nested Menu","type":"internal","url":"#heading","kind":"custom"} --> - <!-- wp:navigation-link {"label":"Nested Menu Link","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - <!-- /wp:navigation-submenu --> - <!-- /wp:navigation-submenu --> - `, - } ); - await editor.insertBlock( { - name: 'core/navigation', - attributes: { overlayMenu: 'off' }, - } ); - await editor.saveSiteEditorEntities(); - } ); - - test( 'submenu opens on click in the arrow', async ( { page } ) => { - await page.goto( '/' ); - const arrowButton = page.getByRole( 'button', { - name: 'Submenu submenu', - } ); - const nestedSubmenuArrowButton = page.getByRole( 'button', { - name: 'Nested Menu submenu', - } ); - const firstLevelElement = page.getByRole( 'link', { - name: 'Submenu Link', - } ); - const secondLevelElement = page.getByRole( 'link', { - name: 'Nested Menu Link', - } ); - - await expect( firstLevelElement ).toBeHidden(); - await expect( secondLevelElement ).toBeHidden(); - await arrowButton.click(); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeHidden(); - await nestedSubmenuArrowButton.click(); - await expect( firstLevelElement ).toBeVisible(); - await expect( secondLevelElement ).toBeVisible(); - await page.click( 'body' ); - await expect( firstLevelElement ).toBeHidden(); - await expect( secondLevelElement ).toBeHidden(); + await editor.insertBlock( { + name: 'core/navigation', + attributes: {}, + innerBlocks: [ + { + name: 'core/page-list', + }, + ], } ); - } ); - - test.describe( 'Page list block', () => { - test.beforeEach( async ( { admin, editor, requestUtils } ) => { - const parentPage = await requestUtils.createPage( { - title: 'Parent Page', - status: 'publish', - } ); - - await requestUtils.createPage( { - title: 'Subpage', - status: 'publish', - parent: parentPage.id, - } ); - await admin.visitSiteEditor( { - postId: 'emptytheme//header', - postType: 'wp_template_part', - } ); - await editor.canvas.click( 'body' ); - await requestUtils.createNavigationMenu( { - title: 'Page list menu', - content: ` - <!-- wp:page-list /--> - <!-- wp:navigation-link {"label":"Link","type":"custom","url":"http://www.wordpress.org/","isTopLevelLink":true} /--> - `, - } ); - await editor.insertBlock( { - name: 'core/navigation', - attributes: { overlayMenu: 'off', openSubmenusOnClick: true }, - } ); - await editor.saveSiteEditorEntities(); + const navBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Navigation', } ); - test( 'page-list submenu user interactions', async ( { page } ) => { - await page.goto( '/' ); - const submenuButton = page.getByRole( 'button', { - name: 'Parent Page', - } ); - const innerElement = page.getByRole( 'link', { - name: 'Subpage', - } ); - await expect( innerElement ).toBeHidden(); - - // page-list submenu opens on click - await submenuButton.click(); - await expect( innerElement ).toBeVisible(); - - // page-list submenu closes on click outside - await page.click( 'body' ); - await expect( innerElement ).toBeHidden(); - - // page-list submenu opens on enter keypress - await submenuButton.focus(); - await page.keyboard.press( 'Enter' ); - await expect( innerElement ).toBeVisible(); - - // page-list submenu closes on ESC key and focuses submenu button - await page.keyboard.press( 'Escape' ); - await expect( innerElement ).toBeHidden(); - await expect( submenuButton ).toBeFocused(); - - // page-list submenu closes on tab outside submenu - await page.keyboard.press( 'Enter' ); - // Tab to first element. - await page.keyboard.press( 'Tab' ); - // Tab outside the submenu. - await page.keyboard.press( 'Tab' ); - await expect( innerElement ).toBeHidden(); - } ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Page List', + } ) + ).toBeVisible(); + + await expect( navBlock ).toBeVisible(); + + await editor.selectBlocks( navBlock ); + + await navBlock.getByRole( 'button', { name: 'Add block' } ).click(); + + // This relies on network so allow additional time for + // the request to complete. + await expect( + page.getByRole( 'button', { + name: 'Dismiss this notice', + text: 'Navigation Menu successfully created', + } ) + ).toBeVisible( { timeout: 10000 } ); + + // The creation Navigation Menu will be a draft + // so we need to check for both publish and draft. + expect( + await requestUtils.getNavigationMenus( { + status: [ 'publish', 'draft' ], + } ) + ).toHaveLength( 1 ); } ); } ); - -class LinkControl { - constructor( { page } ) { - this.page = page; - } - - getSearchInput() { - return this.page.getByRole( 'combobox', { - name: 'URL', - } ); - } - - async pressCancel() { - const cancelButton = this.page.getByRole( 'button', { - name: 'Cancel', - } ); - - return cancelButton.click(); - } - - async getSearchResults() { - const searchInput = this.getSearchInput(); - - const resultsRef = await searchInput.getAttribute( 'aria-owns' ); - - const linkUIResults = this.page.locator( `#${ resultsRef }` ); - - await expect( linkUIResults ).toBeVisible(); - - return linkUIResults.getByRole( 'option' ); - } - - async getNthSearchResult( index = 0 ) { - const results = await this.getSearchResults(); - return results.nth( index ); - } - - async searchFor( searchTerm = 'https://wordpress.org' ) { - const input = this.getSearchInput(); - - await expect( input ).toBeFocused(); - - await this.page.keyboard.type( searchTerm ); - - await expect( input ).toHaveValue( searchTerm ); - - return input; - } - - async getSearchResultText( result ) { - await expect( result ).toBeVisible(); - - return result - .locator( '.block-editor-link-control__search-item-title' ) // this is the only way to get the label text without the URL. - .innerText(); - } -} diff --git a/test/e2e/specs/editor/blocks/paragraph.spec.js b/test/e2e/specs/editor/blocks/paragraph.spec.js index ecade19a1aaa40..1c35f6ddc8958c 100644 --- a/test/e2e/specs/editor/blocks/paragraph.spec.js +++ b/test/e2e/specs/editor/blocks/paragraph.spec.js @@ -28,7 +28,7 @@ test.describe( 'Paragraph', () => { } ); await page.keyboard.type( '1' ); - const firstBlockTagName = await page.evaluate( () => { + const firstBlockTagName = await editor.canvas.evaluate( () => { return document.querySelector( '[data-block]' ).tagName; } ); @@ -59,10 +59,16 @@ test.describe( 'Paragraph', () => { test( 'should allow dropping an image on an empty paragraph block', async ( { editor, - page, pageUtils, draggingUtils, + page, } ) => { + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.insertBlock( { name: 'core/paragraph' } ); const testImageName = '10x10_e2e_test_image_z9T8jK.png'; @@ -76,14 +82,18 @@ test.describe( 'Paragraph', () => { testImagePath ); - await dragOver( '[data-type="core/paragraph"]' ); + await dragOver( + editor.canvas.locator( '[data-type="core/paragraph"]' ) + ); await expect( draggingUtils.dropZone ).toBeVisible(); await expect( draggingUtils.insertionIndicator ).not.toBeVisible(); - await drop(); + await drop( + editor.canvas.locator( '[data-type="core/paragraph"]' ) + ); - const imageBlock = page.locator( + const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' ); await expect( imageBlock ).toBeVisible(); @@ -103,7 +113,7 @@ test.describe( 'Paragraph', () => { attributes: { content: 'My Heading' }, } ); await editor.insertBlock( { name: 'core/paragraph' } ); - await page.focus( 'text=My Heading' ); + await editor.canvas.focus( 'text=My Heading' ); await editor.showBlockToolbar(); const dragHandle = page.locator( @@ -112,7 +122,7 @@ test.describe( 'Paragraph', () => { await dragHandle.hover(); await page.mouse.down(); - const emptyParagraph = page.locator( + const emptyParagraph = editor.canvas.locator( '[data-type="core/paragraph"][data-empty="true"]' ); const boundingBox = await emptyParagraph.boundingBox(); @@ -140,7 +150,7 @@ test.describe( 'Paragraph', () => { '<h2 class="wp-block-heading">My Heading</h2>' ); - const emptyParagraph = page.locator( + const emptyParagraph = editor.canvas.locator( '[data-type="core/paragraph"][data-empty="true"]' ); const boundingBox = await emptyParagraph.boundingBox(); @@ -160,7 +170,6 @@ test.describe( 'Paragraph', () => { test.describe( 'Dragging positions', () => { test( 'Only the first block is an empty paragraph block', async ( { editor, - page, draggingUtils, } ) => { await editor.setContent( ` @@ -173,10 +182,10 @@ test.describe( 'Paragraph', () => { <!-- /wp:heading --> ` ); - const emptyParagraph = page.locator( + const emptyParagraph = editor.canvas.locator( '[data-type="core/paragraph"]' ); - const heading = page.locator( 'text=Heading' ); + const heading = editor.canvas.locator( 'text=Heading' ); await draggingUtils.simulateDraggingHTML( '<h2>Draggable</h2>' @@ -271,7 +280,6 @@ test.describe( 'Paragraph', () => { test( 'Only the second block is an empty paragraph block', async ( { editor, - page, draggingUtils, } ) => { await editor.setContent( ` @@ -284,10 +292,10 @@ test.describe( 'Paragraph', () => { <!-- /wp:paragraph --> ` ); - const emptyParagraph = page.locator( + const emptyParagraph = editor.canvas.locator( '[data-type="core/paragraph"]' ); - const heading = page.locator( 'text=Heading' ); + const heading = editor.canvas.locator( 'text=Heading' ); await draggingUtils.simulateDraggingHTML( '<h2>Draggable</h2>' @@ -382,7 +390,6 @@ test.describe( 'Paragraph', () => { test( 'Both blocks are empty paragraph blocks', async ( { editor, - page, draggingUtils, } ) => { await editor.setContent( ` @@ -395,10 +402,10 @@ test.describe( 'Paragraph', () => { <!-- /wp:paragraph --> ` ); - const firstEmptyParagraph = page + const firstEmptyParagraph = editor.canvas .locator( '[data-type="core/paragraph"]' ) .first(); - const secondEmptyParagraph = page + const secondEmptyParagraph = editor.canvas .locator( '[data-type="core/paragraph"]' ) .nth( 1 ); diff --git a/test/e2e/specs/editor/blocks/pullquote.spec.js b/test/e2e/specs/editor/blocks/pullquote.spec.js index 9b20204a624ece..f2a6698f5065ff 100644 --- a/test/e2e/specs/editor/blocks/pullquote.spec.js +++ b/test/e2e/specs/editor/blocks/pullquote.spec.js @@ -12,7 +12,7 @@ test.describe( 'Quote', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'test' ); await editor.transformBlockTo( 'core/quote' ); diff --git a/test/e2e/specs/editor/blocks/quote.spec.js b/test/e2e/specs/editor/blocks/quote.spec.js index bff5bb69685357..44645005ff05e2 100644 --- a/test/e2e/specs/editor/blocks/quote.spec.js +++ b/test/e2e/specs/editor/blocks/quote.spec.js @@ -33,7 +33,7 @@ test.describe( 'Quote', () => { page, } ) => { // Create a block with some text that will trigger a paragraph creation. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '> A quote' ); // Create a second paragraph. await page.keyboard.press( 'Enter' ); @@ -56,7 +56,7 @@ test.describe( 'Quote', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'test' ); await pageUtils.pressKeys( 'ArrowLeft', { times: 'test'.length } ); await page.keyboard.type( '> ' ); @@ -71,7 +71,7 @@ test.describe( 'Quote', () => { test( 'can be created by typing "/quote"', async ( { editor, page } ) => { // Create a list with the slash block shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/quote' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'I’m a quote' ); @@ -88,7 +88,7 @@ test.describe( 'Quote', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'test' ); await editor.transformBlockTo( 'core/quote' ); expect( await editor.getEditedPostContent() ).toBe( @@ -104,12 +104,12 @@ test.describe( 'Quote', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'one' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'two' ); await page.keyboard.down( 'Shift' ); - await page.click( + await editor.canvas.click( 'role=document[name="Paragraph block"i] >> text=one' ); await page.keyboard.up( 'Shift' ); diff --git a/test/e2e/specs/editor/blocks/separator.spec.js b/test/e2e/specs/editor/blocks/separator.spec.js index 8f195392641c87..a2e088e14c3983 100644 --- a/test/e2e/specs/editor/blocks/separator.spec.js +++ b/test/e2e/specs/editor/blocks/separator.spec.js @@ -12,7 +12,7 @@ test.describe( 'Separator', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '---' ); await page.keyboard.press( 'Enter' ); diff --git a/test/e2e/specs/editor/blocks/spacer.spec.js b/test/e2e/specs/editor/blocks/spacer.spec.js index 41e63c2e4e9dec..77e978a0df3027 100644 --- a/test/e2e/specs/editor/blocks/spacer.spec.js +++ b/test/e2e/specs/editor/blocks/spacer.spec.js @@ -10,7 +10,7 @@ test.describe( 'Spacer', () => { test( 'can be created by typing "/spacer"', async ( { editor, page } ) => { // Create a spacer with the slash block shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/spacer' ); await page.keyboard.press( 'Enter' ); @@ -22,11 +22,11 @@ test.describe( 'Spacer', () => { editor, } ) => { // Create a spacer with the slash block shortcut. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '/spacer' ); await page.keyboard.press( 'Enter' ); - const resizableHandle = page.locator( + const resizableHandle = editor.canvas.locator( // Use class name selector until we have `data-testid` for the resize handles. 'role=document[name="Block: Spacer"i] >> css=.components-resizable-box__handle' ); @@ -39,7 +39,7 @@ test.describe( 'Spacer', () => { expect( await editor.getEditedPostContent() ).toMatchSnapshot(); await expect( - page.locator( 'role=document[name="Block: Spacer"i]' ) + editor.canvas.locator( 'role=document[name="Block: Spacer"i]' ) ).toBeFocused(); } ); } ); diff --git a/test/e2e/specs/editor/blocks/table.spec.js b/test/e2e/specs/editor/blocks/table.spec.js index d206089e3f4e3f..689989f9022a3d 100644 --- a/test/e2e/specs/editor/blocks/table.spec.js +++ b/test/e2e/specs/editor/blocks/table.spec.js @@ -15,7 +15,7 @@ test.describe( 'Table', () => { await editor.insertBlock( { name: 'core/table' } ); // Check for existence of the column count field. - const columnCountInput = page.locator( + const columnCountInput = editor.canvas.locator( 'role=spinbutton[name="Column count"i]' ); await expect( columnCountInput ).toBeVisible(); @@ -27,7 +27,7 @@ test.describe( 'Table', () => { await page.keyboard.type( '5' ); // Check for existence of the row count field. - const rowCountInput = page.locator( + const rowCountInput = editor.canvas.locator( 'role=spinbutton[name="Row count"i]' ); await expect( rowCountInput ).toBeVisible(); @@ -39,7 +39,7 @@ test.describe( 'Table', () => { await page.keyboard.type( '10' ); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Expect the post content to have a correctly sized table. expect( await editor.getEditedPostContent() ).toMatchSnapshot(); @@ -49,10 +49,12 @@ test.describe( 'Table', () => { await editor.insertBlock( { name: 'core/table' } ); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Click the first cell and add some text. - await page.click( 'role=textbox[name="Body cell text"i] >> nth=0' ); + await editor.canvas.click( + 'role=textbox[name="Body cell text"i] >> nth=0' + ); await page.keyboard.type( 'This' ); // Navigate to the next cell and add some text. @@ -90,7 +92,7 @@ test.describe( 'Table', () => { await expect( footerSwitch ).toBeHidden(); // // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Expect the header and footer switches to be present now that the table has been created. await page.click( @@ -103,17 +105,17 @@ test.describe( 'Table', () => { await headerSwitch.check(); await footerSwitch.check(); - await page.click( + await editor.canvas.click( 'role=rowgroup >> nth=0 >> role=textbox[name="Header cell text"i] >> nth=0' ); await page.keyboard.type( 'header' ); - await page.click( + await editor.canvas.click( 'role=rowgroup >> nth=1 >> role=textbox[name="Body cell text"i] >> nth=0' ); await page.keyboard.type( 'body' ); - await page.click( + await editor.canvas.click( 'role=rowgroup >> nth=2 >> role=textbox[name="Footer cell text"i] >> nth=0' ); await page.keyboard.type( 'footer' ); @@ -137,7 +139,7 @@ test.describe( 'Table', () => { await editor.openDocumentSettingsSidebar(); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Toggle on the switches and add some content. await page.click( @@ -145,7 +147,9 @@ test.describe( 'Table', () => { ); await page.locator( 'role=checkbox[name="Header section"i]' ).check(); await page.locator( 'role=checkbox[name="Footer section"i]' ).check(); - await page.click( 'role=textbox[name="Body cell text"i] >> nth=0' ); + await editor.canvas.click( + 'role=textbox[name="Body cell text"i] >> nth=0' + ); // Add a column. await editor.clickBlockToolbarButton( 'Edit table' ); @@ -154,7 +158,9 @@ test.describe( 'Table', () => { // Expect the table to have 3 columns across the header, body and footer. expect( await editor.getEditedPostContent() ).toMatchSnapshot(); - await page.click( 'role=textbox[name="Body cell text"i] >> nth=0' ); + await editor.canvas.click( + 'role=textbox[name="Body cell text"i] >> nth=0' + ); // Delete a column. await editor.clickBlockToolbarButton( 'Edit table' ); @@ -167,15 +173,17 @@ test.describe( 'Table', () => { test( 'allows columns to be aligned', async ( { editor, page } ) => { await editor.insertBlock( { name: 'core/table' } ); - await page.click( 'role=spinbutton[name="Column count"i]' ); + await editor.canvas.click( 'role=spinbutton[name="Column count"i]' ); await page.keyboard.press( 'Backspace' ); await page.keyboard.type( '4' ); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Click the first cell and add some text. Don't align. - const cells = page.locator( 'role=textbox[name="Body cell text"i]' ); + const cells = editor.canvas.locator( + 'role=textbox[name="Body cell text"i]' + ); await cells.nth( 0 ).click(); await page.keyboard.type( 'None' ); @@ -210,7 +218,7 @@ test.describe( 'Table', () => { await editor.openDocumentSettingsSidebar(); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Enable fixed width as it exacerbates the amount of empty space around the RichText. await page.click( @@ -221,11 +229,13 @@ test.describe( 'Table', () => { .check(); // Add multiple new lines to the first cell to make it taller. - await page.click( 'role=textbox[name="Body cell text"i] >> nth=0' ); + await editor.canvas.click( + 'role=textbox[name="Body cell text"i] >> nth=0' + ); await page.keyboard.type( '\n\n\n\n' ); // Get the bounding client rect for the second cell. - const { x: secondCellX, y: secondCellY } = await page + const { x: secondCellX, y: secondCellY } = await editor.canvas .locator( 'role=textbox[name="Body cell text"] >> nth=1' ) .boundingBox(); @@ -241,10 +251,12 @@ test.describe( 'Table', () => { await editor.insertBlock( { name: 'core/table' } ); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Click the first cell and add some text. - await page.click( 'role=document[name="Block: Table"i] >> figcaption' ); + await editor.canvas.click( + 'role=document[name="Block: Table"i] >> figcaption' + ); await page.keyboard.type( 'Caption!' ); expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); @@ -252,7 +264,7 @@ test.describe( 'Table', () => { test( 'up and down arrow navigation', async ( { editor, page } ) => { await editor.insertBlock( { name: 'core/table' } ); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); await page.keyboard.type( '1' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.type( '2' ); @@ -263,19 +275,18 @@ test.describe( 'Table', () => { expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); - test( 'should not have focus loss after creation', async ( { - editor, - page, - } ) => { + test( 'should not have focus loss after creation', async ( { editor } ) => { // Insert table block. await editor.insertBlock( { name: 'core/table' } ); // Create the table. - await page.click( 'role=button[name="Create Table"i]' ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); // Focus should be in first td. await expect( - page.locator( 'role=textbox[name="Body cell text"i] >> nth=0' ) + editor.canvas.locator( + 'role=textbox[name="Body cell text"i] >> nth=0' + ) ).toBeFocused(); } ); } ); diff --git a/test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-Should-save-the-changes-1-chromium.txt b/test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-should-load-script-and-dependencies-in-iframe-1-chromium.txt similarity index 100% rename from test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-Should-save-the-changes-1-chromium.txt rename to test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-should-load-script-and-dependencies-in-iframe-1-chromium.txt diff --git a/test/e2e/specs/editor/plugins/allowed-blocks.spec.js b/test/e2e/specs/editor/plugins/allowed-blocks.spec.js new file mode 100644 index 00000000000000..4211e428238218 --- /dev/null +++ b/test/e2e/specs/editor/plugins/allowed-blocks.spec.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Allowed Blocks Filter', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'gutenberg-test-allowed-blocks' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-allowed-blocks' ); + } ); + + test( 'should restrict the allowed blocks in the inserter', async ( { + page, + } ) => { + // The paragraph block is available. + await page + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const searchbox = page + .getByRole( 'region', { name: 'Block Library' } ) + .getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ); + + await searchbox.fill( 'Paragraph' ); + + await expect( + page.getByRole( 'option', { name: 'Paragraph' } ) + ).toBeVisible(); + + // The gallery block is not available. + await searchbox.click( { + clickCount: 3, + } ); + await page.keyboard.press( 'Backspace' ); + + await searchbox.fill( 'Gallery' ); + + await expect( + page.getByRole( 'option', { name: 'Gallery' } ) + ).toBeHidden(); + } ); + + test( 'should remove not allowed blocks from the block manager', async ( { + page, + } ) => { + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + + await page.getByRole( 'menuitem', { name: 'Preferences' } ).click(); + + await page.getByRole( 'tab', { name: 'Blocks' } ).click(); + + await expect( + page + .getByRole( 'region', { name: 'Available block types' } ) + .getByRole( 'listitem' ) + ).toHaveText( [ 'Paragraph', 'Image' ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/plugins/block-api.spec.js b/test/e2e/specs/editor/plugins/block-api.spec.js index 1d4802b2262913..dd2a89cd2beca2 100644 --- a/test/e2e/specs/editor/plugins/block-api.spec.js +++ b/test/e2e/specs/editor/plugins/block-api.spec.js @@ -15,13 +15,14 @@ test.describe( 'Using Block API', () => { test( 'Inserts the filtered hello world block even when filter added after block registration', async ( { admin, editor, - page, } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'e2e-tests/hello-world' } ); - const block = page.locator( '[data-type="e2e-tests/hello-world"]' ); + const block = editor.canvas.locator( + '[data-type="e2e-tests/hello-world"]' + ); await expect( block ).toHaveText( 'Hello Editor!' ); } ); } ); diff --git a/test/e2e/specs/editor/plugins/block-variations.spec.js b/test/e2e/specs/editor/plugins/block-variations.spec.js new file mode 100644 index 00000000000000..302bac732023c5 --- /dev/null +++ b/test/e2e/specs/editor/plugins/block-variations.spec.js @@ -0,0 +1,220 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Block variations', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'gutenberg-test-block-variations' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-block-variations' + ); + } ); + + test( 'Search for the overridden default Quote block', async ( { + page, + } ) => { + await page + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + await page + .getByRole( 'region', { name: 'Block Library' } ) + .getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ) + .fill( 'Quote' ); + + await expect( + page + .getByRole( 'listbox', { name: 'Blocks' } ) + .getByRole( 'option', { name: 'Quote', exact: true } ) + ).toBeHidden(); + await expect( + page + .getByRole( 'listbox', { name: 'Blocks' } ) + .getByRole( 'option', { name: 'Large Quote' } ) + ).toBeVisible(); + } ); + + test( 'Insert the overridden default Quote block variation', async ( { + editor, + page, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Large Quote' ); + await page.keyboard.press( 'Enter' ); + + await expect( + editor.canvas.getByRole( 'document', { name: 'Block: Quote' } ) + ).toHaveClass( /is-style-large/ ); + } ); + + test( 'Search for the Paragraph block with 2 additional variations', async ( { + page, + } ) => { + await page + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + await page + .getByRole( 'region', { name: 'Block Library' } ) + .getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ) + .fill( 'Paragraph' ); + + await expect( + page + .getByRole( 'listbox', { name: 'Blocks' } ) + .getByRole( 'option' ) + ).toHaveText( [ 'Paragraph', 'Success Message', 'Warning Message' ] ); + } ); + + test( 'Insert the Success Message block variation', async ( { + editor, + page, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Heading' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '/Success Message' ); + await page.keyboard.press( 'Enter' ); + + await expect( + editor.canvas.getByRole( 'document', { name: 'Paragraph block' } ) + ).toHaveText( 'This is a success message!' ); + } ); + + test( 'Pick the additional variation in the inserted Columns block', async ( { + editor, + page, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Columns' ); + await page.keyboard.press( 'Enter' ); + + await editor.canvas + .getByRole( 'list', { name: 'Block variations' } ) + .getByRole( 'button', { name: 'Four columns' } ) + .click(); + + await expect( + editor.canvas + .getByRole( 'document', { name: 'Block: Columns' } ) + .getByRole( 'document' ) + ).toHaveCount( 4 ); + } ); + + // Tests the `useBlockDisplayInformation` hook. + test( 'should show block information when no matching variation is found', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Large Quote' ); + await page.keyboard.press( 'Enter' ); + + // Select the quote block. + await page.keyboard.press( 'ArrowDown' ); + + await expect( + page + .getByRole( 'list', { name: 'Block breadcrumb' } ) + .getByRole( 'listitem' ) + .filter( { hasText: 'Quote' } ) + ).toHaveAttribute( 'aria-current', 'true' ); + + await pageUtils.pressKeys( 'access+o' ); + + await expect( + page + .getByRole( 'treegrid', { name: 'Block navigation structure' } ) + .getByRole( 'link' ) + ).toHaveText( 'Quote' ); + + await expect( + page.locator( '.block-editor-block-card__description' ) + ).toHaveText( + 'Give quoted text visual emphasis. "In quoting others, we cite ourselves." — Julio Cortázar' + ); + } ); + + test( 'should display variations info if all declared', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Heading' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '/Success Message' ); + await page.keyboard.press( 'Enter' ); + + await expect( + page + .getByRole( 'list', { name: 'Block breadcrumb' } ) + .getByRole( 'listitem' ) + .filter( { hasText: 'Success Message' } ) + ).toHaveAttribute( 'aria-current', 'true' ); + + await pageUtils.pressKeys( 'access+o' ); + + await expect( + page + .getByRole( 'treegrid', { name: 'Block navigation structure' } ) + .getByRole( 'link' ) + ).toHaveText( 'Success Message' ); + + await expect( + page.locator( '.block-editor-block-card__description' ) + ).toHaveText( + 'This block displays a success message. This description overrides the default one provided for the Paragraph block.' + ); + } ); + + test( 'should display mixed block and variation match information', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Heading' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '/Warning Message' ); + await page.keyboard.press( 'Enter' ); + + await expect( + page + .getByRole( 'list', { name: 'Block breadcrumb' } ) + .getByRole( 'listitem' ) + .filter( { hasText: 'Warning Message' } ) + ).toHaveAttribute( 'aria-current', 'true' ); + + await pageUtils.pressKeys( 'access+o' ); + + await expect( + page + .getByRole( 'treegrid', { + name: 'Block navigation structure', + } ) + .getByRole( 'link' ) + ).toHaveText( 'Warning Message' ); + + // Warning Message variation is missing the `description`. + await expect( + page.locator( '.block-editor-block-card__description' ) + ).toHaveText( 'Start with the basic building block of all narrative.' ); + } ); +} ); diff --git a/test/e2e/specs/editor/plugins/custom-post-types.spec.js b/test/e2e/specs/editor/plugins/custom-post-types.spec.js index 07d32755632821..55207df05175b0 100644 --- a/test/e2e/specs/editor/plugins/custom-post-types.spec.js +++ b/test/e2e/specs/editor/plugins/custom-post-types.spec.js @@ -20,7 +20,7 @@ test.describe( 'Test Custom Post Types', () => { page, } ) => { await admin.createNewPost( { postType: 'hierar-no-title' } ); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Parent Post' ); await editor.publishPost(); @@ -53,7 +53,7 @@ test.describe( 'Test Custom Post Types', () => { await page.getByRole( 'listbox' ).getByRole( 'option' ).first().click(); const parentPage = await parentPageLocator.inputValue(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Child Post' ); await editor.publishPost(); await page.reload(); @@ -68,7 +68,7 @@ test.describe( 'Test Custom Post Types', () => { page, } ) => { await admin.createNewPost( { postType: 'leg_block_in_tpl' } ); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Hello there' ); await expect.poll( editor.getBlocks ).toMatchObject( [ diff --git a/test/e2e/specs/editor/plugins/deprecated-node-matcher.spec.js b/test/e2e/specs/editor/plugins/deprecated-node-matcher.spec.js index 1466781bbf59aa..58f742341ebc3b 100644 --- a/test/e2e/specs/editor/plugins/deprecated-node-matcher.spec.js +++ b/test/e2e/specs/editor/plugins/deprecated-node-matcher.spec.js @@ -27,7 +27,6 @@ test.describe( 'Deprecated Node Matcher', () => { page, editor, } ) => { - // await insertBlock( 'Deprecated Node Matcher' ); await editor.insertBlock( { name: 'core/deprecated-node-matcher' } ); await page.keyboard.type( 'test' ); await page.keyboard.press( 'Enter' ); @@ -39,7 +38,6 @@ test.describe( 'Deprecated Node Matcher', () => { editor, pageUtils, } ) => { - // await insertBlock( 'Deprecated Children Matcher' ); await editor.insertBlock( { name: 'core/deprecated-children-matcher', } ); diff --git a/test/e2e/specs/editor/plugins/format-api.spec.js b/test/e2e/specs/editor/plugins/format-api.spec.js index 21e942ddc2199b..f98d8292ea8f6f 100644 --- a/test/e2e/specs/editor/plugins/format-api.spec.js +++ b/test/e2e/specs/editor/plugins/format-api.spec.js @@ -21,7 +21,7 @@ test.describe( 'Using Format API', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'First paragraph' ); await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); await editor.clickBlockToolbarButton( 'More' ); diff --git a/test/e2e/specs/editor/plugins/hooks-api.spec.js b/test/e2e/specs/editor/plugins/hooks-api.spec.js index 675b3861ee2a2a..9af8e6570eef32 100644 --- a/test/e2e/specs/editor/plugins/hooks-api.spec.js +++ b/test/e2e/specs/editor/plugins/hooks-api.spec.js @@ -19,8 +19,10 @@ test.describe( 'Using Hooks API', () => { test( 'Should contain a reset block button on the sidebar', async ( { page, + editor, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'First paragraph' ); await page.click( `role=region[name="Editor settings"i] >> role=tab[name="Settings"i]` @@ -34,10 +36,11 @@ test.describe( 'Using Hooks API', () => { editor, page, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'First paragraph' ); - const paragraphBlock = page.locator( + const paragraphBlock = editor.canvas.locator( 'role=document[name="Paragraph block"i]' ); await expect( paragraphBlock ).toHaveText( 'First paragraph' ); diff --git a/test/e2e/specs/editor/plugins/iframed-block.spec.js b/test/e2e/specs/editor/plugins/iframed-block.spec.js index 55b67cb70fe86e..0b5343169d5bf8 100644 --- a/test/e2e/specs/editor/plugins/iframed-block.spec.js +++ b/test/e2e/specs/editor/plugins/iframed-block.spec.js @@ -13,34 +13,17 @@ test.describe( 'Iframed block', () => { await requestUtils.deactivatePlugin( 'gutenberg-test-iframed-block' ); } ); - test( 'Should save the changes', async ( { editor, page } ) => { + test( 'should load script and dependencies in iframe', async ( { + editor, + } ) => { await editor.insertBlock( { name: 'test/iframed-block' } ); expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + // Expect the script to load in the iframe, which replaces the block text. await expect( - page.locator( 'role=document[name="Block: Iframed Block"i]' ) + editor.canvas.locator( + 'role=document[name="Block: Iframed Block"i]' + ) ).toContainText( 'Iframed Block (set with jQuery)' ); - - // open page from sidebar settings - await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Page"i]' - ); - - // Opens the template editor with a newly created template. - await page.click( 'role=button[name="Select template"i]' ); - await page.click( 'role=button[name="Add template"i]' ); - await page.fill( 'role=textbox[name="NAME"i]', 'Test template' ); - await page.click( 'role=button[name="Create"i]' ); - - // Expect iframe canvas to be visible - await expect( - page.locator( 'iframe[name="editor-canvas"]' ) - ).toBeVisible(); - - // Expect the script to load in the iframe, which replaces the block text. - const iframedText = page.frameLocator( 'iframe' ).locator( 'body' ); - await expect( iframedText ).toContainText( - 'Iframed Block (set with jQuery)' - ); } ); } ); diff --git a/test/e2e/specs/editor/plugins/iframed-equeue-block-assets.spec.js b/test/e2e/specs/editor/plugins/iframed-equeue-block-assets.spec.js new file mode 100644 index 00000000000000..391e1fdc17ec72 --- /dev/null +++ b/test/e2e/specs/editor/plugins/iframed-equeue-block-assets.spec.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'iframed inline styles', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.activatePlugin( + 'gutenberg-test-iframed-enqueue-block-assets' + ), + ] ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + requestUtils.deactivatePlugin( + 'gutenberg-test-iframed-enqueue-block-assets' + ), + ] ); + } ); + + test( 'should load styles added through enqueue_block_assets', async ( { + editor, + } ) => { + const canvasBody = editor.canvas.locator( 'body' ); + + await expect( canvasBody ).toHaveCSS( + 'background-color', + 'rgb(33, 117, 155)' + ); + await expect( canvasBody ).toHaveCSS( 'padding', '20px' ); + await expect( canvasBody ).toHaveAttribute( + 'data-iframed-enqueue-block-assets-l10n', + 'Iframed Enqueue Block Assets!' + ); + } ); +} ); diff --git a/test/e2e/specs/editor/plugins/image-size.spec.js b/test/e2e/specs/editor/plugins/image-size.spec.js index a7502c93a2476c..1e0bc91407565d 100644 --- a/test/e2e/specs/editor/plugins/image-size.spec.js +++ b/test/e2e/specs/editor/plugins/image-size.spec.js @@ -52,10 +52,10 @@ test.describe( 'changing image size', () => { // Verify that the custom size was applied to the image. await expect( - page.locator( `role=img[name="${ filename }"]` ) + editor.canvas.locator( `role=img[name="${ filename }"]` ) ).toHaveCSS( 'width', '499px' ); await expect( page.locator( 'role=spinbutton[name="Width"i]' ) - ).toHaveValue( '499' ); + ).toHaveValue( '' ); } ); } ); diff --git a/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js b/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js new file mode 100644 index 00000000000000..a515ef68316e83 --- /dev/null +++ b/test/e2e/specs/editor/plugins/inner-blocks-allowed-blocks.spec.js @@ -0,0 +1,146 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Allowed Blocks Setting on InnerBlocks', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( + 'gutenberg-test-innerblocks-allowed-blocks' + ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-innerblocks-allowed-blocks' + ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'allows all blocks if the allowed blocks setting was not set', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/group', + attributes: { + layout: { type: 'constrained' }, + }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { placeholder: 'Add a description' }, + }, + ], + } ); + + await editor.canvas + .getByRole( 'document', { + name: 'Empty block', + } ) + .click(); + + const blockInserter = page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ); + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockInserter.click(); + await expect( blockLibrary ).toBeVisible(); + expect( + await blockLibrary.getByRole( 'option' ).count() + ).toBeGreaterThan( 10 ); + } ); + + test( 'limits the blocks if the allowed blocks setting was set', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/group', + attributes: { + layout: { type: 'constrained' }, + allowedBlocks: [ + 'core/paragraph', + 'core/heading', + 'core/image', + ], + }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { placeholder: 'Add a description' }, + }, + ], + } ); + + // Select inner block. + await editor.canvas + .getByRole( 'document', { + name: 'Empty block', + } ) + .click(); + + const blockInserter = page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ); + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockInserter.click(); + await expect( blockLibrary ).toBeVisible(); + await expect( blockLibrary.getByRole( 'option' ) ).toHaveText( [ + 'Paragraph', + 'Heading', + 'Image', + ] ); + } ); + + // Note: This behavior isn't fully supported. See https://github.com/WordPress/gutenberg/issues/14515. + test( 'correctly applies dynamic allowed blocks restrictions', async ( { + editor, + page, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/Allowed Blocks Dynamic' ); + await page.keyboard.press( 'Enter' ); + + const blockAppender = editor.canvas.getByRole( 'button', { + name: 'Add block', + } ); + await expect( blockAppender ).toBeVisible(); + await blockAppender.click(); + + const blockListBox = page.getByRole( 'listbox', { name: 'Blocks' } ); + await expect( blockListBox ).toBeVisible(); + await expect( blockListBox.getByRole( 'option' ) ).toHaveText( [ + 'Image', + 'List', + ] ); + + // Insert list block. + await blockListBox.getByRole( 'option', { name: 'List' } ).click(); + // Select the list wrapper and then parent block. + await page.keyboard.press( 'ArrowUp' ); + await editor.clickBlockToolbarButton( 'Select Allowed Blocks Dynamic' ); + + // Insert the image. + await blockAppender.click(); + await blockListBox.getByRole( 'option', { name: 'Image' } ).click(); + + await editor.clickBlockToolbarButton( 'Select Allowed Blocks Dynamic' ); + await blockAppender.click(); + + // It should display a different allowed block list. + await expect( blockListBox.getByRole( 'option' ) ).toHaveText( [ + 'Gallery', + 'Video', + ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/plugins/post-type-templates.spec.js b/test/e2e/specs/editor/plugins/post-type-templates.spec.js index 2caffdf52abe2e..c743d08d8ae681 100644 --- a/test/e2e/specs/editor/plugins/post-type-templates.spec.js +++ b/test/e2e/specs/editor/plugins/post-type-templates.spec.js @@ -35,7 +35,7 @@ test.describe( 'Post type templates', () => { // Remove a block from the template to verify that it's not // re-added after saving and reloading the editor. - await page.focus( 'role=textbox[name="Add title"i]' ); + await editor.canvas.focus( 'role=textbox[name="Add title"i]' ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'Backspace' ); await page.click( 'role=button[name="Save draft"i]' ); @@ -64,7 +64,7 @@ test.describe( 'Post type templates', () => { } ) => { // Remove all blocks from the template to verify that they're not // re-added after saving and reloading the editor. - await page.fill( + await editor.canvas.fill( 'role=textbox[name="Add title"i]', 'My Empty Book' ); @@ -125,11 +125,11 @@ test.describe( 'Post type templates', () => { // Remove the default block template to verify that it's not // re-added after saving and reloading the editor. - await page.fill( + await editor.canvas.fill( 'role=textbox[name="Add title"i]', 'My Image Format' ); - await page.focus( 'role=document[name="Block: Image"i]' ); + await editor.canvas.focus( 'role=document[name="Block: Image"i]' ); await page.keyboard.press( 'Backspace' ); await page.click( 'role=button[name="Save draft"i]' ); await expect( diff --git a/test/e2e/specs/editor/plugins/wp-editor-meta-box.spec.js b/test/e2e/specs/editor/plugins/wp-editor-meta-box.spec.js index 27cf0b7b160bde..13720de509e3c8 100644 --- a/test/e2e/specs/editor/plugins/wp-editor-meta-box.spec.js +++ b/test/e2e/specs/editor/plugins/wp-editor-meta-box.spec.js @@ -20,7 +20,10 @@ test.describe( 'WP Editor Meta Boxes', () => { await admin.createNewPost(); // Add title to enable valid non-empty post save. - await page.type( 'role=textbox[name="Add title"i]', 'Hello Meta' ); + await editor.canvas.type( + 'role=textbox[name="Add title"i]', + 'Hello Meta' + ); // Type something. await page.click( 'role=button[name="Text"i]' ); diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-Should-navigate-inner-blocks-with-arrow-keys-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-Should-navigate-inner-blocks-with-arrow-keys-1-chromium.txt deleted file mode 100644 index f6834177f03b73..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-Should-navigate-inner-blocks-with-arrow-keys-1-chromium.txt +++ /dev/null @@ -1,21 +0,0 @@ -<!-- wp:paragraph --> -<p>First paragraph</p> -<!-- /wp:paragraph --> - -<!-- wp:columns --> -<div class="wp-block-columns"><!-- wp:column --> -<div class="wp-block-column"><!-- wp:paragraph --> -<p>1st col</p> -<!-- /wp:paragraph --></div> -<!-- /wp:column --> - -<!-- wp:column --> -<div class="wp-block-column"><!-- wp:paragraph --> -<p>2nd col</p> -<!-- /wp:paragraph --></div> -<!-- /wp:column --></div> -<!-- /wp:columns --> - -<!-- wp:paragraph --> -<p>Second paragraph</p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-create-valid-paragraph-blocks-when-rapidly-pressing-Enter-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-create-valid-paragraph-blocks-when-rapidly-pressing-Enter-1-chromium.txt deleted file mode 100644 index 8b3e8afd099094..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-create-valid-paragraph-blocks-when-rapidly-pressing-Enter-1-chromium.txt +++ /dev/null @@ -1,43 +0,0 @@ -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-around-inline-boundaries-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-around-inline-boundaries-1-chromium.txt deleted file mode 100644 index 1ad3516b59244a..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-around-inline-boundaries-1-chromium.txt +++ /dev/null @@ -1,11 +0,0 @@ -<!-- wp:paragraph --> -<p>FirstAfter</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>Before<strong>InsideSecondInside</strong>After</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>BeforeThird</p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-normal-line-height-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-normal-line-height-1-chromium.txt deleted file mode 100644 index c5276526e35be3..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-normal-line-height-1-chromium.txt +++ /dev/null @@ -1,7 +0,0 @@ -<!-- wp:paragraph --> -<p>1</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-padding-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-padding-1-chromium.txt deleted file mode 100644 index cbf68836cabedb..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-padding-1-chromium.txt +++ /dev/null @@ -1,7 +0,0 @@ -<!-- wp:paragraph --> -<p>1</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>2</p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-side-padding-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-side-padding-1-chromium.txt deleted file mode 100644 index 9c3517aa3a5946..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-contenteditable-with-side-padding-1-chromium.txt +++ /dev/null @@ -1,11 +0,0 @@ -<!-- wp:paragraph --> -<p>1</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-empty-paragraphs-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-empty-paragraphs-1-chromium.txt deleted file mode 100644 index 0e4af197a8815d..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-navigate-empty-paragraphs-1-chromium.txt +++ /dev/null @@ -1,11 +0,0 @@ -<!-- wp:paragraph --> -<p>1</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>3</p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-not-prematurely-multi-select-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-not-prematurely-multi-select-1-chromium.txt deleted file mode 100644 index 21ee0884f979d1..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/Writing-Flow-should-not-prematurely-multi-select-1-chromium.txt +++ /dev/null @@ -1,7 +0,0 @@ -<!-- wp:paragraph --> -<p>1</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>></p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-should-split-and-merge-paragraph-blocks-using-Enter-and-Backspace-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-should-split-and-merge-paragraph-blocks-using-Enter-and-Backspace-1-chromium.txt deleted file mode 100644 index e6b19a3a16b457..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-should-split-and-merge-paragraph-blocks-using-Enter-and-Backspace-1-chromium.txt +++ /dev/null @@ -1,7 +0,0 @@ -<!-- wp:paragraph --> -<p>First</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>Second</p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-should-undo-split-in-one-go-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-should-undo-split-in-one-go-1-chromium.txt deleted file mode 100644 index 2c788e94846abb..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-should-undo-split-in-one-go-1-chromium.txt +++ /dev/null @@ -1,3 +0,0 @@ -<!-- wp:paragraph --> -<p>12</p> -<!-- /wp:paragraph --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-18edb-roduces-more-than-one-block-on-forward-delete-2-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-18edb-roduces-more-than-one-block-on-forward-delete-2-chromium.txt deleted file mode 100644 index 463d4ecae0e093..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-18edb-roduces-more-than-one-block-on-forward-delete-2-chromium.txt +++ /dev/null @@ -1,9 +0,0 @@ -<!-- wp:paragraph --> -<p>hi-item 1</p> -<!-- /wp:paragraph --> - -<!-- wp:list --> -<ul><!-- wp:list-item --> -<li>item 2</li> -<!-- /wp:list-item --></ul> -<!-- /wp:list --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-2a1ee-roduces-more-than-one-block-on-forward-delete-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-2a1ee-roduces-more-than-one-block-on-forward-delete-1-chromium.txt deleted file mode 100644 index 04346aeb9e959c..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-2a1ee-roduces-more-than-one-block-on-forward-delete-1-chromium.txt +++ /dev/null @@ -1,13 +0,0 @@ -<!-- wp:paragraph --> -<p>hi</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>item 1</p> -<!-- /wp:paragraph --> - -<!-- wp:list --> -<ul><!-- wp:list-item --> -<li>item 2</li> -<!-- /wp:list-item --></ul> -<!-- /wp:list --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-46dfa-rge-produces-more-than-one-block-on-backspace-2-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-46dfa-rge-produces-more-than-one-block-on-backspace-2-chromium.txt deleted file mode 100644 index 463d4ecae0e093..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-46dfa-rge-produces-more-than-one-block-on-backspace-2-chromium.txt +++ /dev/null @@ -1,9 +0,0 @@ -<!-- wp:paragraph --> -<p>hi-item 1</p> -<!-- /wp:paragraph --> - -<!-- wp:list --> -<ul><!-- wp:list-item --> -<li>item 2</li> -<!-- /wp:list-item --></ul> -<!-- /wp:list --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-92273-rge-produces-more-than-one-block-on-backspace-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-92273-rge-produces-more-than-one-block-on-backspace-1-chromium.txt deleted file mode 100644 index 04346aeb9e959c..00000000000000 --- a/test/e2e/specs/editor/various/__snapshots__/splitting-and-merging-blocks-test-restore-sele-92273-rge-produces-more-than-one-block-on-backspace-1-chromium.txt +++ /dev/null @@ -1,13 +0,0 @@ -<!-- wp:paragraph --> -<p>hi</p> -<!-- /wp:paragraph --> - -<!-- wp:paragraph --> -<p>item 1</p> -<!-- /wp:paragraph --> - -<!-- wp:list --> -<ul><!-- wp:list-item --> -<li>item 2</li> -<!-- /wp:list-item --></ul> -<!-- /wp:list --> \ No newline at end of file diff --git a/test/e2e/specs/editor/various/a11y.spec.js b/test/e2e/specs/editor/various/a11y.spec.js index 5725f216d1cf24..8b819d3866b6ca 100644 --- a/test/e2e/specs/editor/various/a11y.spec.js +++ b/test/e2e/specs/editor/various/a11y.spec.js @@ -20,10 +20,18 @@ test.describe( 'a11y (@firefox, @webkit)', () => { test( 'navigating through the Editor regions four times should land on the Editor top bar region', async ( { page, pageUtils, + editor, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); // On a new post, initial focus is set on the Post title. await expect( - page.locator( 'role=textbox[name=/Add title/i]' ) + editor.canvas.locator( 'role=textbox[name=/Add title/i]' ) ).toBeFocused(); // Navigate to the 'Editor settings' region. await pageUtils.pressKeys( 'ctrl+`' ); @@ -47,6 +55,13 @@ test.describe( 'a11y (@firefox, @webkit)', () => { page, pageUtils, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); // Open keyboard shortcuts modal. await pageUtils.pressKeys( 'access+h' ); diff --git a/test/e2e/specs/editor/various/adding-inline-tokens.spec.js b/test/e2e/specs/editor/various/adding-inline-tokens.spec.js new file mode 100644 index 00000000000000..0facffd9097e87 --- /dev/null +++ b/test/e2e/specs/editor/various/adding-inline-tokens.spec.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { v4 as uuid } from 'uuid'; + +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'adding inline tokens', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should insert inline image', async ( { + page, + editor, + pageUtils, + } ) => { + // Create a paragraph. + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + + await page.keyboard.type( 'a ' ); + + await editor.showBlockToolbar(); + await page.click( 'role=button[name="More"i]' ); + await page.click( 'role=menuitem[name="Inline image"i]' ); + + const testImagePath = path.join( + __dirname, + '..', + '..', + '..', + 'assets', + '10x10_e2e_test_image_z9T8jK.png' + ); + const filename = uuid(); + const tmpFileName = path.join( os.tmpdir(), filename + '.png' ); + fs.copyFileSync( testImagePath, tmpFileName ); + await page + .locator( '.media-modal .moxie-shim input[type=file]' ) + .setInputFiles( tmpFileName ); + + // Insert the uploaded image. + await page.click( 'role=button[name="Select"i]' ); + + // Check the content. + const contentRegex = new RegExp( + `a <img class="wp-image-\\d+" style="width:\\s*10px;?" src="[^"]+\\/${ filename }\\.png" alt=""\\/?>` + ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: expect.stringMatching( contentRegex ) }, + }, + ] ); + + await pageUtils.pressKeys( 'shift+ArrowLeft' ); + + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.fill( 'role=spinbutton[name="WIDTH"i]', '20' ); + await page.click( 'role=button[name="Apply"i]' ); + + // Check the content. + const contentRegex2 = new RegExp( + `a <img class="wp-image-\\d+" style="width:\\s*20px;?" src="[^"]+\\/${ filename }\\.png" alt=""\\/?>` + ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: expect.stringMatching( contentRegex2 ) }, + }, + ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js index 0176fb45982db8..53b2940fe6c755 100644 --- a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js +++ b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js @@ -475,4 +475,32 @@ test.describe( 'Autocomplete (@firefox, @webkit)', () => { page.locator( 'role=option', { hasText: 'Frodo Baggins' } ) ).not.toBeVisible(); } ); + + test( 'should allow speaking number of initial results', async ( { + page, + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/' ); + await expect( + page.locator( `role=option[name="Image"i]` ) + ).toBeVisible(); + // Get the assertive live region screen reader announcement. + await expect( + page.getByText( + 'Initial 9 results loaded. Type to filter all available results. Use up and down arrow keys to navigate.' + ) + ).toBeVisible(); + + await page.keyboard.type( 'heading' ); + await expect( + page.locator( `role=option[name="Heading"i]` ) + ).toBeVisible(); + // Get the assertive live region screen reader announcement. + await expect( + page.getByText( + '2 results found, use up and down arrow keys to navigate.' + ) + ).toBeVisible(); + } ); } ); diff --git a/test/e2e/specs/editor/various/behaviors.spec.js b/test/e2e/specs/editor/various/behaviors.spec.js index b219ebfb809c13..438eca45e876dd 100644 --- a/test/e2e/specs/editor/various/behaviors.spec.js +++ b/test/e2e/specs/editor/various/behaviors.spec.js @@ -8,65 +8,130 @@ const path = require( 'path' ); */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -test.describe( 'Testing behaviors functionality', () => { - const filename = '1024x768_e2e_test_image_size.jpeg'; - const filepath = path.join( './test/e2e/assets', filename ); +test.use( { + behaviorUtils: async ( { page, requestUtils }, use ) => { + await use( new BehaviorUtils( { page, requestUtils } ) ); + }, +} ); - const createMedia = async ( { admin, requestUtils } ) => { - await admin.createNewPost(); - const media = await requestUtils.uploadMedia( filepath ); - return media; - }; +const filename = '1024x768_e2e_test_image_size.jpeg'; +test.describe( 'Testing behaviors functionality', () => { test.afterAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'twentytwentyone' ); await requestUtils.deleteAllPosts(); } ); + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); test.afterEach( async ( { requestUtils } ) => { await requestUtils.deleteAllMedia(); } ); - test( '`No Behaviors` should be the default as defined in the core theme.json', async ( { + test( 'Behaviors UI can be disabled in the `theme.json`', async ( { + admin, + editor, + requestUtils, + page, + behaviorUtils, + } ) => { + // { "lightbox": true } is the default behavior setting, so we activate the + // `behaviors-ui-disabled` theme where it is disabled by default. Change if we change + // the default value in the core theme.json file. + await requestUtils.activateTheme( 'behaviors-ui-disabled' ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); + + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + }, + } ); + + await editor.openDocumentSettingsSidebar(); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .click(); + + // No behaviors dropdown should be present. + await expect( + editorSettings.getByRole( 'combobox', { + name: 'Behavior', + } ) + ).toBeHidden(); + } ); + + test( "Block's value for behaviors takes precedence over the theme's value", async ( { admin, editor, requestUtils, page, + behaviorUtils, } ) => { await requestUtils.activateTheme( 'twentytwentyone' ); - const media = await createMedia( { admin, requestUtils } ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); + await editor.insertBlock( { name: 'core/image', attributes: { alt: filename, id: media.id, url: media.source_url, + // Explicitly set the value for behaviors to true. + behaviors: { + lightbox: { + enabled: true, + animation: 'zoom', + }, + }, }, } ); - await page.getByRole( 'button', { name: 'Advanced' } ).click(); - const select = page.getByLabel( 'Behavior' ); + await editor.openDocumentSettingsSidebar(); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .click(); + const select = editorSettings.getByRole( 'combobox', { + name: 'Behavior', + } ); - // By default, no behaviors should be selected. - await expect( select ).toHaveCount( 1 ); + // The lightbox should be selected because the value from the block's + // attributes takes precedence over the theme's value. + await expect( select ).toHaveValue( 'lightbox' ); + + // There should be 3 options available: `No behaviors` and `Lightbox`. + await expect( select.getByRole( 'option' ) ).toHaveCount( 3 ); + + // We can change the value of the behaviors dropdown to `No behaviors`. + await select.selectOption( { label: 'No behaviors' } ); await expect( select ).toHaveValue( '' ); - // By default, you should be able to select the Lightbox behavior. - const options = select.locator( 'option' ); - await expect( options ).toHaveCount( 2 ); + // Here we should also check that the block renders on the frontend with the + // lightbox even though the theme.json has it set to false. } ); - test( 'Behaviors UI can be disabled in the `theme.json`', async ( { + test( 'Lightbox behavior is disabled if the Image has a link', async ( { admin, editor, requestUtils, page, + behaviorUtils, } ) => { - // { "lightbox": true } is the default behavior setting, so we activate the - // `behaviors-ui-disabled` theme where it is disabled by default. Change if we change - // the default value in the core theme.json file. - await requestUtils.activateTheme( 'behaviors-ui-disabled' ); - const media = await createMedia( { admin, requestUtils } ); + // In this theme, the default value for settings.behaviors.blocks.core/image.lightbox is `true`. + await requestUtils.activateTheme( 'behaviors-enabled' ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); await editor.insertBlock( { name: 'core/image', @@ -74,23 +139,38 @@ test.describe( 'Testing behaviors functionality', () => { alt: filename, id: media.id, url: media.source_url, + linkDestination: 'custom', }, } ); - await page.getByRole( 'button', { name: 'Advanced' } ).click(); + await editor.openDocumentSettingsSidebar(); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .click(); + const select = editorSettings.getByRole( 'combobox', { + name: 'Behavior', + } ); - // No behaviors dropdown should be present. - await expect( page.getByLabel( 'Behavior' ) ).toHaveCount( 0 ); + // The behaviors dropdown should be present but disabled. + await expect( select ).toBeDisabled(); } ); - test( "Block's value for behaviors takes precedence over the theme's value", async ( { + test( 'Lightbox behavior control has a default option that removes the markup', async ( { admin, editor, requestUtils, page, + behaviorUtils, } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - const media = await createMedia( { admin, requestUtils } ); + const date = new Date(); + const year = date.getFullYear(); + const month = ( date.getMonth() + 1 ).toString().padStart( 2, '0' ); + await requestUtils.activateTheme( 'behaviors-enabled' ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); await editor.insertBlock( { name: 'core/image', @@ -98,40 +178,82 @@ test.describe( 'Testing behaviors functionality', () => { alt: filename, id: media.id, url: media.source_url, - // Explicitly set the value for behaviors to true. behaviors: { lightbox: true }, }, } ); + expect( await editor.getEditedPostContent() ) + .toBe( `<!-- wp:image {"id":${ media.id },"behaviors":{"lightbox":true}} --> +<figure class="wp-block-image"><img src="http://localhost:8889/wp-content/uploads/${ year }/${ month }/1024x768_e2e_test_image_size.jpeg" alt="1024x768_e2e_test_image_size.jpeg" class="wp-image-${ media.id }"/></figure> +<!-- /wp:image -->` ); - await page.getByRole( 'button', { name: 'Advanced' } ).click(); - const select = page.getByLabel( 'Behavior' ); + await editor.openDocumentSettingsSidebar(); - // The lightbox should be selected because the value from the block's - // attributes takes precedence over the theme's value. - await expect( select ).toHaveCount( 1 ); - await expect( select ).toHaveValue( 'lightbox' ); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); - // There should be 2 options available: `No behaviors` and `Lightbox`. - const options = select.locator( 'option' ); - await expect( options ).toHaveCount( 2 ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .last() + .click(); - // We can change the value of the behaviors dropdown to `No behaviors`. - await select.selectOption( { label: 'No behaviors' } ); - await expect( select ).toHaveValue( '' ); + const select = editorSettings.getByRole( 'combobox', { + name: 'Behavior', + } ); - // Here we should also check that the block renders on the frontend with the - // lightbox even though the theme.json has it set to false. + await select.selectOption( { label: 'Default' } ); + expect( await editor.getEditedPostContent() ) + .toBe( `<!-- wp:image {"id":${ media.id }} --> +<figure class="wp-block-image"><img src="http://localhost:8889/wp-content/uploads/${ year }/${ month }/1024x768_e2e_test_image_size.jpeg" alt="1024x768_e2e_test_image_size.jpeg" class="wp-image-${ media.id }"/></figure> +<!-- /wp:image -->` ); } ); - test( 'You can set the default value for the behaviors in the theme.json', async ( { + test( 'Should load the view script if the lightbox is disabled in theme.json but enabled via block settings', async ( { admin, editor, requestUtils, page, + behaviorUtils, + } ) => { + await requestUtils.activateTheme( 'twentytwentythree' ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); + + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + behaviors: { + lightbox: { + enabled: true, + }, + }, + }, + } ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // The view script should be loaded !!! + const interactivityScript = page.locator( + 'script#wp-block-image-view-js' + ); + await expect( interactivityScript ).toHaveCount( 1 ); + } ); + + test( 'Should load the view script if the lightbox is enabled via theme.json', async ( { + admin, + editor, + requestUtils, + page, + behaviorUtils, } ) => { - // In this theme, the default value for settings.behaviors.blocks.core/image.lightbox is `true`. await requestUtils.activateTheme( 'behaviors-enabled' ); - const media = await createMedia( { admin, requestUtils } ); + + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); await editor.insertBlock( { name: 'core/image', @@ -142,20 +264,106 @@ test.describe( 'Testing behaviors functionality', () => { }, } ); - await page.getByRole( 'button', { name: 'Advanced' } ).click(); - const select = page.getByLabel( 'Behavior' ); + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); - // The behaviors dropdown should be present and the value should be set to - // `lightbox`. - await expect( select ).toHaveCount( 1 ); - await expect( select ).toHaveValue( 'lightbox' ); + // The view script should be loaded !!! + const interactivityScript = page.locator( + 'script#wp-block-image-view-js' + ); + await expect( interactivityScript ).toHaveCount( 1 ); + } ); - // There should be 2 options available: `No behaviors` and `Lightbox`. - const options = select.locator( 'option' ); - await expect( options ).toHaveCount( 2 ); + test( 'Should NOT load the view script if the lightbox is disabled in theme.json and in block settings', async ( { + admin, + editor, + requestUtils, + page, + behaviorUtils, + } ) => { + await requestUtils.activateTheme( 'twentytwentythree' ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); - // We can change the value of the behaviors dropdown to `No behaviors`. - await select.selectOption( { label: 'No behaviors' } ); - await expect( select ).toHaveValue( '' ); + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + behaviors: { + lightbox: { + enabled: false, + }, + }, + }, + } ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // The view script should NOT be loaded !!! + const interactivityScript = page.locator( + 'script#wp-block-image-view-js' + ); + await expect( interactivityScript ).toHaveCount( 0 ); + } ); + + test( 'Should NOT load the view script if the lightbox is enabled in theme.json but disabled in block settings', async ( { + admin, + editor, + requestUtils, + page, + behaviorUtils, + } ) => { + await requestUtils.activateTheme( 'behaviors-enabled' ); + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); + + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: media.id, + url: media.source_url, + behaviors: { + lightbox: { + enabled: false, + }, + }, + }, + } ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // The view script should NOT be loaded !!! + const interactivityScript = page.locator( + 'script#wp-block-image-view-js' + ); + await expect( interactivityScript ).toHaveCount( 0 ); } ); } ); + +class BehaviorUtils { + constructor( { page, requestUtils } ) { + this.page = page; + this.requestUtils = requestUtils; + + this.TEST_IMAGE_FILE_PATH = path.join( + __dirname, + '..', + '..', + '..', + 'assets', + filename + ); + } + + async createMedia() { + const media = await this.requestUtils.uploadMedia( + this.TEST_IMAGE_FILE_PATH + ); + return media; + } +} diff --git a/test/e2e/specs/editor/various/block-deletion.spec.js b/test/e2e/specs/editor/various/block-deletion.spec.js index d02a9167c0c3af..9fcacae05b63fa 100644 --- a/test/e2e/specs/editor/various/block-deletion.spec.js +++ b/test/e2e/specs/editor/various/block-deletion.spec.js @@ -37,7 +37,7 @@ test.describe( 'Block deletion', () => { // Remove the current paragraph via the Block Toolbar options menu. await editor.showBlockToolbar(); - await editor.canvas + await page .getByRole( 'toolbar', { name: 'Block tools' } ) .getByRole( 'button', { name: 'Options' } ) .click(); @@ -84,7 +84,7 @@ test.describe( 'Block deletion', () => { // Remove the current paragraph via the Block Toolbar options menu. await editor.showBlockToolbar(); - await editor.canvas + await page .getByRole( 'toolbar', { name: 'Block tools' } ) .getByRole( 'button', { name: 'Options' } ) .click(); @@ -313,7 +313,7 @@ test.describe( 'Block deletion', () => { // Remove that paragraph via its options menu. await editor.showBlockToolbar(); - await editor.canvas + await page .getByRole( 'toolbar', { name: 'Block tools' } ) .getByRole( 'button', { name: 'Options' } ) .click(); diff --git a/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js b/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js new file mode 100644 index 00000000000000..4dc49bc0f7510a --- /dev/null +++ b/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js @@ -0,0 +1,243 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const COLUMNS_BLOCK = [ + { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'First column' }, + }, + ], + }, + { + name: 'core/column', + }, + { + name: 'core/column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Third column' }, + }, + ], + }, + ], + }, +]; + +test.describe( 'Navigating the block hierarchy', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should navigate using the list view sidebar', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.insertBlock( { name: 'core/columns' } ); + await editor.canvas.click( + 'role=button[name="Two columns; equal split"i]' + ); + + // Open the block inserter. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Enter' ); + + // Add a paragraph in the first column. + const paragraph = page.getByRole( 'option', { name: 'Paragraph' } ); + await expect( paragraph ).toBeVisible(); + await paragraph.click(); + await page.keyboard.type( 'First column' ); + + await pageUtils.pressKeys( 'access+o' ); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + + await expect( listView ).toBeVisible(); + await listView + .getByRole( 'gridcell', { name: 'Columns', exact: true } ) + .click(); + + // Tweak the columns count. + await page.getByRole( 'spinbutton', { name: 'Columns' } ).fill( '3' ); + + // Wait for the new column block to appear in the list view + const column = listView.getByRole( 'gridcell', { + name: 'Column', + exact: true, + } ); + await expect( column ).toHaveCount( 3 ); + + await column.last().click(); + + // Open the block inserter. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Enter' ); + + await expect( paragraph ).toBeVisible(); + await paragraph.click(); + await page.keyboard.type( 'Third column' ); + + await expect.poll( editor.getBlocks ).toMatchObject( COLUMNS_BLOCK ); + } ); + + test( 'should navigate block hierarchy using only the keyboard', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.insertBlock( { name: 'core/columns' } ); + await editor.canvas.click( + 'role=button[name="Two columns; equal split"i]' + ); + + // Open the block inserter. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Enter' ); + + // Add a paragraph in the first column. + const paragraph = page.getByRole( 'option', { name: 'Paragraph' } ); + await expect( paragraph ).toBeVisible(); + await paragraph.click(); + await page.keyboard.type( 'First column' ); + + await pageUtils.pressKeys( 'access+o' ); + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + await expect( listView ).toBeVisible(); + + // Navigate to the columns blocks using the keyboard. + await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); + await page.keyboard.press( 'Enter' ); + + // Move focus to the sidebar area. + await pageUtils.pressKeys( 'ctrl+`' ); + + // Navigate to the block settings sidebar and tweak the column count. + await pageUtils.pressKeys( 'Tab', { times: 5 } ); + await expect( + page.getByRole( 'slider', { name: 'Columns' } ) + ).toBeFocused(); + await page.keyboard.press( 'ArrowRight' ); + + // Navigate to the third column in the columns block via List View. + await pageUtils.pressKeys( 'ctrlShift+`', { times: 2 } ); + await pageUtils.pressKeys( 'Tab', { times: 3 } ); + await pageUtils.pressKeys( 'ArrowDown', { times: 4 } ); + + // Insert text in the last column block. + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Enter' ); + + await expect( paragraph ).toBeVisible(); + await paragraph.click(); + await page.keyboard.type( 'Third column' ); + + await expect.poll( editor.getBlocks ).toMatchObject( COLUMNS_BLOCK ); + } ); + + test( 'should appear and function even without nested blocks', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'You say goodbye' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '## Hello, hello' ); + + // Open list view and return to the first block. + await pageUtils.pressKeys( 'access+o' ); + await expect( + page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ) + ).toBeVisible(); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'Space' ); + + // Replace its contents. + await pageUtils.pressKeys( 'primary+a' ); + await page.keyboard.type( 'and I say hello' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'and I say hello' }, + }, + { + name: 'core/heading', + }, + ] ); + } ); + + test( 'should select the wrapper div for a group', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/group' } ); + await editor.canvas.click( + 'role=button[name="Group: Gather blocks in a container."i]' + ); + + // Open the block inserter. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Enter' ); + + // Add some random blocks. + const paragraph = page.getByRole( 'option', { name: 'Paragraph' } ); + await expect( paragraph ).toBeVisible(); + await paragraph.click(); + await page.keyboard.type( 'just a paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '/spacer' ); + await page.keyboard.press( 'Enter' ); + + // Verify group block contents. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'just a paragraph' }, + }, + { + name: 'core/spacer', + }, + ], + }, + ] ); + + // Deselect the blocks. + await editor.canvas.click( 'role=textbox[name="Add title"i]' ); + + // Open list view and return to the first block. + await pageUtils.pressKeys( 'access+o' ); + await expect( + page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + + await expect( + editor.canvas.getByRole( 'document', { name: 'Block: Group' } ) + ).toBeFocused(); + } ); +} ); diff --git a/test/e2e/specs/editor/various/block-locking.spec.js b/test/e2e/specs/editor/various/block-locking.spec.js index 2e977690a1708d..c374c4ee2b0b54 100644 --- a/test/e2e/specs/editor/various/block-locking.spec.js +++ b/test/e2e/specs/editor/various/block-locking.spec.js @@ -8,79 +8,135 @@ test.describe( 'Block Locking', () => { await admin.createNewPost(); } ); - test.describe( 'General', () => { - test( 'can prevent removal', async ( { editor, page } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'Some paragraph' ); + test( 'can prevent removal', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Some paragraph' ); - await editor.clickBlockOptionsMenuItem( 'Lock' ); + await editor.clickBlockOptionsMenuItem( 'Lock' ); - await page.click( 'role=checkbox[name="Prevent removal"]' ); - await page.click( 'role=button[name="Apply"]' ); + await page.click( 'role=checkbox[name="Prevent removal"]' ); + await page.click( 'role=button[name="Apply"]' ); - await expect( - page.locator( 'role=menuitem[name="Delete"]' ) - ).not.toBeVisible(); - } ); + await expect( + page.locator( 'role=menuitem[name="Delete"]' ) + ).not.toBeVisible(); + } ); - test( 'can disable movement', async ( { editor, page } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'First paragraph' ); + test( 'can disable movement', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'First paragraph' ); - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'Second paragraph' ); + await page.keyboard.type( 'Enter' ); + await page.keyboard.type( 'Second paragraph' ); - await editor.clickBlockOptionsMenuItem( 'Lock' ); + await editor.clickBlockOptionsMenuItem( 'Lock' ); - await page.click( 'role=checkbox[name="Disable movement"]' ); - await page.click( 'role=button[name="Apply"]' ); + await page.click( 'role=checkbox[name="Disable movement"]' ); + await page.click( 'role=button[name="Apply"]' ); - // Hide options. - await editor.clickBlockToolbarButton( 'Options' ); + // Hide options. + await editor.clickBlockToolbarButton( 'Options' ); - // Drag handle is hidden. - await expect( - page.locator( 'role=button[name="Drag"]' ) - ).not.toBeVisible(); + // Drag handle is hidden. + await expect( + page.locator( 'role=button[name="Drag"]' ) + ).not.toBeVisible(); - // Movers are hidden. No need to check for both. - await expect( - page.locator( 'role=button[name="Move up"]' ) - ).not.toBeVisible(); - } ); + // Movers are hidden. No need to check for both. + await expect( + page.locator( 'role=button[name="Move up"]' ) + ).not.toBeVisible(); + } ); - test( 'can lock everything', async ( { editor, page } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'Some paragraph' ); + test( 'can lock everything', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Some paragraph' ); - await editor.clickBlockOptionsMenuItem( 'Lock' ); + await editor.clickBlockOptionsMenuItem( 'Lock' ); - await page.click( 'role=checkbox[name="Lock all"]' ); - await page.click( 'role=button[name="Apply"]' ); + await page.click( 'role=checkbox[name="Lock all"]' ); + await page.click( 'role=button[name="Apply"]' ); - expect( await editor.getEditedPostContent() ) - .toBe( `<!-- wp:paragraph {"lock":{"move":true,"remove":true}} --> + expect( await editor.getEditedPostContent() ) + .toBe( `<!-- wp:paragraph {"lock":{"move":true,"remove":true}} --> <p>Some paragraph</p> <!-- /wp:paragraph -->` ); - } ); + } ); - test( 'can unlock from toolbar', async ( { editor, page } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'Some paragraph' ); + test( 'can unlock from toolbar', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Some paragraph' ); - await editor.clickBlockOptionsMenuItem( 'Lock' ); + await editor.clickBlockOptionsMenuItem( 'Lock' ); - await page.click( 'role=checkbox[name="Lock all"]' ); - await page.click( 'role=button[name="Apply"]' ); + await page.click( 'role=checkbox[name="Lock all"]' ); + await page.click( 'role=button[name="Apply"]' ); - await editor.clickBlockToolbarButton( 'Unlock Paragraph' ); - await page.click( 'role=checkbox[name="Lock all"]' ); - await page.click( 'role=button[name="Apply"]' ); + await editor.clickBlockToolbarButton( 'Unlock' ); + await page.click( 'role=checkbox[name="Lock all"]' ); + await page.click( 'role=button[name="Apply"]' ); - expect( await editor.getEditedPostContent() ) - .toBe( `<!-- wp:paragraph {"lock":{"move":false,"remove":false}} --> + expect( await editor.getEditedPostContent() ) + .toBe( `<!-- wp:paragraph {"lock":{"move":false,"remove":false}} --> <p>Some paragraph</p> <!-- /wp:paragraph -->` ); + } ); + + test( 'block locking supersedes template locking', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { + name: 'core/group', + attributes: { + layout: { type: 'constrained' }, + templateLock: 'all', + }, + innerBlocks: [ + { + name: 'core/heading', + attributes: { content: 'Hello, hello' }, + }, + { + name: 'core/paragraph', + attributes: { content: 'WordPress' }, + }, + ], + } ); + + const paragraph = editor.canvas.getByRole( 'document', { + name: 'Paragraph block', } ); + await paragraph.click(); + + await editor.clickBlockToolbarButton( 'Unlock' ); + await page.click( 'role=checkbox[name="Lock all"]' ); + await page.click( 'role=button[name="Apply"]' ); + + await expect( + page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Move up' } ) + ).toBeVisible(); + + await paragraph.click(); + await pageUtils.pressKeys( 'access+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + attributes: { + layout: { type: 'constrained' }, + templateLock: 'all', + }, + innerBlocks: [ + { + name: 'core/heading', + attributes: { content: 'Hello, hello' }, + }, + ], + }, + ] ); } ); } ); diff --git a/test/e2e/specs/editor/various/block-mover.spec.js b/test/e2e/specs/editor/various/block-mover.spec.js index e90ee5f7f0122d..4ed90191f25585 100644 --- a/test/e2e/specs/editor/various/block-mover.spec.js +++ b/test/e2e/specs/editor/various/block-mover.spec.js @@ -23,7 +23,7 @@ test.describe( 'block mover', () => { } ); // Select a block so the block mover is rendered. - await page.focus( 'text=First Paragraph' ); + await editor.canvas.focus( 'text=First Paragraph' ); await editor.showBlockToolbar(); const moveDownButton = page.locator( @@ -47,7 +47,7 @@ test.describe( 'block mover', () => { attributes: { content: 'First Paragraph' }, } ); // Select a block so the block mover has the possibility of being rendered. - await page.focus( 'text=First Paragraph' ); + await editor.canvas.focus( 'text=First Paragraph' ); await editor.showBlockToolbar(); // Ensure no block mover exists when only one block exists on the page. diff --git a/test/e2e/specs/editor/various/compatibility-classic-editor.spec.js b/test/e2e/specs/editor/various/compatibility-classic-editor.spec.js index 0ea1908cd90ef5..a1efbfa579b9a7 100644 --- a/test/e2e/specs/editor/various/compatibility-classic-editor.spec.js +++ b/test/e2e/specs/editor/various/compatibility-classic-editor.spec.js @@ -17,7 +17,7 @@ test.describe( 'Compatibility with classic editor', () => { editor, } ) => { await editor.insertBlock( { name: 'core/html' } ); - await page.focus( 'role=textbox[name="HTML"i]' ); + await editor.canvas.focus( 'role=textbox[name="HTML"i]' ); await page.keyboard.type( '<a>' ); await page.keyboard.type( 'Random Link' ); await page.keyboard.type( '</a> ' ); diff --git a/test/e2e/specs/editor/various/content-only-lock.spec.js b/test/e2e/specs/editor/various/content-only-lock.spec.js index c215da33f3ec57..d6ea152d65f3ff 100644 --- a/test/e2e/specs/editor/various/content-only-lock.spec.js +++ b/test/e2e/specs/editor/various/content-only-lock.spec.js @@ -15,16 +15,17 @@ test.describe( 'Content-only lock', () => { } ) => { // Add content only locked block in the code editor await pageUtils.pressKeys( 'secondary+M' ); // Emulates CTRL+Shift+Alt + M => toggle code editor - await page.click( '.editor-post-text-editor' ); - await page.keyboard - .type( `<!-- wp:group {"templateLock":"contentOnly","layout":{"type":"constrained"}} --> - <div class="wp-block-group"><!-- wp:paragraph --> - <p>Hello</p> - <!-- /wp:paragraph --></div> - <!-- /wp:group -->` ); - await pageUtils.pressKeys( 'secondary+M' ); - await page.click( 'role=document[name="Paragraph block"i]' ); + await page.getByPlaceholder( 'Start writing with text or HTML' ) + .fill( `<!-- wp:group {"templateLock":"contentOnly","layout":{"type":"constrained"}} --> +<div class="wp-block-group"><!-- wp:paragraph --> +<p>Hello</p> +<!-- /wp:paragraph --></div> +<!-- /wp:group -->` ); + + await pageUtils.pressKeys( 'secondary+M' ); + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); + await editor.canvas.click( 'role=document[name="Paragraph block"i]' ); await page.keyboard.type( ' World' ); expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js index cb040504de4c81..823926c1121a02 100644 --- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js +++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js @@ -13,7 +13,7 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Copy - collapsed selection' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '2' ); @@ -31,7 +31,14 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Cut - collapsed selection' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '2' ); @@ -59,7 +66,7 @@ test.describe( 'Copy/cut/paste', () => { await page.evaluate( () => { window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); } ); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await pageUtils.pressKeys( 'primary+v' ); expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); @@ -78,7 +85,7 @@ test.describe( 'Copy/cut/paste', () => { await page.evaluate( () => { window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); } ); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await pageUtils.pressKeys( 'primary+v' ); expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); @@ -88,7 +95,7 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'First block' ); await page.keyboard.press( 'Enter' ); @@ -240,7 +247,7 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'A block' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'B block' ); @@ -252,7 +259,7 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+ArrowLeft' ); // Sometimes the caret has not moved to the correct position before pressing Enter. // @see https://github.com/WordPress/gutenberg/issues/40303#issuecomment-1109434887 - await page.waitForFunction( + await editor.canvas.waitForFunction( () => window.getSelection().type === 'Caret' ); // Create a new block at the top of the document to paste there. @@ -267,7 +274,7 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'A block' ); await editor.insertBlock( { name: 'core/spacer' } ); await page.keyboard.press( 'Enter' ); @@ -280,7 +287,7 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+ArrowLeft' ); // Sometimes the caret has not moved to the correct position before pressing Enter. // @see https://github.com/WordPress/gutenberg/issues/40303#issuecomment-1109434887 - await page.waitForFunction( + await editor.canvas.waitForFunction( () => window.getSelection().type === 'Caret' ); // Create a new block at the top of the document to paste there. @@ -295,7 +302,7 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'A block' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'B block' ); @@ -307,7 +314,7 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+ArrowLeft' ); // Sometimes the caret has not moved to the correct position before pressing Enter. // @see https://github.com/WordPress/gutenberg/issues/40303#issuecomment-1109434887 - await page.waitForFunction( + await editor.canvas.waitForFunction( () => window.getSelection().type === 'Caret' ); // Create a new block at the top of the document to paste there. @@ -322,7 +329,7 @@ test.describe( 'Copy/cut/paste', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'A block' ); await editor.insertBlock( { name: 'core/spacer' } ); await page.keyboard.press( 'Enter' ); @@ -335,7 +342,7 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+ArrowLeft' ); // Sometimes the caret has not moved to the correct position before pressing Enter. // @see https://github.com/WordPress/gutenberg/issues/40303#issuecomment-1109434887 - await page.waitForFunction( + await editor.canvas.waitForFunction( () => window.getSelection().type === 'Caret' ); // Create a new block at the top of the document to paste there. @@ -362,7 +369,7 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+ArrowLeft' ); // Sometimes the caret has not moved to the correct position before pressing Enter. // @see https://github.com/WordPress/gutenberg/issues/40303#issuecomment-1109434887 - await page.waitForFunction( + await editor.canvas.waitForFunction( () => window.getSelection().type === 'Caret' ); // Create a new block at the top of the document to paste there. @@ -389,7 +396,7 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+ArrowLeft' ); // Sometimes the caret has not moved to the correct position before pressing Enter. // @see https://github.com/WordPress/gutenberg/issues/40303#issuecomment-1109434887 - await page.waitForFunction( + await editor.canvas.waitForFunction( () => window.getSelection().type === 'Caret' ); // Create a new code block to paste there. @@ -399,8 +406,8 @@ test.describe( 'Copy/cut/paste', () => { } ); test( 'should paste single line in post title', async ( { - page, pageUtils, + editor, } ) => { // This test checks whether we are correctly handling single line // pasting in the post title. Previously we were accidentally falling @@ -413,13 +420,16 @@ test.describe( 'Copy/cut/paste', () => { await pageUtils.pressKeys( 'primary+v' ); // Expect the span to be filtered out. expect( - await page.evaluate( () => document.activeElement.innerHTML ) + await editor.canvas.evaluate( + () => document.activeElement.innerHTML + ) ).toMatchSnapshot(); } ); test( 'should paste single line in post title with existing content', async ( { page, pageUtils, + editor, } ) => { await page.keyboard.type( 'ab' ); await page.keyboard.press( 'ArrowLeft' ); @@ -430,7 +440,9 @@ test.describe( 'Copy/cut/paste', () => { // Ensure the selection is correct. await page.keyboard.type( 'y' ); expect( - await page.evaluate( () => document.activeElement.innerHTML ) + await editor.canvas.evaluate( + () => document.activeElement.innerHTML + ) ).toBe( 'axyb' ); } ); diff --git a/test/e2e/specs/editor/various/draggable-blocks.spec.js b/test/e2e/specs/editor/various/draggable-blocks.spec.js index d73040bafa48c9..a66efe4540f38a 100644 --- a/test/e2e/specs/editor/various/draggable-blocks.spec.js +++ b/test/e2e/specs/editor/various/draggable-blocks.spec.js @@ -42,7 +42,9 @@ test.describe( 'Draggable block', () => { <p>2</p> <!-- /wp:paragraph -->` ); - await page.focus( 'role=document[name="Paragraph block"i] >> text=2' ); + await editor.canvas.focus( + 'role=document[name="Paragraph block"i] >> text=2' + ); await editor.showBlockToolbar(); const dragHandle = page.locator( @@ -54,7 +56,7 @@ test.describe( 'Draggable block', () => { await page.mouse.down(); // Move to and hover on the upper half of the paragraph block to trigger the indicator. - const firstParagraph = page.locator( + const firstParagraph = editor.canvas.locator( 'role=document[name="Paragraph block"i] >> text=1' ); const firstParagraphBound = await firstParagraph.boundingBox(); @@ -112,7 +114,9 @@ test.describe( 'Draggable block', () => { <p>2</p> <!-- /wp:paragraph -->` ); - await page.focus( 'role=document[name="Paragraph block"i] >> text=1' ); + await editor.canvas.focus( + 'role=document[name="Paragraph block"i] >> text=1' + ); await editor.showBlockToolbar(); const dragHandle = page.locator( @@ -124,7 +128,7 @@ test.describe( 'Draggable block', () => { await page.mouse.down(); // Move to and hover on the bottom half of the paragraph block to trigger the indicator. - const secondParagraph = page.locator( + const secondParagraph = editor.canvas.locator( 'role=document[name="Paragraph block"i] >> text=2' ); const secondParagraphBound = await secondParagraph.boundingBox(); @@ -193,7 +197,9 @@ test.describe( 'Draggable block', () => { ], } ); - await page.focus( 'role=document[name="Paragraph block"i] >> text=2' ); + await editor.canvas.focus( + 'role=document[name="Paragraph block"i] >> text=2' + ); await editor.showBlockToolbar(); const dragHandle = page.locator( @@ -205,7 +211,7 @@ test.describe( 'Draggable block', () => { await page.mouse.down(); // Move to and hover on the left half of the paragraph block to trigger the indicator. - const firstParagraph = page.locator( + const firstParagraph = editor.canvas.locator( 'role=document[name="Paragraph block"i] >> text=1' ); const firstParagraphBound = await firstParagraph.boundingBox(); @@ -272,7 +278,9 @@ test.describe( 'Draggable block', () => { ], } ); - await page.focus( 'role=document[name="Paragraph block"i] >> text=1' ); + await editor.canvas.focus( + 'role=document[name="Paragraph block"i] >> text=1' + ); await editor.showBlockToolbar(); const dragHandle = page.locator( @@ -284,7 +292,7 @@ test.describe( 'Draggable block', () => { await page.mouse.down(); // Move to and hover on the right half of the paragraph block to trigger the indicator. - const secondParagraph = page.locator( + const secondParagraph = editor.canvas.locator( 'role=document[name="Paragraph block"i] >> text=2' ); const secondParagraphBound = await secondParagraph.boundingBox(); @@ -334,6 +342,13 @@ test.describe( 'Draggable block', () => { editor, pageUtils, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); // Insert a row. await editor.insertBlock( { name: 'core/group', diff --git a/test/e2e/specs/editor/various/duplicating-blocks.spec.js b/test/e2e/specs/editor/various/duplicating-blocks.spec.js index aed4fae223894c..aabb93e2295722 100644 --- a/test/e2e/specs/editor/various/duplicating-blocks.spec.js +++ b/test/e2e/specs/editor/various/duplicating-blocks.spec.js @@ -8,7 +8,7 @@ test.describe( 'Duplicating blocks', () => { await admin.createNewPost(); } ); - test( 'should duplicate blocks using the block settings menu', async ( { + test( 'should duplicate blocks using the block settings menu and keyboard shortcut', async ( { page, pageUtils, editor, @@ -16,11 +16,7 @@ test.describe( 'Duplicating blocks', () => { await editor.insertBlock( { name: 'core/paragraph' } ); await page.keyboard.type( 'Clone me' ); - // Select the test we just typed - // This doesn't do anything but we previously had a duplicationi bug - // When the selection was not collapsed. - await pageUtils.pressKeys( 'primary+a' ); - + // Test: Duplicate using the block settings menu. await editor.clickBlockToolbarButton( 'Options' ); await page.click( 'role=menuitem[name=/Duplicate/i]' ); @@ -33,22 +29,8 @@ test.describe( 'Duplicating blocks', () => { <p>Clone me</p> <!-- /wp:paragraph -->` ); - } ); - - test( 'should duplicate blocks using the keyboard shortcut', async ( { - page, - pageUtils, - editor, - } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'Clone me' ); - // Select the test we just typed - // This doesn't do anything but we previously had a duplicationi bug - // When the selection was not collapsed. - await pageUtils.pressKeys( 'primary+a' ); - - // Duplicate using the keyboard shortccut. + // Test: Duplicate using the keyboard shortccut. await pageUtils.pressKeys( 'primaryShift+d' ); expect( await editor.getEditedPostContent() ).toBe( @@ -56,6 +38,10 @@ test.describe( 'Duplicating blocks', () => { <p>Clone me</p> <!-- /wp:paragraph --> +<!-- wp:paragraph --> +<p>Clone me</p> +<!-- /wp:paragraph --> + <!-- wp:paragraph --> <p>Clone me</p> <!-- /wp:paragraph -->` diff --git a/test/e2e/specs/editor/various/font-size-picker.spec.js b/test/e2e/specs/editor/various/font-size-picker.spec.js index e63a5984443bca..ddc47e3ee6de66 100644 --- a/test/e2e/specs/editor/various/font-size-picker.spec.js +++ b/test/e2e/specs/editor/various/font-size-picker.spec.js @@ -24,7 +24,9 @@ test.describe( 'Font Size Picker', () => { page, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph to be made "small"' ); await page.click( 'role=region[name="Editor settings"i] >> role=button[name="Set custom size"i]' @@ -45,7 +47,9 @@ test.describe( 'Font Size Picker', () => { pageUtils, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph reset - custom size' ); await page.click( 'role=region[name="Editor settings"i] >> role=button[name="Set custom size"i]' @@ -135,7 +139,9 @@ test.describe( 'Font Size Picker', () => { pageUtils, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph to be made "large"' ); await page.click( 'role=group[name="Font size"i] >> role=button[name="Font size"i]' @@ -155,7 +161,9 @@ test.describe( 'Font Size Picker', () => { pageUtils, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph with font size reset using tools panel menu' ); @@ -186,7 +194,9 @@ test.describe( 'Font Size Picker', () => { pageUtils, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph with font size reset using input field' ); @@ -221,7 +231,9 @@ test.describe( 'Font Size Picker', () => { page, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph to be made "large"' ); await page.click( 'role=radiogroup[name="Font size"i] >> role=radio[name="Large"i]' @@ -238,7 +250,9 @@ test.describe( 'Font Size Picker', () => { page, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph with font size reset using tools panel menu' ); @@ -267,7 +281,9 @@ test.describe( 'Font Size Picker', () => { pageUtils, } ) => { await editor.openDocumentSettingsSidebar(); - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( + 'role=button[name="Add default block"i]' + ); await page.keyboard.type( 'Paragraph with font size reset using input field' ); diff --git a/test/e2e/specs/editor/various/footnotes.spec.js b/test/e2e/specs/editor/various/footnotes.spec.js new file mode 100644 index 00000000000000..e024964831dfe7 --- /dev/null +++ b/test/e2e/specs/editor/various/footnotes.spec.js @@ -0,0 +1,425 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +async function getFootnotes( page, withoutSave = false ) { + // Save post so we can check meta. + if ( ! withoutSave ) { + await page.click( 'button:text("Save draft")' ); + } + await page.waitForSelector( 'button:text("Saved")' ); + const footnotes = await page.evaluate( () => { + return window.wp.data + .select( 'core' ) + .getEntityRecord( + 'postType', + 'post', + window.wp.data.select( 'core/editor' ).getCurrentPostId() + ).meta.footnotes; + } ); + return JSON.parse( footnotes ); +} + +test.describe( 'Footnotes', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'can be inserted', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'first paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'second paragraph' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + await page.keyboard.type( 'first footnote' ); + + const id1 = await editor.canvas.evaluate( () => { + return document.activeElement.id; + } ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'first paragraph' }, + }, + { + name: 'core/paragraph', + attributes: { + content: `second paragraph<sup data-fn="${ id1 }" class="fn"><a href="#${ id1 }" id="${ id1 }-link">1</a></sup>`, + }, + }, + { + name: 'core/footnotes', + }, + ] ); + + await editor.canvas.click( 'p:text("first paragraph")' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + await page.keyboard.type( 'second footnote' ); + + const id2 = await editor.canvas.evaluate( () => { + return document.activeElement.id; + } ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: `first paragraph<sup data-fn="${ id2 }" class="fn"><a href="#${ id2 }" id="${ id2 }-link">1</a></sup>`, + }, + }, + { + name: 'core/paragraph', + attributes: { + content: `second paragraph<sup data-fn="${ id1 }" class="fn"><a href="#${ id1 }" id="${ id1 }-link">2</a></sup>`, + }, + }, + { + name: 'core/footnotes', + }, + ] ); + + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'second footnote', + id: id2, + }, + { + content: 'first footnote', + id: id1, + }, + ] ); + + await editor.canvas.click( 'p:text("first paragraph")' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'Move down' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: `second paragraph<sup data-fn="${ id1 }" class="fn"><a href="#${ id1 }" id="${ id1 }-link">1</a></sup>`, + }, + }, + { + name: 'core/paragraph', + attributes: { + content: `first paragraph<sup data-fn="${ id2 }" class="fn"><a href="#${ id2 }" id="${ id2 }-link">2</a></sup>`, + }, + }, + { + name: 'core/footnotes', + }, + ] ); + + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'first footnote', + id: id1, + }, + { + content: 'second footnote', + id: id2, + }, + ] ); + + await editor.canvas.click( `a[href="#${ id2 }-link"]` ); + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: `second paragraph<sup data-fn="${ id1 }" class="fn"><a href="#${ id1 }" id="${ id1 }-link">1</a></sup>`, + }, + }, + { + name: 'core/paragraph', + attributes: { + content: `first paragraph`, + }, + }, + { + name: 'core/footnotes', + }, + ] ); + + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'first footnote', + id: id1, + }, + ] ); + + await editor.canvas.click( `a[href="#${ id1 }-link"]` ); + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: `second paragraph`, + }, + }, + { + name: 'core/paragraph', + attributes: { + content: `first paragraph`, + }, + }, + { + name: 'core/footnotes', + }, + ] ); + + expect( await getFootnotes( page ) ).toMatchObject( [] ); + } ); + + test( 'can be inserted in a list', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '* 1' ); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + await page.keyboard.type( 'a' ); + + const id1 = await editor.canvas.evaluate( () => { + return document.activeElement.id; + } ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { + content: `1<sup data-fn="${ id1 }" class="fn"><a href="#${ id1 }" id="${ id1 }-link">1</a></sup>`, + }, + }, + ], + }, + { + name: 'core/footnotes', + }, + ] ); + + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'a', + id: id1, + }, + ] ); + } ); + + test( 'can be inserted in a table', async ( { editor, page } ) => { + await editor.insertBlock( { name: 'core/table' } ); + await editor.canvas.click( 'role=button[name="Create Table"i]' ); + await page.keyboard.type( '1' ); + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + await page.keyboard.type( 'a' ); + + const id1 = await editor.canvas.evaluate( () => { + return document.activeElement.id; + } ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/table', + attributes: { + body: [ + { + cells: [ + { + content: `1<sup data-fn="${ id1 }" class="fn"><a href="#${ id1 }" id="${ id1 }-link">1</a></sup>`, + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + ], + }, + }, + { + name: 'core/footnotes', + }, + ] ); + + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'a', + id: id1, + }, + ] ); + } ); + + test( 'works with revisions', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'first paragraph' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'second paragraph' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + // Check if content is correctly slashed on save and restore. + await page.keyboard.type( 'first footnote"' ); + + const id1 = await editor.canvas.evaluate( () => { + return document.activeElement.id; + } ); + + await editor.canvas.click( 'p:text("first paragraph")' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + await page.keyboard.type( 'second footnote' ); + + const id2 = await editor.canvas.evaluate( () => { + return document.activeElement.id; + } ); + + // This also saves the post! + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'second footnote', + id: id2, + }, + { + content: 'first footnote"', + id: id1, + }, + ] ); + + await editor.canvas.click( 'p:text("first paragraph")' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'Move down' ); + + // This also saves the post! + expect( await getFootnotes( page ) ).toMatchObject( [ + { + content: 'first footnote"', + id: id1, + }, + { + content: 'second footnote', + id: id2, + }, + ] ); + + const editorPage = page; + const previewPage = await editor.openPreviewPage(); + + await expect( + previewPage.locator( 'ol.wp-block-footnotes' ) + ).toHaveText( 'first footnote” ↩︎second footnote ↩︎' ); + + await previewPage.close(); + await editorPage.bringToFront(); + + // Open revisions. + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Post' } ) + .click(); + await page.locator( 'a:text("2 Revisions")' ).click(); + await page.locator( '.revisions-controls .ui-slider-handle' ).focus(); + await page.keyboard.press( 'ArrowLeft' ); + await page.locator( 'input:text("Restore This Revision")' ).click(); + + expect( await getFootnotes( page, true ) ).toMatchObject( [ + { + content: 'second footnote', + id: id2, + }, + { + content: 'first footnote"', + id: id1, + }, + ] ); + + const previewPage2 = await editor.openPreviewPage(); + + await expect( + previewPage2.locator( 'ol.wp-block-footnotes' ) + ).toHaveText( 'second footnote ↩︎first footnote” ↩︎' ); + + await previewPage2.close(); + await editorPage.bringToFront(); + } ); + + test( 'can be previewed when published', async ( { editor, page } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'a' ); + + await editor.showBlockToolbar(); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Footnote")' ).click(); + + await page.keyboard.type( '1' ); + + // Publish post. + await editor.publishPost(); + + await editor.canvas.click( 'ol.wp-block-footnotes li span' ); + await page.keyboard.press( 'End' ); + await page.keyboard.type( '2' ); + + const editorPage = page; + const previewPage = await editor.openPreviewPage(); + + await expect( + previewPage.locator( 'ol.wp-block-footnotes li' ) + ).toHaveText( '12 ↩︎' ); + + await previewPage.close(); + await editorPage.bringToFront(); + + // Test again, this time with an existing revision (different code + // path). + await editor.canvas.click( 'ol.wp-block-footnotes li span' ); + await page.keyboard.press( 'End' ); + // Test slashing. + await page.keyboard.type( '3"' ); + + const previewPage2 = await editor.openPreviewPage(); + + // Note: quote will get curled by wptexturize. + await expect( + previewPage2.locator( 'ol.wp-block-footnotes li' ) + ).toHaveText( '123″ ↩︎' ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/inner-blocks-templates.spec.js b/test/e2e/specs/editor/various/inner-blocks-templates.spec.js index 87ad2604281983..47ef0dfe747912 100644 --- a/test/e2e/specs/editor/various/inner-blocks-templates.spec.js +++ b/test/e2e/specs/editor/various/inner-blocks-templates.spec.js @@ -6,7 +6,7 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'Inner blocks templates', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.activatePlugin( - 'gutenberg-test-inner-blocks-templates' + 'gutenberg-test-innerblocks-templates' ); } ); @@ -16,7 +16,7 @@ test.describe( 'Inner blocks templates', () => { test.afterAll( async ( { requestUtils } ) => { await requestUtils.deactivatePlugin( - 'gutenberg-test-inner-blocks-templates' + 'gutenberg-test-innerblocks-templates' ); } ); @@ -28,9 +28,11 @@ test.describe( 'Inner blocks templates', () => { name: 'test/test-inner-blocks-async-template', } ); - const blockWithTemplateContent = page.locator( - 'role=document[name="Block: Test Inner Blocks Async Template"i] >> text=OneTwo' - ); + const blockWithTemplateContent = page + .frameLocator( '[name=editor-canvas]' ) + .locator( + 'role=document[name="Block: Test Inner Blocks Async Template"i] >> text=OneTwo' + ); // The block template content appears asynchronously, so wait for it. await expect( blockWithTemplateContent ).toBeVisible(); diff --git a/test/e2e/specs/editor/various/inserting-blocks.spec.js b/test/e2e/specs/editor/various/inserting-blocks.spec.js index 0ddb2b6b59228c..39c159b00b75c0 100644 --- a/test/e2e/specs/editor/various/inserting-blocks.spec.js +++ b/test/e2e/specs/editor/various/inserting-blocks.spec.js @@ -15,8 +15,16 @@ test.use( { } ); test.describe( 'Inserting blocks (@firefox, @webkit)', () => { - test.beforeEach( async ( { admin } ) => { + test.beforeEach( async ( { admin, page } ) => { await admin.createNewPost(); + // To do: some drag an drop tests are failing, so run them without + // iframe for now. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); } ); test.afterAll( async ( { requestUtils } ) => { @@ -39,7 +47,7 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { name: 'core/paragraph', attributes: { content: 'Dummy text' }, } ); - const paragraphBlock = page.locator( + const paragraphBlock = editor.canvas.locator( '[data-type="core/paragraph"] >> text=Dummy text' ); @@ -116,7 +124,7 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { const beforeContent = await editor.getEditedPostContent(); - const paragraphBlock = page.locator( + const paragraphBlock = editor.canvas.locator( '[data-type="core/paragraph"] >> text=Dummy text' ); @@ -176,7 +184,7 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { attributes: { content: 'Dummy text' }, } ); - const paragraphBlock = page.locator( + const paragraphBlock = editor.canvas.locator( '[data-type="core/paragraph"] >> text=Dummy text' ); @@ -244,7 +252,7 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { const beforeContent = await editor.getEditedPostContent(); - const paragraphBlock = page.locator( + const paragraphBlock = editor.canvas.locator( '[data-type="core/paragraph"] >> text=Dummy text' ); diff --git a/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js b/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js index e4857f84d46c36..c8ca539bda4150 100644 --- a/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js +++ b/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js @@ -12,9 +12,9 @@ test.describe( 'Keep styles on block transforms', () => { page, editor, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); - await page.keyboard.type( '## Heading' ); await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '## Heading' ); await page.click( 'role=button[name="Color Text styles"i]' ); await page.click( 'role=button[name="Color: Luminous vivid orange"i]' ); @@ -37,8 +37,15 @@ test.describe( 'Keep styles on block transforms', () => { pageUtils, editor, } ) => { - // Create a paragraph block with some content. - await page.click( 'role=button[name="Add default block"i]' ); + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Line 1 to be made large' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'Line 2 to be made large' ); @@ -70,8 +77,8 @@ test.describe( 'Keep styles on block transforms', () => { page, editor, } ) => { - // Create a paragraph block with some content. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.openDocumentSettingsSidebar(); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Line 1 to be made large' ); await page.click( 'role=radio[name="Large"i]' ); await editor.showBlockToolbar(); diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index b7021752ea8cfb..304007f4d0d252 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -4,6 +4,12 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'List View', () => { + test.use( { + listViewUtils: async ( { page, pageUtils, editor }, use ) => { + await use( new ListViewUtils( { page, pageUtils, editor } ) ); + }, + } ); + test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); } ); @@ -115,146 +121,6 @@ test.describe( 'List View', () => { await expect( listView.getByRole( 'row' ) ).toHaveCount( 2 ); } ); - // Check for regression of https://github.com/WordPress/gutenberg/issues/39026. - test( 'selects the previous block after removing the selected one', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - await editor.insertBlock( { name: 'core/paragraph' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Paragraph', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Remove the Paragraph block via its options menu in List View. - await listView - .getByRole( 'button', { name: 'Options for Paragraph' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete/i } ).click(); - - // Heading block should be selected as previous block. - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ) - ).toBeFocused(); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/39026. - test( 'selects the next block after removing the very first block', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - await editor.insertBlock( { name: 'core/paragraph' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Paragraph', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Select the image block in List View. - await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); - await expect( - listView.getByRole( 'link', { - name: 'Image', - } ) - ).toBeFocused(); - await page.keyboard.press( 'Enter' ); - - // Remove the Image block via its options menu in List View. - await listView - .getByRole( 'button', { name: 'Options for Image' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete/i } ).click(); - - // Heading block should be selected as previous block. - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ) - ).toBeFocused(); - } ); - - /** - * When all the blocks gets removed from the editor, it inserts a default - * paragraph block; make sure that paragraph block gets selected after - * removing blocks from ListView. - */ - test( 'selects the default paragraph block after removing all blocks', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Heading', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Select the Image block as well. - await pageUtils.pressKeys( 'shift+ArrowUp' ); - await expect( - listView.getByRole( 'gridcell', { - name: 'Image', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Remove both blocks. - await listView - .getByRole( 'button', { name: 'Options for Image' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete blocks/i } ).click(); - - // Newly created paragraph block should be selected. - await expect( - editor.canvas.getByRole( 'document', { name: /Empty block/i } ) - ).toBeFocused(); - } ); - test( 'expands nested list items', async ( { editor, page, @@ -395,6 +261,13 @@ test.describe( 'List View', () => { page, pageUtils, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.insertBlock( { name: 'core/image' } ); await editor.insertBlock( { name: 'core/paragraph', @@ -465,13 +338,12 @@ test.describe( 'List View', () => { // Focus the list view close button and make sure the shortcut will // close the list view. This is to catch a bug where elements could be - // out of range of the sidebar region. Must shift+tab 3 times to reach - // close button before tabs. - await pageUtils.pressKeys( 'shift+Tab' ); + // out of range of the sidebar region. Must shift+tab 2 times to reach + // close button before tab panel. await pageUtils.pressKeys( 'shift+Tab' ); await pageUtils.pressKeys( 'shift+Tab' ); await expect( - editor.canvas + page .getByRole( 'region', { name: 'Document Overview' } ) .getByRole( 'button', { name: 'Close', @@ -488,7 +360,8 @@ test.describe( 'List View', () => { // Focus the outline tab and select it. This test ensures the outline // tab receives similar focus events based on the shortcut. await pageUtils.pressKeys( 'shift+Tab' ); - const outlineButton = editor.canvas.getByRole( 'button', { + await page.keyboard.press( 'ArrowRight' ); + const outlineButton = page.getByRole( 'tab', { name: 'Outline', } ); await expect( outlineButton ).toBeFocused(); @@ -557,4 +430,441 @@ test.describe( 'List View', () => { } ) ).toBeFocused(); } ); + + test( 'should duplicate and delete blocks using keyboard', async ( { + editor, + page, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { + name: 'core/group', + innerBlocks: [ { name: 'core/pullquote' } ], + } ); + await editor.insertBlock( { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { name: 'core/heading' }, + { name: 'core/paragraph' }, + ], + }, + { + name: 'core/column', + innerBlocks: [ { name: 'core/verse' } ], + }, + ], + } ); + await editor.insertBlock( { name: 'core/file' } ); + + // Open List View. + const listView = await listViewUtils.openListView(); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'The last inserted block should be selected and focused.' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns' }, + { name: 'core/file', selected: true, focused: true }, + ] ); + + await pageUtils.pressKeys( 'primaryShift+d' ); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Duplicating a block should retain selection on existing block, move focus to duplicated block.' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns' }, + { name: 'core/file', selected: true }, + { name: 'core/file', focused: true }, + ] ); + + // Move focus to the first file block, and then delete it. + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'Delete' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting a block should move focus and selection to the previous block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns', selected: true, focused: true }, + { name: 'core/file' }, + ] ); + + // Expand the current column. + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Move focus but do not select the second column' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + selected: true, + innerBlocks: [ + { name: 'core/column' }, + { name: 'core/column', focused: true }, + ], + }, + { name: 'core/file' }, + ] ); + + await page.keyboard.press( 'Delete' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting a inner block moves focus to the previous inner block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + selected: true, + innerBlocks: [ + { + name: 'core/column', + selected: false, + focused: true, + }, + ], + }, + { name: 'core/file' }, + ] ); + + // Expand the current column. + await page.keyboard.press( 'ArrowRight' ); + // Move focus and select the Heading block. + await listView + .getByRole( 'gridcell', { name: 'Heading', exact: true } ) + .dblclick(); + // Select both inner blocks in the column. + await page.keyboard.press( 'Shift+ArrowDown' ); + + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting multiple blocks moves focus to the parent block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + selected: true, + focused: true, + innerBlocks: [], + }, + ], + }, + { name: 'core/file' }, + ] ); + + // Move focus and select the first block. + await listView + .getByRole( 'gridcell', { name: 'Group', exact: true } ) + .dblclick(); + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting the first block moves focus to the second block' + ) + .toMatchObject( [ + { + name: 'core/columns', + selected: true, + focused: true, + }, + { name: 'core/file' }, + ] ); + + // Delete remaining blocks. + // Keyboard shortcut should also work. + await pageUtils.pressKeys( 'access+z' ); + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting the only blocks left will create a default block and focus/select it' + ) + .toMatchObject( [ + { + name: 'core/paragraph', + selected: true, + focused: true, + }, + ] ); + + await editor.insertBlock( { name: 'core/heading' } ); + await page.evaluate( () => + window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock() + ); + await listView + .getByRole( 'gridcell', { name: 'Paragraph' } ) + .getByRole( 'link' ) + .focus(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Block selection is cleared and focus is on the paragraph block' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: false, focused: true }, + { name: 'core/heading', selected: false }, + ] ); + + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting blocks without existing selection will not select blocks' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false, focused: true }, + ] ); + + // Insert a block that is locked and cannot be removed. + await editor.insertBlock( { + name: 'core/file', + attributes: { lock: { move: false, remove: true } }, + } ); + // Click on the Heading block to select it. + await listView + .getByRole( 'gridcell', { name: 'Heading', exact: true } ) + .click(); + await listView + .getByRole( 'gridcell', { name: 'File' } ) + .getByRole( 'link' ) + .focus(); + for ( const keys of [ 'Delete', 'Backspace', 'access+z' ] ) { + await pageUtils.pressKeys( keys ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Trying to delete locked blocks should not do anything' + ) + .toMatchObject( [ + { name: 'core/heading', selected: true, focused: false }, + { name: 'core/file', selected: false, focused: true }, + ] ); + } + } ); + + test( 'block settings dropdown menu', async ( { + editor, + page, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { name: 'core/heading' } ); + await editor.insertBlock( { name: 'core/file' } ); + + // Open List View. + const listView = await listViewUtils.openListView(); + + await listView + .getByRole( 'button', { name: 'Options for Heading' } ) + .click(); + + await page + .getByRole( 'menu', { name: 'Options for Heading' } ) + .getByRole( 'menuitem', { name: 'Duplicate' } ) + .click(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should duplicate a block and move focus' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false }, + { name: 'core/heading', selected: false, focused: true }, + { name: 'core/file', selected: true }, + ] ); + + await page.keyboard.press( 'Shift+ArrowUp' ); + await listView + .getByRole( 'button', { name: 'Options for Heading' } ) + .first() + .click(); + await page + .getByRole( 'menu', { name: 'Options for Heading' } ) + .getByRole( 'menuitem', { name: 'Delete blocks' } ) + .click(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should delete multiple selected blocks using the dropdown menu' + ) + .toMatchObject( [ + { name: 'core/file', selected: true, focused: true }, + ] ); + + await page.keyboard.press( 'ArrowRight' ); + const optionsForFileToggle = listView + .getByRole( 'row' ) + .filter( { + has: page.getByRole( 'gridcell', { name: 'File' } ), + } ) + .getByRole( 'button', { name: 'Options for File' } ); + const optionsForFileMenu = page.getByRole( 'menu', { + name: 'Options for File', + } ); + await expect( + optionsForFileToggle, + 'Pressing arrow right should move focus to the menu dropdown toggle button' + ).toBeFocused(); + + await page.keyboard.press( 'Enter' ); + await expect( + optionsForFileMenu, + 'Pressing Enter should open the menu dropdown' + ).toBeVisible(); + + await page.keyboard.press( 'Escape' ); + await expect( + optionsForFileMenu, + 'Pressing Escape should close the menu dropdown' + ).toBeHidden(); + await expect( + optionsForFileToggle, + 'Should move focus back to the toggle button' + ).toBeFocused(); + + await page.keyboard.press( 'Space' ); + await expect( + optionsForFileMenu, + 'Pressing Space should also open the menu dropdown' + ).toBeVisible(); + + await pageUtils.pressKeys( 'primaryAlt+t' ); // Keyboard shortcut for Insert before. + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Pressing keyboard shortcut should also work when the menu is opened and focused' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: true, focused: false }, + { name: 'core/file', selected: false, focused: false }, + ] ); + await expect( + optionsForFileMenu, + 'The menu should be closed after pressing keyboard shortcut' + ).toBeHidden(); + + await optionsForFileToggle.click(); + await pageUtils.pressKeys( 'access+z' ); // Keyboard shortcut for Delete. + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting blocks should move focus and selection' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: true, focused: true }, + ] ); + + // Insert a block that is locked and cannot be removed. + await editor.insertBlock( { + name: 'core/file', + attributes: { lock: { move: false, remove: true } }, + } ); + await optionsForFileToggle.click(); + await expect( + optionsForFileMenu.getByRole( 'menuitem', { name: 'Delete' } ), + 'The delete menu item should be hidden for locked blocks' + ).toBeHidden(); + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Pressing keyboard shortcut should not delete locked blocks either' + ) + .toMatchObject( [ + { name: 'core/paragraph' }, + { name: 'core/file', selected: true }, + ] ); + await expect( + optionsForFileMenu, + 'The dropdown menu should also be visible' + ).toBeVisible(); + } ); } ); + +/** @typedef {import('@playwright/test').Locator} Locator */ +class ListViewUtils { + #page; + #pageUtils; + #editor; + + constructor( { page, pageUtils, editor } ) { + this.#page = page; + this.#pageUtils = pageUtils; + this.#editor = editor; + + /** @type {Locator} */ + this.listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + } + + /** + * @return {Promise<Locator>} The list view locator. + */ + openListView = async () => { + await this.#pageUtils.pressKeys( 'access+o' ); + return this.listView; + }; + + getBlocksWithA11yAttributes = async () => { + const selectedRows = await this.listView + .getByRole( 'row' ) + .filter( { + has: this.#page.getByRole( 'gridcell', { selected: true } ), + } ) + .all(); + const selectedClientIds = await Promise.all( + selectedRows.map( ( row ) => row.getAttribute( 'data-block' ) ) + ); + const focusedRows = await this.listView + .getByRole( 'row' ) + .filter( { has: this.#page.locator( ':focus' ) } ) + .all(); + const focusedClientId = + focusedRows.length > 0 + ? await focusedRows[ focusedRows.length - 1 ].getAttribute( + 'data-block' + ) + : null; + // Don't use the util to get the unmodified default block when it's empty. + const blocks = await this.#page.evaluate( () => + window.wp.data.select( 'core/block-editor' ).getBlocks() + ); + function recursivelyApplyAttributes( _blocks ) { + return _blocks.map( ( block ) => ( { + name: block.name, + selected: selectedClientIds.includes( block.clientId ), + focused: block.clientId === focusedClientId, + innerBlocks: recursivelyApplyAttributes( block.innerBlocks ), + } ) ); + } + return recursivelyApplyAttributes( blocks ); + }; +} diff --git a/test/e2e/specs/editor/various/manage-reusable-blocks.spec.js b/test/e2e/specs/editor/various/manage-reusable-blocks.spec.js index 64d09dc39af721..bb390d2b39a8e4 100644 --- a/test/e2e/specs/editor/various/manage-reusable-blocks.spec.js +++ b/test/e2e/specs/editor/various/manage-reusable-blocks.spec.js @@ -35,7 +35,7 @@ test.describe( 'Managing reusable blocks', () => { // Wait for the success notice. await expect( - page.locator( 'text=Reusable block imported successfully!' ) + page.locator( 'text=Pattern imported successfully!' ) ).toBeVisible(); // Refresh the page. diff --git a/test/e2e/specs/editor/various/mentions.spec.js b/test/e2e/specs/editor/various/mentions.spec.js index b7e75e046c471a..061b8d67a0801f 100644 --- a/test/e2e/specs/editor/various/mentions.spec.js +++ b/test/e2e/specs/editor/various/mentions.spec.js @@ -23,7 +23,7 @@ test.describe( 'autocomplete mentions', () => { } ); test( 'should insert mention', async ( { page, editor } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'I am @ad' ); await expect( page.locator( 'role=listbox >> role=option[name=/admin/i]' ) @@ -42,7 +42,7 @@ test.describe( 'autocomplete mentions', () => { editor, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'Stuck in the middle with you' ); await pageUtils.pressKeys( 'ArrowLeft', { times: 'you'.length } ); await page.keyboard.type( '@j' ); @@ -62,7 +62,7 @@ test.describe( 'autocomplete mentions', () => { page, editor, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'I am @j' ); await expect( page.locator( 'role=listbox >> role=option[name=/testuser/i]' ) diff --git a/test/e2e/specs/editor/various/multi-block-selection.spec.js b/test/e2e/specs/editor/various/multi-block-selection.spec.js index d72ab2c6bf7dbf..3f2c0e38d7b954 100644 --- a/test/e2e/specs/editor/various/multi-block-selection.spec.js +++ b/test/e2e/specs/editor/various/multi-block-selection.spec.js @@ -247,6 +247,13 @@ test.describe( 'Multi-block selection', () => { editor, multiBlockSelectionUtils, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.canvas .getByRole( 'button', { name: 'Add default block' } ) .click(); @@ -292,6 +299,13 @@ test.describe( 'Multi-block selection', () => { editor, pageUtils, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'test' }, @@ -304,6 +318,13 @@ test.describe( 'Multi-block selection', () => { .filter( { hasText: 'Draft saved' } ) ).toBeVisible(); await page.reload(); + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.canvas .getByRole( 'document', { name: 'Paragraph block' } ) @@ -546,34 +567,59 @@ test.describe( 'Multi-block selection', () => { ] ); } ); + // This test MUST fail when this line is removed: + // https://github.com/WordPress/gutenberg/blob/eb2bb1d3456ea98db74b4518e3394ed6aed9e79f/packages/block-editor/src/components/writing-flow/use-drag-selection.js#L68 test( 'should return original focus after failed multi selection attempt', async ( { page, editor, } ) => { - await editor.insertBlock( { - name: 'core/paragraph', - attributes: { content: '1' }, - } ); - await editor.insertBlock( { - name: 'core/paragraph', - attributes: { content: '2' }, - } ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '12' ); + await page.keyboard.press( 'ArrowLeft' ); - const [ paragraph1, paragraph2 ] = await editor.canvas - .getByRole( 'document', { name: 'Paragraph block' } ) - .all(); - const { height } = await paragraph2.boundingBox(); + const [ coord1, coord2 ] = await editor.canvas.evaluate( () => { + const selection = window.getSelection(); - // Move caret to the start of the second block. - await paragraph2.click( { position: { x: 0, y: height / 2 } } ); - await page.mouse.down(); - await paragraph1.hover( { - position: { x: -5, y: height / 2 }, - // Use force since it's outside the bounding box of the element. - force: true, + if ( ! selection.rangeCount ) { + return; + } + + const range = selection.getRangeAt( 0 ); + const rect1 = range.getClientRects()[ 0 ]; + const element = document.querySelector( + '[data-type="core/paragraph"]' + ); + const rect2 = element.getBoundingClientRect(); + const iframeOffset = window.frameElement.getBoundingClientRect(); + + return [ + { + x: iframeOffset.x + rect1.x, + y: iframeOffset.y + rect1.y + rect1.height / 2, + }, + { + // Move a bit outside the paragraph. + x: iframeOffset.x + rect2.x - 5, + y: iframeOffset.y + rect2.y + rect2.height / 2, + }, + ]; } ); + + await page.mouse.click( coord1.x, coord1.y ); + await page.mouse.down(); + await page.mouse.move( coord2.x, coord2.y, { steps: 10 } ); + // Simulate moving once in and out of the paragraph. + // Fixes https://github.com/WordPress/gutenberg/issues/48747. + await page.mouse.move( coord1.x, coord1.y, { steps: 10 } ); + await page.mouse.move( coord2.x, coord2.y, { steps: 10 } ); await page.mouse.up(); + // Wait for: + // https://github.com/WordPress/gutenberg/blob/eb2bb1d3456ea98db74b4518e3394ed6aed9e79f/packages/block-editor/src/components/writing-flow/use-drag-selection.js#L47 + await page.evaluate( + () => new Promise( window.requestAnimationFrame ) + ); + // Only "1" should be deleted. await page.keyboard.press( 'Backspace' ); @@ -867,7 +913,6 @@ test.describe( 'Multi-block selection', () => { } ); test( 'should select title if the cursor is on title', async ( { - page, editor, pageUtils, multiBlockSelectionUtils, @@ -890,7 +935,7 @@ test.describe( 'Multi-block selection', () => { .toEqual( [] ); await expect .poll( () => - page.evaluate( () => window.getSelection().toString() ) + editor.canvas.evaluate( () => window.getSelection().toString() ) ) .toBe( 'Post title' ); } ); @@ -1093,13 +1138,9 @@ test.describe( 'Multi-block selection', () => { name: 'core/paragraph', attributes: { content: '1' }, }, - { - name: 'core/paragraph', - attributes: { content: '|' }, - }, { name: 'core/heading', - attributes: { level: 2, content: '2' }, + attributes: { level: 2, content: '|2' }, }, ] ); } ); @@ -1138,10 +1179,17 @@ test.describe( 'Multi-block selection', () => { ] ); } ); - test( 'should partially select with shift + click', async ( { + test( 'should partially select with shift + click (@webkit)', async ( { page, editor, } ) => { + // To do: run with iframe. + await page.evaluate( () => { + window.wp.blocks.registerBlockType( 'test/v2', { + apiVersion: '2', + title: 'test', + } ); + } ); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: '<strong>1</strong>[' }, @@ -1161,11 +1209,15 @@ test.describe( 'Multi-block selection', () => { .getByRole( 'region', { name: 'Editor content' } ) .getByText( '1', { exact: true } ); const strongBox = await strongText.boundingBox(); - await strongText.click( { - // Ensure clicking on the right half of the element. - position: { x: strongBox.width, y: strongBox.height / 2 }, - modifiers: [ 'Shift' ], - } ); + // Focus and move the caret to the end. + await editor.canvas + .getByRole( 'document', { name: 'Paragraph block' } ) + .filter( { hasText: '1[' } ) + .click( { + // Ensure clicking on the right half of the element. + position: { x: strongBox.width - 1, y: strongBox.height / 2 }, + modifiers: [ 'Shift' ], + } ); await page.keyboard.press( 'Backspace' ); // Ensure selection is in the correct place. diff --git a/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js new file mode 100644 index 00000000000000..abdb1800d150a2 --- /dev/null +++ b/test/e2e/specs/editor/various/navigable-toolbar.spec.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Block Toolbar', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.describe( 'Contextual Toolbar', () => { + test( 'should not scroll page', async ( { page, pageUtils } ) => { + while ( + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + const scrollable = + window.wp.dom.getScrollContainer( activeElement ); + return ! scrollable || scrollable.scrollTop === 0; + } ) + ) { + await page.keyboard.press( 'Enter' ); + } + + await page.keyboard.type( 'a' ); + + const scrollTopBefore = await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + window.scrollContainer = + window.wp.dom.getScrollContainer( activeElement ); + return window.scrollContainer.scrollTop; + } ); + + await pageUtils.pressKeys( 'alt+F10' ); + await expect( + page + .getByRole( 'toolbar', { name: 'Block Tools' } ) + .getByRole( 'button', { name: 'Paragraph' } ) + ).toBeFocused(); + + const scrollTopAfter = await page.evaluate( () => { + return window.scrollContainer.scrollTop; + } ); + expect( scrollTopBefore ).toBe( scrollTopAfter ); + } ); + } ); + + test( 'should focus with Shift+Tab', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'a' ); + await pageUtils.pressKeys( 'shift+Tab' ); + await expect( + page + .getByRole( 'toolbar', { name: 'Block Tools' } ) + .getByRole( 'button', { name: 'Paragraph' } ) + ).toBeFocused(); + } ); +} ); diff --git a/test/e2e/specs/editor/various/new-post-default-content.spec.js b/test/e2e/specs/editor/various/new-post-default-content.spec.js index 82c8e3a948f31b..db9e3c38dc2962 100644 --- a/test/e2e/specs/editor/various/new-post-default-content.spec.js +++ b/test/e2e/specs/editor/various/new-post-default-content.spec.js @@ -27,7 +27,7 @@ test.describe( 'new editor filtered state', () => { // Assert they match what the plugin set. await expect( - page.locator( 'role=textbox[name="Add title"i]' ) + editor.canvas.locator( 'role=textbox[name="Add title"i]' ) ).toHaveText( 'My default title' ); await expect .poll( editor.getEditedPostContent ) diff --git a/test/e2e/specs/editor/various/new-post.spec.js b/test/e2e/specs/editor/various/new-post.spec.js index e58e8ed94ffc56..4b192693c07b07 100644 --- a/test/e2e/specs/editor/various/new-post.spec.js +++ b/test/e2e/specs/editor/various/new-post.spec.js @@ -26,7 +26,9 @@ test.describe( 'new editor state', () => { await expect( page ).toHaveURL( /post-new.php/ ); // Should display the blank title. - const title = page.locator( 'role=textbox[name="Add title"i]' ); + const title = editor.canvas.locator( + 'role=textbox[name="Add title"i]' + ); await expect( title ).toBeEditable(); await expect( title ).toHaveText( '' ); @@ -55,23 +57,24 @@ test.describe( 'new editor state', () => { test( 'should focus the title if the title is empty', async ( { admin, - page, + editor, } ) => { await admin.createNewPost(); await expect( - page.locator( 'role=textbox[name="Add title"i]' ) + editor.canvas.locator( 'role=textbox[name="Add title"i]' ) ).toBeFocused(); } ); test( 'should not focus the title if the title exists', async ( { admin, page, + editor, } ) => { await admin.createNewPost(); // Enter a title for this post. - await page.type( + await editor.canvas.type( 'role=textbox[name="Add title"i]', 'Here is the title' ); diff --git a/test/e2e/specs/editor/various/post-editor-template-mode.spec.js b/test/e2e/specs/editor/various/post-editor-template-mode.spec.js index c7aed497dc25cc..a8e4d04df378e1 100644 --- a/test/e2e/specs/editor/various/post-editor-template-mode.spec.js +++ b/test/e2e/specs/editor/various/post-editor-template-mode.spec.js @@ -101,188 +101,6 @@ test.describe( 'Post Editor Template mode', () => { ) ).toBeVisible(); } ); - - test( 'Allow editing the title of a new custom template', async ( { - page, - postEditorTemplateMode, - } ) => { - async function editTemplateTitle( newTitle ) { - await page - .getByRole( 'button', { name: 'Template Options' } ) - .click(); - - await page - .getByRole( 'textbox', { name: 'Title' } ) - .fill( newTitle ); - - const editorContent = page.getByLabel( 'Editor Content' ); - await editorContent.click(); - } - - await postEditorTemplateMode.createPostAndSaveDraft(); - await postEditorTemplateMode.createNewTemplate( 'Foobar' ); - await editTemplateTitle( 'Barfoo' ); - - await expect( - page.getByRole( 'button', { name: 'Template Options' } ) - ).toHaveText( 'Barfoo' ); - } ); - - test.describe( 'Delete Post Template Confirmation Dialog', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test.beforeEach( async ( { postEditorTemplateMode } ) => { - await postEditorTemplateMode.createPostAndSaveDraft(); - } ); - - [ 'large', 'small' ].forEach( ( viewport ) => { - test( `should retain template if deletion is canceled when the viewport is ${ viewport }`, async ( { - editor, - page, - pageUtils, - postEditorTemplateMode, - } ) => { - await pageUtils.setBrowserViewport( viewport ); - - await postEditorTemplateMode.disableTemplateWelcomeGuide(); - - const templateTitle = `${ viewport } Viewport Deletion Test`; - - await postEditorTemplateMode.createNewTemplate( templateTitle ); - - // Close the settings in small viewport. - if ( viewport === 'small' ) { - await page.click( 'role=button[name="Close settings"i]' ); - } - - // Edit the template. - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( - 'Just a random paragraph added to the template' - ); - - await postEditorTemplateMode.saveTemplateWithoutPublishing(); - - // Test deletion dialog. - { - const templateDropdown = - postEditorTemplateMode.editorTopBar.locator( - 'role=button[name="Template Options"i]' - ); - await templateDropdown.click(); - await page.click( - 'role=menuitem[name="Delete template"i]' - ); - - const confirmDeletionDialog = page.locator( 'role=dialog' ); - await expect( confirmDeletionDialog ).toBeFocused(); - await expect( - confirmDeletionDialog.locator( - `text=Are you sure you want to delete the ${ templateTitle } template? It may be used by other pages or posts.` - ) - ).toBeVisible(); - - await confirmDeletionDialog - .locator( 'role=button[name="Cancel"i]' ) - .click(); - } - - // Exit template mode. - await page.click( 'role=button[name="Back"i]' ); - - await editor.openDocumentSettingsSidebar(); - - // Move focus to the "Post" panel in the editor sidebar. - const postPanel = - postEditorTemplateMode.editorSettingsSidebar.locator( - 'role=button[name="Post"i]' - ); - await postPanel.click(); - - await postEditorTemplateMode.openTemplatePopover(); - - const templateSelect = page.locator( - 'role=combobox[name="Template"i]' - ); - await expect( templateSelect ).toHaveValue( - `${ viewport }-viewport-deletion-test` - ); - } ); - - test( `should delete template if deletion is confirmed when the viewport is ${ viewport }`, async ( { - editor, - page, - pageUtils, - postEditorTemplateMode, - } ) => { - const templateTitle = `${ viewport } Viewport Deletion Test`; - - await pageUtils.setBrowserViewport( viewport ); - - await postEditorTemplateMode.createNewTemplate( templateTitle ); - - // Close the settings in small viewport. - if ( viewport === 'small' ) { - await page.click( 'role=button[name="Close settings"i]' ); - } - - // Edit the template. - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( - 'Just a random paragraph added to the template' - ); - - await postEditorTemplateMode.saveTemplateWithoutPublishing(); - - { - const templateDropdown = - postEditorTemplateMode.editorTopBar.locator( - 'role=button[name="Template Options"i]' - ); - await templateDropdown.click(); - await page.click( - 'role=menuitem[name="Delete template"i]' - ); - - const confirmDeletionDialog = page.locator( 'role=dialog' ); - await expect( confirmDeletionDialog ).toBeFocused(); - await expect( - confirmDeletionDialog.locator( - `text=Are you sure you want to delete the ${ templateTitle } template? It may be used by other pages or posts.` - ) - ).toBeVisible(); - - await confirmDeletionDialog - .locator( 'role=button[name="OK"i]' ) - .click(); - } - - // Saving isn't technically necessary, but for themes without any specified templates, - // the removal of the Templates dropdown is delayed. A save and reload allows for this - // delay and prevents flakiness - { - await page.click( 'role=button[name="Save draft"i]' ); - await page.waitForSelector( - 'role=button[name="Dismiss this notice"] >> text=Draft saved' - ); - await page.reload(); - } - - const templateOptions = - postEditorTemplateMode.editorSettingsSidebar.locator( - 'role=combobox[name="Template:"i] >> role=menuitem' - ); - const availableTemplates = - await templateOptions.allTextContents(); - - expect( availableTemplates ).not.toContain( - `${ viewport } Viewport Deletion Test` - ); - } ); - } ); - } ); } ); class PostEditorTemplateMode { @@ -331,11 +149,14 @@ class PostEditorTemplateMode { 'role=button[name="Dismiss this notice"] >> text=Editing template. Changes made here affect all posts and pages that use the template.' ); - await expect( this.editorTopBar ).toHaveText( /Just an FSE Post/ ); + await expect( + this.editorTopBar.getByRole( 'heading[level=1]' ) + ).toHaveText( 'Editing template: Single Entries' ); } async createPostAndSaveDraft() { await this.admin.createNewPost(); + await this.editor.canvas.waitForLoadState(); // Create a random post. await this.page.keyboard.type( 'Just an FSE Post' ); await this.page.keyboard.press( 'Enter' ); diff --git a/test/e2e/specs/editor/various/post-visibility.spec.js b/test/e2e/specs/editor/various/post-visibility.spec.js index 611e260e17de54..3f83221c27b819 100644 --- a/test/e2e/specs/editor/various/post-visibility.spec.js +++ b/test/e2e/specs/editor/various/post-visibility.spec.js @@ -78,7 +78,7 @@ test.describe( 'Post visibility', () => { await admin.createNewPost(); // Enter a title for this post. - await page.type( 'role=textbox[name="Add title"i]', 'Title' ); + await editor.canvas.type( 'role=textbox[name="Add title"i]', 'Title' ); await editor.openDocumentSettingsSidebar(); diff --git a/test/e2e/specs/editor/various/preview.spec.js b/test/e2e/specs/editor/various/preview.spec.js index d71d9ada9b5103..cc97d5741c4c11 100644 --- a/test/e2e/specs/editor/various/preview.spec.js +++ b/test/e2e/specs/editor/various/preview.spec.js @@ -27,7 +27,7 @@ test.describe( 'Preview', () => { editorPage.locator( 'role=button[name="Preview"i]' ) ).toBeDisabled(); - await editorPage.type( + await editor.canvas.type( 'role=textbox[name="Add title"i]', 'Hello World' ); @@ -48,7 +48,7 @@ test.describe( 'Preview', () => { // Return to editor to change title. await editorPage.bringToFront(); - await editorPage.type( 'role=textbox[name="Add title"i]', '!' ); + await editor.canvas.type( 'role=textbox[name="Add title"i]', '!' ); await previewUtils.waitForPreviewNavigation( previewPage ); // Title in preview should match updated input. @@ -70,7 +70,7 @@ test.describe( 'Preview', () => { // Return to editor to change title. await editorPage.bringToFront(); - await editorPage.fill( + await editor.canvas.fill( 'role=textbox[name="Add title"i]', 'Hello World! And more.' ); @@ -104,10 +104,12 @@ test.describe( 'Preview', () => { page, previewUtils, } ) => { + await editor.openDocumentSettingsSidebar(); + const editorPage = page; // Type aaaaa in the title field. - await editorPage.type( 'role=textbox[name="Add title"]', 'aaaaa' ); + await editor.canvas.type( 'role=textbox[name="Add title"]', 'aaaaa' ); await editorPage.keyboard.press( 'Tab' ); // Save the post as a draft. @@ -127,7 +129,7 @@ test.describe( 'Preview', () => { await editorPage.bringToFront(); // Append bbbbb to the title, and tab away from the title so blur event is triggered. - await editorPage.fill( + await editor.canvas.fill( 'role=textbox[name="Add title"i]', 'aaaaabbbbb' ); @@ -155,7 +157,7 @@ test.describe( 'Preview', () => { const editorPage = page; // Type Lorem in the title field. - await editorPage.type( 'role=textbox[name="Add title"i]', 'Lorem' ); + await editor.canvas.type( 'role=textbox[name="Add title"i]', 'Lorem' ); // Open the preview page. const previewPage = await editor.openPreviewPage( editorPage ); @@ -172,7 +174,7 @@ test.describe( 'Preview', () => { await page.click( 'role=button[name="Close panel"i]' ); // Change the title and preview again. - await editorPage.type( 'role=textbox[name="Add title"i]', ' Ipsum' ); + await editor.canvas.type( 'role=textbox[name="Add title"i]', ' Ipsum' ); await previewUtils.waitForPreviewNavigation( previewPage ); // Title in preview should match updated input. @@ -180,7 +182,10 @@ test.describe( 'Preview', () => { // Return to editor and switch to Draft. await editorPage.bringToFront(); - await page.click( 'role=button[name="Switch to draft"i]' ); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Switch to draft' } ) + .click(); // FIXME: The confirmation dialog is not named yet. await page.click( 'role=dialog >> role=button[name="OK"i]' ); @@ -191,7 +196,7 @@ test.describe( 'Preview', () => { ).toBeVisible(); // Change the title. - await editorPage.type( 'role=textbox[name="Add title"i]', ' Draft' ); + await editor.canvas.type( 'role=textbox[name="Add title"i]', ' Draft' ); // Open the preview page. await previewUtils.waitForPreviewNavigation( previewPage ); @@ -222,7 +227,10 @@ test.describe( 'Preview with Custom Fields enabled', () => { const editorPage = page; // Add an initial title and content. - await editorPage.type( 'role=textbox[name="Add title"i]', 'title 1' ); + await editor.canvas.type( + 'role=textbox[name="Add title"i]', + 'title 1' + ); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'content 1' }, @@ -246,8 +254,11 @@ test.describe( 'Preview with Custom Fields enabled', () => { // Return to editor and modify the title and content. await editorPage.bringToFront(); - await editorPage.fill( 'role=textbox[name="Add title"i]', 'title 2' ); - await editorPage.fill( + await editor.canvas.fill( + 'role=textbox[name="Add title"i]', + 'title 2' + ); + await editor.canvas.fill( 'role=document >> text="content 1"', 'content 2' ); diff --git a/test/e2e/specs/editor/various/rtl.spec.js b/test/e2e/specs/editor/various/rtl.spec.js index 899dfd3c87ddec..8475605e339fcb 100644 --- a/test/e2e/specs/editor/various/rtl.spec.js +++ b/test/e2e/specs/editor/various/rtl.spec.js @@ -150,7 +150,7 @@ test.describe( 'RTL', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await pageUtils.pressKeys( 'primary+b' ); await page.keyboard.type( ARABIC_ONE ); await pageUtils.pressKeys( 'primary+b' ); diff --git a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js index 9a97d1a00310ac..9361da403c6d9e 100644 --- a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js +++ b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js @@ -82,6 +82,11 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { await editor.setIsFixedToolbar( true ); } ); + test.afterEach( async ( { editor } ) => { + // Ensure the fixed toolbar option is off + await editor.setIsFixedToolbar( false ); + } ); + test( 'Focuses the correct toolbar in edit mode', async ( { editor, page, @@ -95,11 +100,8 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { await editor.insertBlock( { name: 'core/paragraph' } ); await toolbarUtils.moveToToolbarShortcut(); await expect( - toolbarUtils.blockToolbarShowDocumentButton + toolbarUtils.blockToolbarParagraphButton ).toBeFocused(); - await expect( - toolbarUtils.documentToolbarTooltip - ).not.toBeVisible(); // Test: Focus the block toolbar from paragraph block with content await editor.insertBlock( { name: 'core/paragraph' } ); @@ -108,11 +110,8 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { ); await toolbarUtils.moveToToolbarShortcut(); await expect( - toolbarUtils.blockToolbarShowDocumentButton + toolbarUtils.blockToolbarParagraphButton ).toBeFocused(); - await expect( - toolbarUtils.documentToolbarTooltip - ).not.toBeVisible(); } ); test( 'Focuses the correct toolbar in select mode', async ( { @@ -130,11 +129,8 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { await toolbarUtils.useSelectMode(); await toolbarUtils.moveToToolbarShortcut(); await expect( - toolbarUtils.blockToolbarShowDocumentButton + toolbarUtils.blockToolbarParagraphButton ).toBeFocused(); - await expect( - toolbarUtils.documentToolbarTooltip - ).not.toBeVisible(); // Test: Focus the block toolbar from paragraph in select mode await editor.insertBlock( { name: 'core/paragraph' } ); @@ -144,11 +140,8 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { await toolbarUtils.useSelectMode(); await toolbarUtils.moveToToolbarShortcut(); await expect( - toolbarUtils.blockToolbarShowDocumentButton + toolbarUtils.blockToolbarParagraphButton ).toBeFocused(); - await expect( - toolbarUtils.documentToolbarTooltip - ).not.toBeVisible(); } ); } ); @@ -161,11 +154,6 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { }, } ); - test.beforeEach( async ( { editor } ) => { - // Ensure the fixed toolbar option is off - await editor.setIsFixedToolbar( false ); - } ); - test( 'Focuses the correct toolbar in edit mode', async ( { editor, page, @@ -254,7 +242,7 @@ class ToolbarUtils { exact: true, } ); this.blockToolbarShowDocumentButton = this.page.getByRole( 'button', { - name: 'Show document tools', + name: 'Hide block tools', exact: true, } ); } diff --git a/test/e2e/specs/editor/various/shortcut-help.spec.js b/test/e2e/specs/editor/various/shortcut-help.spec.js new file mode 100644 index 00000000000000..1aaf5e93c975c5 --- /dev/null +++ b/test/e2e/specs/editor/various/shortcut-help.spec.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'keyboard shortcut help modal', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'opens from the options menu, closes with its close button and returns focus', async ( { + page, + } ) => { + await page + .locator( 'role=region[name="Editor top bar"]' ) + .locator( '[aria-label="Options"]' ) + .click(); + const menuItem = page.locator( 'role=menuitem', { + hasText: /^Keyboard shortcuts/i, + } ); + const dialog = page.locator( 'role=dialog[name="Keyboard shortcuts"]' ); + + await menuItem.click(); + await expect( dialog ).toBeVisible(); + + await page.locator( 'role=button[name="Close"]' ).click(); + await expect( dialog ).toBeHidden(); + await expect( menuItem ).toBeFocused(); + } ); + + test( 'toggles open/closed using the keyboard shortcut (access+h)', async ( { + page, + pageUtils, + } ) => { + await pageUtils.pressKeys( 'access+h' ); + const dialog = page.locator( 'role=dialog[name="Keyboard shortcuts"]' ); + await expect( dialog ).toBeVisible(); + + await pageUtils.pressKeys( 'access+h' ); + await expect( dialog ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/editor/various/splitting-merging.spec.js b/test/e2e/specs/editor/various/splitting-merging.spec.js index 08aa33ec095522..1c5e12be8abb11 100644 --- a/test/e2e/specs/editor/various/splitting-merging.spec.js +++ b/test/e2e/specs/editor/various/splitting-merging.spec.js @@ -3,7 +3,7 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -test.describe( 'splitting and merging blocks', () => { +test.describe( 'splitting and merging blocks (@firefox, @webkit)', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); } ); @@ -306,11 +306,11 @@ test.describe( 'splitting and merging blocks', () => { // There is a default block and post title: await expect( - page.locator( 'role=document[name=/Empty block/i]' ) + editor.canvas.locator( 'role=document[name=/Empty block/i]' ) ).toBeVisible(); await expect( - page.locator( 'role=textbox[name="Add title"i]' ) + editor.canvas.locator( 'role=textbox[name="Add title"i]' ) ).toBeVisible(); // But the effective saved content is still empty: @@ -318,7 +318,7 @@ test.describe( 'splitting and merging blocks', () => { // And focus is retained: await expect( - page.locator( 'role=document[name=/Empty block/i]' ) + editor.canvas.locator( 'role=document[name=/Empty block/i]' ) ).toBeFocused(); } ); @@ -334,10 +334,17 @@ test.describe( 'splitting and merging blocks', () => { await pageUtils.pressKeys( 'primary+z' ); // Check the content. - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: '12', + }, + }, + ] ); } ); - test( 'should not split with line break in front', async ( { + test( 'should not split with line break in front (-firefox)', async ( { editor, page, pageUtils, @@ -364,6 +371,52 @@ test.describe( 'splitting and merging blocks', () => { } ); test.describe( 'test restore selection when merge produces more than one block', () => { + const snap1 = [ + { + name: 'core/paragraph', + attributes: { + content: 'hi', + }, + }, + { + name: 'core/paragraph', + attributes: { + content: 'item 1', + }, + }, + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { + content: 'item 2', + }, + }, + ], + }, + ]; + + const snap2 = [ + { + name: 'core/paragraph', + attributes: { + content: 'hi-item 1', + }, + }, + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { + content: 'item 2', + }, + }, + ], + }, + ]; + test( 'on forward delete', async ( { editor, page, pageUtils } ) => { await editor.insertBlock( { name: 'core/paragraph' } ); await page.keyboard.type( 'hi' ); @@ -374,14 +427,14 @@ test.describe( 'splitting and merging blocks', () => { await pageUtils.pressKeys( 'ArrowUp', { times: 3 } ); await page.keyboard.press( 'Delete' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( snap1 ); await page.keyboard.press( 'Delete' ); // Carret should be in the first block and at the proper position. await page.keyboard.type( '-' ); // Check the content. - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( snap2 ); } ); test( 'on backspace', async ( { editor, page, pageUtils } ) => { @@ -395,14 +448,14 @@ test.describe( 'splitting and merging blocks', () => { await pageUtils.pressKeys( 'ArrowLeft', { times: 6 } ); await page.keyboard.press( 'Backspace' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( snap1 ); await page.keyboard.press( 'Backspace' ); // Carret should be in the first block and at the proper position. await page.keyboard.type( '-' ); // Check the content. - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( snap2 ); } ); } ); } ); diff --git a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js index ccb5952a571256..d17cef9215f4ae 100644 --- a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js +++ b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js @@ -16,11 +16,13 @@ test.describe( 'Toolbar roving tabindex', () => { await page.keyboard.type( 'First block' ); } ); - test( 'ensures paragraph block toolbar uses roving tabindex', async ( { + test( 'ensures base block toolbars use roving tabindex', async ( { editor, page, + pageUtils, ToolbarRovingTabindexUtils, } ) => { + // ensures paragraph block toolbar uses roving tabindex await editor.insertBlock( { name: 'core/paragraph' } ); await page.keyboard.type( 'Paragraph' ); await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( @@ -34,13 +36,8 @@ test.describe( 'Toolbar roving tabindex', () => { 'Paragraph block', 'Paragraph' ); - } ); - test( 'ensures heading block toolbar uses roving tabindex', async ( { - editor, - page, - ToolbarRovingTabindexUtils, - } ) => { + // test: ensures heading block toolbar uses roving tabindex await editor.insertBlock( { name: 'core/heading' } ); await page.keyboard.type( 'Heading' ); await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( @@ -52,13 +49,8 @@ test.describe( 'Toolbar roving tabindex', () => { 'Block: Heading', 'Heading' ); - } ); - test( 'ensures list block toolbar uses roving tabindex', async ( { - editor, - page, - ToolbarRovingTabindexUtils, - } ) => { + // ensures list block toolbar uses roving tabindex await editor.insertBlock( { name: 'core/list' } ); await page.keyboard.type( 'List' ); await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( @@ -71,40 +63,19 @@ test.describe( 'Toolbar roving tabindex', () => { 'Block: List', 'List' ); - } ); - test( 'ensures image block toolbar uses roving tabindex', async ( { - editor, - ToolbarRovingTabindexUtils, - } ) => { - await editor.insertBlock( { name: 'core/image' } ); - await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( - 'Block: Image', - 'Image' - ); - await ToolbarRovingTabindexUtils.wrapCurrentBlockWithGroup( 'Image' ); - await ToolbarRovingTabindexUtils.testGroupKeyboardNavigation( - 'Block: Image', - 'Image' - ); - } ); - - test( 'ensures table block toolbar uses roving tabindex', async ( { - editor, - page, - ToolbarRovingTabindexUtils, - pageUtils, - } ) => { + // ensures table block toolbar uses roving tabindex await editor.insertBlock( { name: 'core/table' } ); await page.keyboard.press( 'ArrowLeft' ); await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( 'Block: Table', 'Table' ); + // Move focus to the first toolbar item. await page.keyboard.press( 'Home' ); await ToolbarRovingTabindexUtils.expectLabelToHaveFocus( 'Table' ); - await page.click( `role=button[name="Create Table"i]` ); + await editor.canvas.click( `role=button[name="Create Table"i]` ); await pageUtils.pressKeys( 'Tab' ); await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( 'Body cell text', @@ -115,12 +86,8 @@ test.describe( 'Toolbar roving tabindex', () => { 'Block: Table', 'Table' ); - } ); - test( 'ensures custom html block toolbar uses roving tabindex', async ( { - editor, - ToolbarRovingTabindexUtils, - } ) => { + // ensures custom html block toolbar uses roving tabindex await editor.insertBlock( { name: 'core/html' } ); await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( 'HTML', @@ -133,6 +100,19 @@ test.describe( 'Toolbar roving tabindex', () => { 'Block: Custom HTML', 'Custom HTML' ); + + // ensures image block toolbar uses roving tabindex + // This also tests if shift + tab works as expected to move focus to the toolbar when the preceding block has a form element. + await editor.insertBlock( { name: 'core/image' } ); + await ToolbarRovingTabindexUtils.testBlockToolbarKeyboardNavigation( + 'Block: Image', + 'Image' + ); + await ToolbarRovingTabindexUtils.wrapCurrentBlockWithGroup( 'Image' ); + await ToolbarRovingTabindexUtils.testGroupKeyboardNavigation( + 'Block: Image', + 'Image' + ); } ); test( 'ensures block toolbar remembers the last focused item', async ( { @@ -188,15 +168,19 @@ class ToolbarRovingTabindexUtils { } async expectLabelToHaveFocus( label ) { - let ariaLabel = await this.page.evaluate( () => - document.activeElement.getAttribute( 'aria-label' ) - ); + let ariaLabel = await this.page.evaluate( () => { + const { activeElement } = + document.activeElement.contentDocument ?? document; + return activeElement.getAttribute( 'aria-label' ); + } ); // If the labels don't match, try pressing Up Arrow to focus the block wrapper in non-content editable block. if ( ariaLabel !== label ) { await this.page.keyboard.press( 'ArrowUp' ); - ariaLabel = await this.page.evaluate( () => - document.activeElement.getAttribute( 'aria-label' ) - ); + ariaLabel = await this.page.evaluate( () => { + const { activeElement } = + document.activeElement.contentDocument ?? document; + return activeElement.getAttribute( 'aria-label' ); + } ); } expect( ariaLabel ).toBe( label ); } diff --git a/test/e2e/specs/editor/various/undo.spec.js b/test/e2e/specs/editor/various/undo.spec.js index 29b34ea416ff29..17d365a3406800 100644 --- a/test/e2e/specs/editor/various/undo.spec.js +++ b/test/e2e/specs/editor/various/undo.spec.js @@ -20,7 +20,7 @@ test.describe( 'undo', () => { pageUtils, undoUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'before pause' ); await editor.page.waitForTimeout( 1000 ); await page.keyboard.type( ' after pause' ); @@ -88,7 +88,7 @@ test.describe( 'undo', () => { pageUtils, undoUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'before keyboard ' ); await pageUtils.pressKeys( 'primary+b' ); @@ -159,8 +159,8 @@ test.describe( 'undo', () => { } ); } ); - test( 'should undo bold', async ( { page, pageUtils } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + test( 'should undo bold', async ( { page, pageUtils, editor } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'test' ); await page.click( 'role=button[name="Save draft"i]' ); await expect( @@ -169,12 +169,19 @@ test.describe( 'undo', () => { ) ).toBeVisible(); await page.reload(); - await page.click( '[data-type="core/paragraph"]' ); + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); + await editor.canvas.click( '[data-type="core/paragraph"]' ); await pageUtils.pressKeys( 'primary+a' ); await pageUtils.pressKeys( 'primary+b' ); await pageUtils.pressKeys( 'primary+z' ); - const activeElementLocator = page.locator( ':focus' ); - await expect( activeElementLocator ).toHaveText( 'test' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'test', + }, + }, + ] ); } ); test( 'Should undo/redo to expected level intervals', async ( { @@ -183,7 +190,7 @@ test.describe( 'undo', () => { pageUtils, undoUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); const firstBlock = await editor.getEditedPostContent(); @@ -326,7 +333,7 @@ test.describe( 'undo', () => { // See: https://github.com/WordPress/gutenberg/issues/14950 // Issue is demonstrated from an edited post: create, save, and reload. - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( 'original' ); await page.click( 'role=button[name="Save draft"i]' ); await expect( @@ -335,10 +342,11 @@ test.describe( 'undo', () => { ) ).toBeVisible(); await page.reload(); + await page.waitForSelector( 'iframe[name="editor-canvas"]' ); // Issue is demonstrated by forcing state merges (multiple inputs) on // an existing text after a fresh reload. - await page.click( '[data-type="core/paragraph"] >> nth=0' ); + await editor.canvas.click( '[data-type="core/paragraph"] >> nth=0' ); await page.keyboard.type( 'modified' ); // The issue is demonstrated after the one second delay to trigger the @@ -351,7 +359,9 @@ test.describe( 'undo', () => { // regression present was accurate, it would produce the correct // content. The issue had manifested in the form of what was shown to // the user since the blocks state failed to sync to block editor. - const activeElementLocator = page.locator( ':focus' ); + const activeElementLocator = editor.canvas.locator( + '[data-type="core/paragraph"] >> nth=0' + ); await expect( activeElementLocator ).toHaveText( 'original' ); } ); @@ -360,7 +370,7 @@ test.describe( 'undo', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1' ); await page.click( 'role=button[name="Save draft"i]' ); await expect( @@ -378,7 +388,7 @@ test.describe( 'undo', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1' ); await editor.publishPost(); await pageUtils.pressKeys( 'primary+z' ); @@ -391,7 +401,7 @@ test.describe( 'undo', () => { page, pageUtils, } ) => { - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( '1' ); await page.click( 'role=button[name="Save draft"i]' ); @@ -406,7 +416,7 @@ test.describe( 'undo', () => { await expect( page.locator( 'role=button[name="Undo"]' ) ).toBeDisabled(); - await page.click( '[data-type="core/paragraph"]' ); + await editor.canvas.click( '[data-type="core/paragraph"]' ); await page.keyboard.type( '2' ); @@ -436,7 +446,7 @@ test.describe( 'undo', () => { // block attribute as in the previous action and results in transient edits // and skipping `undo` history steps. const text = 'tonis'; - await page.click( 'role=button[name="Add default block"i]' ); + await editor.canvas.click( 'role=button[name="Add default block"i]' ); await page.keyboard.type( text ); await editor.publishPost(); await pageUtils.pressKeys( 'primary+z' ); @@ -455,6 +465,40 @@ test.describe( 'undo', () => { }, ] ); } ); + + // @see https://github.com/WordPress/gutenberg/issues/12075 + test( 'should be able to undo and redo property cross property changes', async ( { + page, + pageUtils, + editor, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .type( 'a' ); // First step. + await page.keyboard.press( 'Backspace' ); // Second step. + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); // third step. + + // Title should be empty + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( '' ); + + // First undo removes the block. + // Second undo restores the title. + await pageUtils.pressKeys( 'primary+z' ); + await pageUtils.pressKeys( 'primary+z' ); + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'a' ); + + // Redoing the "backspace" should clear the title again. + await pageUtils.pressKeys( 'primaryShift+z' ); + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( '' ); + } ); } ); class UndoUtils { diff --git a/test/e2e/specs/editor/various/writing-flow.spec.js b/test/e2e/specs/editor/various/writing-flow.spec.js index 80e3fb3b126827..44ffb33e7a00cf 100644 --- a/test/e2e/specs/editor/various/writing-flow.spec.js +++ b/test/e2e/specs/editor/various/writing-flow.spec.js @@ -4,12 +4,12 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.use( { - writingFlowUtils: async ( { page }, use ) => { - await use( new WritingFlowUtils( { page } ) ); + writingFlowUtils: async ( { page, editor }, use ) => { + await use( new WritingFlowUtils( { page, editor } ) ); }, } ); -test.describe( 'Writing Flow', () => { +test.describe( 'Writing Flow (@firefox, @webkit)', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); } ); @@ -29,7 +29,7 @@ test.describe( 'Writing Flow', () => { // See: https://github.com/WordPress/gutenberg/issues/18928 await writingFlowUtils.addDemoContent(); - const activeElementLocator = page.locator( ':focus' ); + const activeElementLocator = editor.canvas.locator( ':focus' ); // Arrow up into nested context focuses last text input. await page.keyboard.press( 'ArrowUp' ); @@ -46,7 +46,7 @@ test.describe( 'Writing Flow', () => { .poll( writingFlowUtils.getActiveBlockName ) .toBe( 'core/column' ); await page.keyboard.press( 'ArrowUp' ); - const activeElementBlockType = await page.evaluate( () => + const activeElementBlockType = await editor.canvas.evaluate( () => document.activeElement.getAttribute( 'data-type' ) ); expect( activeElementBlockType ).toBe( 'core/columns' ); @@ -63,7 +63,42 @@ test.describe( 'Writing Flow', () => { await expect( activeElementLocator ).toBeFocused(); await expect( activeElementLocator ).toHaveText( 'First paragraph' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'First paragraph' }, + }, + { + name: 'core/columns', + attributes: {}, + innerBlocks: [ + { + name: 'core/column', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1st col' }, + }, + ], + }, + { + name: 'core/column', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '2nd col' }, + }, + ], + }, + ], + }, + { + name: 'core/paragraph', + attributes: { content: 'Second paragraph' }, + }, + ] ); } ); test( 'Should navigate between inner and root blocks in navigation mode', async ( { @@ -167,7 +202,22 @@ test.describe( 'Writing Flow', () => { await page.keyboard.press( 'ArrowRight' ); await page.keyboard.type( 'Before' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'FirstAfter' }, + }, + { + name: 'core/paragraph', + attributes: { + content: 'Before<strong>InsideSecondInside</strong>After', + }, + }, + { + name: 'core/paragraph', + attributes: { content: 'BeforeThird' }, + }, + ] ); } ); test( 'should navigate around nested inline boundaries', async ( { @@ -317,25 +367,25 @@ test.describe( 'Writing Flow', () => { await editor.insertBlock( { name: 'core/paragraph' } ); await page.keyboard.type( 'abc' ); // Need content to remove placeholder label. await editor.selectBlocks( - page.locator( 'role=document[name="Block: Shortcode"i]' ) + editor.canvas.locator( 'role=document[name="Block: Shortcode"i]' ) ); // Should remain in title upon ArrowRight: await page.keyboard.press( 'ArrowRight' ); await expect( - page.locator( 'role=document[name="Block: Shortcode"i]' ) + editor.canvas.locator( 'role=document[name="Block: Shortcode"i]' ) ).toHaveClass( /is-selected/ ); // Should remain in title upon modifier + ArrowDown: await pageUtils.pressKeys( 'primary+ArrowDown' ); await expect( - page.locator( 'role=document[name="Block: Shortcode"i]' ) + editor.canvas.locator( 'role=document[name="Block: Shortcode"i]' ) ).toHaveClass( /is-selected/ ); // Should navigate to the next block. await page.keyboard.press( 'ArrowDown' ); await expect( - page.locator( 'role=document[name="Paragraph block"i]' ) + editor.canvas.locator( 'role=document[name="Paragraph block"i]' ) ).toHaveClass( /is-selected/ ); } ); @@ -424,7 +474,12 @@ test.describe( 'Writing Flow', () => { await pageUtils.pressKeys( 'Enter', { times: 10 } ); // Check that none of the paragraph blocks have <br> in them. - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( + Array( 11 ).fill( { + name: 'core/paragraph', + attributes: { content: '' }, + } ) + ); } ); test( 'should navigate empty paragraphs', async ( { editor, page } ) => { @@ -438,7 +493,20 @@ test.describe( 'Writing Flow', () => { await page.keyboard.press( 'ArrowRight' ); await page.keyboard.type( '3' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '' }, + }, + { + name: 'core/paragraph', + attributes: { content: '3' }, + }, + ] ); } ); test( 'should navigate contenteditable with padding', async ( { @@ -447,18 +515,27 @@ test.describe( 'Writing Flow', () => { } ) => { await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Enter' ); - await page.evaluate( () => { + await editor.canvas.evaluate( () => { document.activeElement.style.paddingTop = '100px'; } ); await page.keyboard.press( 'ArrowUp' ); await page.keyboard.type( '1' ); - await page.evaluate( () => { + await editor.canvas.evaluate( () => { document.activeElement.style.paddingBottom = '100px'; } ); await page.keyboard.press( 'ArrowDown' ); await page.keyboard.type( '2' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '2' }, + }, + ] ); } ); test( 'should navigate contenteditable with normal line height', async ( { @@ -467,13 +544,22 @@ test.describe( 'Writing Flow', () => { } ) => { await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Enter' ); - await page.evaluate( () => { + await editor.canvas.evaluate( () => { document.activeElement.style.lineHeight = 'normal'; } ); await page.keyboard.press( 'ArrowUp' ); await page.keyboard.type( '1' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '' }, + }, + ] ); } ); test( 'should not prematurely multi-select', async ( { @@ -492,7 +578,16 @@ test.describe( 'Writing Flow', () => { await page.keyboard.up( 'Shift' ); await page.keyboard.press( 'Backspace' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '>' }, + }, + ] ); } ); test( 'should merge paragraphs', async ( { editor, page } ) => { @@ -596,7 +691,7 @@ test.describe( 'Writing Flow', () => { <!-- /wp:paragraph -->` ); } ); - test( 'should preserve horizontal position when navigating vertically between blocks', async ( { + test( 'should preserve horizontal position when navigating vertically between blocks (-webkit)', async ( { editor, page, } ) => { @@ -650,7 +745,7 @@ test.describe( 'Writing Flow', () => { } ) => { await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Enter' ); - await page.evaluate( () => { + await editor.canvas.evaluate( () => { document.activeElement.style.paddingLeft = '100px'; } ); await page.keyboard.press( 'Enter' ); @@ -658,7 +753,20 @@ test.describe( 'Writing Flow', () => { await page.keyboard.press( 'ArrowUp' ); await page.keyboard.type( '1' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '' }, + }, + { + name: 'core/paragraph', + attributes: { content: '' }, + }, + ] ); } ); test( 'should extend selection into paragraph for list with longer last item', async ( { @@ -696,7 +804,7 @@ test.describe( 'Writing Flow', () => { await page.keyboard.type( '2' ); await page.keyboard.press( 'ArrowUp' ); - const paragraphBlock = page + const paragraphBlock = editor.canvas .locator( 'role=document[name="Paragraph block"i]' ) .first(); const paragraphRect = await paragraphBlock.boundingBox(); @@ -761,7 +869,7 @@ test.describe( 'Writing Flow', () => { <figure class="wp-block-image alignwide"><img alt=""/></figure> <!-- /wp:image -->` ); - const paragraphBlock = page.locator( + const paragraphBlock = editor.canvas.locator( 'role=document[name="Paragraph block"i]' ); @@ -784,7 +892,7 @@ test.describe( 'Writing Flow', () => { await page.mouse.click( x, lowerInserterY ); await expect( - page.locator( 'role=document[name="Block: Image"i]' ) + editor.canvas.locator( 'role=document[name="Block: Image"i]' ) ).toHaveClass( /is-selected/ ); } ); @@ -802,7 +910,7 @@ test.describe( 'Writing Flow', () => { // Create the table. await page.keyboard.press( 'Space' ); await expect( - page.locator( 'role=document[name="Block: Table"i]' ) + editor.canvas.locator( 'role=document[name="Block: Table"i]' ) ).toBeVisible(); // Navigate to the second cell. await page.keyboard.press( 'ArrowRight' ); @@ -867,7 +975,7 @@ test.describe( 'Writing Flow', () => { await page.mouse.up(); await expect( - page.locator( 'role=document[name="Paragraph block"i]' ) + editor.canvas.locator( 'role=document[name="Paragraph block"i]' ) ).toHaveClass( /is-selected/ ); } ); @@ -899,14 +1007,15 @@ test.describe( 'Writing Flow', () => { <!-- /wp:paragraph -->` ); } ); - test( 'should move to the start of the first line on ArrowUp', async ( { + test( 'should move to the start of the first line on ArrowUp (-firefox)', async ( { page, + editor, } ) => { await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'a' ); async function getHeight() { - return await page.evaluate( + return await editor.canvas.evaluate( () => document.activeElement.offsetHeight ); } @@ -928,18 +1037,19 @@ test.describe( 'Writing Flow', () => { // Expect the "." to be added at the start of the paragraph await expect( - page.locator( 'role=document[name="Paragraph block"i]' ) + editor.canvas.locator( 'role=document[name="Paragraph block"i]' ) ).toHaveText( /^\.a+$/ ); } ); - test( 'should vertically move the caret from corner to corner', async ( { + test( 'should vertically move the caret from corner to corner (-webkit)', async ( { page, + editor, } ) => { await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'a' ); async function getHeight() { - return await page.evaluate( + return await editor.canvas.evaluate( () => document.activeElement.offsetHeight ); } @@ -961,19 +1071,20 @@ test.describe( 'Writing Flow', () => { // Expect the "." to be added at the start of the paragraph await expect( - page.locator( 'role=document[name="Paragraph block"i]' ) + editor.canvas.locator( 'role=document[name="Paragraph block"i]' ) ).toHaveText( /^a+\.a$/ ); } ); test( 'should vertically move the caret when pressing Alt', async ( { page, pageUtils, + editor, } ) => { await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'a' ); async function getHeight() { - return await page.evaluate( + return await editor.canvas.evaluate( () => document.activeElement.offsetHeight ); } @@ -995,14 +1106,17 @@ test.describe( 'Writing Flow', () => { // Expect the "." to be added at the start of the paragraph await expect( - page.locator( 'role=document[name="Paragraph block"i] >> nth = 0' ) + editor.canvas.locator( + 'role=document[name="Paragraph block"i] >> nth = 0' + ) ).toHaveText( /^.a+$/ ); } ); } ); class WritingFlowUtils { - constructor( { page } ) { + constructor( { page, editor } ) { this.page = page; + this.editor = editor; this.getActiveBlockName = this.getActiveBlockName.bind( this ); } @@ -1021,19 +1135,19 @@ class WritingFlowUtils { await this.page.keyboard.press( 'Enter' ); await this.page.keyboard.type( '/columns' ); await this.page.keyboard.press( 'Enter' ); - await this.page.click( + await this.editor.canvas.click( 'role=button[name="Two columns; equal split"i]' ); - await this.page.click( 'role=button[name="Add block"i]' ); + await this.editor.canvas.click( 'role=button[name="Add block"i]' ); await this.page.click( 'role=listbox[name="Blocks"i] >> role=option[name="Paragraph"i]' ); await this.page.keyboard.type( '1st col' ); // If this text is too long, it may wrap to a new line and cause test failure. That's why we're using "1st" instead of "First" here. - await this.page.focus( + await this.editor.canvas.focus( 'role=document[name="Block: Column (2 of 2)"i]' ); - await this.page.click( 'role=button[name="Add block"i]' ); + await this.editor.canvas.click( 'role=button[name="Add block"i]' ); await this.page.click( 'role=listbox[name="Blocks"i] >> role=option[name="Paragraph"i]' ); diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts new file mode 100644 index 00000000000000..401bbcd6b24ddf --- /dev/null +++ b/test/e2e/specs/interactivity/directive-bind.spec.ts @@ -0,0 +1,259 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-bind', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-bind' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-bind' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'change href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'change href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'update missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + } ); + + test( 'add missing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + await expect( el ).toBeChecked(); + } ); + + test( 'remove existing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'remove existing checked at hydration' ); + await expect( el ).not.toBeChecked(); + } ); + + test( 'update existing checked', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + const el2 = page.getByTestId( 'remove existing checked at hydration' ); + let checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + let checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( true ); + expect( checked2 ).toBe( false ); + await page.getByTestId( 'toggle' ).click(); + checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( false ); + expect( checked2 ).toBe( true ); + } ); + + test( 'nested binds', async ( { page } ) => { + const el = page.getByTestId( 'nested binds - 1' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + const el2 = page.getByTestId( 'nested binds - 2' ); + await expect( el2 ).toHaveAttribute( 'width', '1' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + await expect( el2 ).toHaveAttribute( 'width', '2' ); + } ); + + test( 'check enumerated attributes with true/false values', async ( { + page, + } ) => { + const el = page.getByTestId( + 'check enumerated attributes with true/false exist and have a string value' + ); + await expect( el ).toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'true' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'false' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'false' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'false' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'true' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'true' ); + } ); + + test.describe( 'attribute hydration', () => { + /** + * Data structure to define a hydration test case. + */ + type MatrixEntry = { + /** + * Test ID of the element (the `data-testid` attr). + */ + testid: string; + /** + * Name of the attribute being hydrated. + */ + name: string; + /** + * Array of different values to test. + */ + values: Record< + /** + * The type of value we are hydrating. E.g., false is `false`, + * undef is `undefined`, emptyString is `''`, etc. + */ + string, + [ + /** + * Value that the attribute should contain after hydration. + * If the attribute is missing, this value is `null`. + */ + attributeValue: any, + /** + * Value that the HTMLElement instance property should + * contain after hydration. + */ + entityPropValue: any + ] + >; + }; + + const matrix: MatrixEntry[] = [ + { + testid: 'image', + name: 'width', + values: { + false: [ null, 5 ], + true: [ 'true', 5 ], + null: [ null, 5 ], + undef: [ null, 5 ], + emptyString: [ '', 5 ], + anyString: [ 'any', 5 ], + number: [ '10', 10 ], + }, + }, + { + testid: 'input', + name: 'name', + values: { + false: [ 'false', 'false' ], + true: [ 'true', 'true' ], + null: [ '', '' ], + undef: [ '', '' ], + emptyString: [ '', '' ], + anyString: [ 'any', 'any' ], + number: [ '10', '10' ], + }, + }, + { + testid: 'input', + name: 'value', + values: { + false: [ null, 'false' ], + true: [ null, 'true' ], + null: [ null, '' ], + undef: [ null, '' ], + emptyString: [ null, '' ], + anyString: [ null, 'any' ], + number: [ null, '10' ], + }, + }, + { + testid: 'input', + name: 'disabled', + values: { + false: [ null, false ], + true: [ '', true ], + null: [ null, false ], + undef: [ null, false ], + emptyString: [ null, false ], + anyString: [ '', true ], + number: [ '', true ], + }, + }, + { + testid: 'input', + name: 'aria-disabled', + values: { + false: [ 'false', undefined ], + true: [ 'true', undefined ], + null: [ null, undefined ], + undef: [ null, undefined ], + emptyString: [ '', undefined ], + anyString: [ 'any', undefined ], + number: [ '10', undefined ], + }, + }, + ]; + + for ( const { testid, name, values } of matrix ) { + test( `${ name } is correctly hydrated for different values`, async ( { + page, + } ) => { + for ( const type in values ) { + const [ attrValue, propValue ] = values[ type ]; + + const container = page.getByTestId( `hydrating ${ type }` ); + const el = container.getByTestId( testid ); + const toggle = container.getByTestId( 'toggle value' ); + + const hydratedAttr = await el.getAttribute( name ); + const hydratedProp = await el.evaluate( + ( node, propName ) => ( node as any )[ propName ], + name + ); + expect( [ type, hydratedAttr ] ).toEqual( [ + type, + attrValue, + ] ); + expect( [ type, hydratedProp ] ).toEqual( [ + type, + propValue, + ] ); + + // Only check the rendered value if the new value is not + // `undefined` and the attibute is neither `value` nor + // `disabled` because Preact doesn't update the attribute + // for those cases. + // See https://github.com/preactjs/preact/blob/099c38c6ef92055428afbc116d18a6b9e0c2ea2c/src/diff/index.js#L471-L494 + if ( + type === 'undef' && + ( name === 'value' || name === 'undefined' ) + ) { + return; + } + + await toggle.click( { clickCount: 2, delay: 50 } ); + + // Values should be the same as before. + const renderedAttr = await el.getAttribute( name ); + const renderedProp = await el.evaluate( + ( node, propName ) => ( node as any )[ propName ], + name + ); + expect( [ type, renderedAttr ] ).toEqual( [ + type, + attrValue, + ] ); + expect( [ type, renderedProp ] ).toEqual( [ + type, + propValue, + ] ); + } + } ); + } + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-effect.spec.ts new file mode 100644 index 00000000000000..40030d257661fc --- /dev/null +++ b/test/e2e/specs/interactivity/directive-effect.spec.ts @@ -0,0 +1,46 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-effect', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-effect' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-effect' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'check that effect runs when it is added', async ( { page } ) => { + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is in the DOM' ); + } ); + + test( 'check that effect runs when it is removed', async ( { page } ) => { + await page.getByTestId( 'toggle' ).click(); + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is not in the DOM' ); + } ); + + test( 'change focus after DOM changes', async ( { page } ) => { + const el = page.getByTestId( 'input' ); + await expect( el ).toBeFocused(); + await page.getByTestId( 'toggle' ).click(); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toBeFocused(); + } ); + + test( 'short-circuit infinite loops', async ( { page } ) => { + const el = page.getByTestId( 'short-circuit infinite loops' ); + await expect( el ).toContainText( '1' ); + await page.getByTestId( 'increment' ).click(); + await expect( el ).toContainText( '3' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-init.spec.ts b/test/e2e/specs/interactivity/directive-init.spec.ts new file mode 100644 index 00000000000000..aa81ab1ea61db2 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-init.spec.ts @@ -0,0 +1,76 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-init', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-init' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-init' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should run when the block renders', async ( { page } ) => { + const el = page.getByTestId( 'single init' ); + await expect( el.getByTestId( 'isReady' ) ).toHaveText( 'true' ); + await expect( el.getByTestId( 'calls' ) ).toHaveText( '1' ); + } ); + + test( 'should not run again if accessed signals change', async ( { + page, + } ) => { + const el = page.getByTestId( 'single init' ); + await expect( el.getByTestId( 'isReady' ) ).toHaveText( 'true' ); + await el.getByRole( 'button' ).click(); + await expect( el.getByTestId( 'isReady' ) ).toHaveText( 'false' ); + await expect( el.getByTestId( 'calls' ) ).toHaveText( '1' ); + } ); + + test( 'should run multiple inits if defined', async ( { page } ) => { + const el = page.getByTestId( 'multiple inits' ); + await expect( el.getByTestId( 'isReady' ) ).toHaveText( 'true,true' ); + await expect( el.getByTestId( 'calls' ) ).toHaveText( '1,1' ); + } ); + + test( 'should run the init callback when the element is unmounted', async ( { + page, + } ) => { + const container = page.getByTestId( 'init show' ); + const show = container.getByTestId( 'show' ); + const toggle = container.getByTestId( 'toggle' ); + const isMounted = container.getByTestId( 'isMounted' ); + + await expect( show ).toHaveText( 'Initially visible' ); + await expect( isMounted ).toHaveText( 'true' ); + + await toggle.click(); + + await expect( show ).not.toBeVisible(); + await expect( isMounted ).toHaveText( 'false' ); + } ); + + test( 'should run init when the element is mounted', async ( { page } ) => { + const container = page.getByTestId( 'init show' ); + const show = container.getByTestId( 'show' ); + const toggle = container.getByTestId( 'toggle' ); + const isMounted = container.getByTestId( 'isMounted' ); + + await toggle.click(); + + await expect( show ).not.toBeVisible(); + await expect( isMounted ).toHaveText( 'false' ); + + await toggle.click(); + + await expect( show ).toHaveText( 'Initially visible' ); + await expect( isMounted ).toHaveText( 'true' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-key.spec.ts b/test/e2e/specs/interactivity/directive-key.spec.ts new file mode 100644 index 00000000000000..b780100b92a6dc --- /dev/null +++ b/test/e2e/specs/interactivity/directive-key.spec.ts @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-key', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-key' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-key' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should keep the elements when adding items to the start of the array', async ( { + page, + } ) => { + // Add a number to the node so we can check later that it is still there. + await page + .getByTestId( 'first-item' ) + .evaluate( ( n ) => ( ( n as any )._id = 123 ) ); + await page.getByTestId( 'navigate' ).click(); + const id = await page + .getByTestId( 'second-item' ) + .evaluate( ( n ) => ( n as any )._id ); + expect( id ).toBe( 123 ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-on.spec.ts b/test/e2e/specs/interactivity/directive-on.spec.ts new file mode 100644 index 00000000000000..03dfe64462c567 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-on.spec.ts @@ -0,0 +1,54 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-on', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-on' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-on' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'callbacks should run whenever the specified event is dispatched', async ( { + page, + } ) => { + const counter = page.getByTestId( 'counter' ); + await page + .getByTestId( 'button' ) + .click( { clickCount: 3, delay: 100 } ); + await expect( counter ).toHaveText( '3' ); + } ); + + test( 'callbacks should receive the dispatched event', async ( { + page, + } ) => { + const text = page.getByTestId( 'text' ); + await page.getByTestId( 'input' ).fill( 'hello!' ); + await expect( text ).toHaveText( 'hello!' ); + } ); + + test( 'callbacks should be able to access the context', async ( { + page, + } ) => { + const option = page.getByTestId( 'option' ); + await page.getByTestId( 'select' ).selectOption( 'dog' ); + await expect( option ).toHaveText( 'dog' ); + } ); + + test( 'should work with custom events', async ( { page } ) => { + const counter = page.getByTestId( 'custom events counter' ); + await page + .getByTestId( 'custom events button' ) + .click( { clickCount: 3, delay: 100 } ); + await expect( counter ).toHaveText( '3' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-priorities.spec.ts b/test/e2e/specs/interactivity/directive-priorities.spec.ts new file mode 100644 index 00000000000000..56745bfad0c433 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-priorities.spec.ts @@ -0,0 +1,101 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Directives (w/ priority)', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-priorities' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-priorities' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should run in priority order', async ( { page } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + await expect( executionOrder ).toHaveText( + 'context, attribute, text, children' + ); + } ); + + test( 'should wrap those with less priority', async ( { page } ) => { + // Check that attribute value is correctly received from Provider. + const element = page.getByTestId( 'test directives' ); + await expect( element ).toHaveAttribute( + 'data-attribute', + 'from context' + ); + + // Check that text value is correctly received from Provider, and text + // wrapped with an element with `data-testid=text`. + const text = element.getByTestId( 'text' ); + await expect( text ).toHaveText( 'from context' ); + } ); + + test( 'should propagate element modifications top-down', async ( { + page, + } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + const element = page.getByTestId( 'test directives' ); + const text = element.getByTestId( 'text' ); + + // Get buttons. + const updateAttribute = element.getByRole( 'button', { + name: 'Update attribute', + } ); + const updateText = element.getByRole( 'button', { + name: 'Update text', + } ); + + // Modify `attribute` inside context. This triggers a re-render for the + // component that wraps the `attribute` directive, evaluating it again. + // Nested components are re-rendered as well, so their directives are + // also re-evaluated (note how `text` and `children` have run). + await updateAttribute.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + ].join( ', ' ) + ); + + // Modify `text` inside context. This triggers a re-render of the + // component that wraps the `text` directive. In this case, only + // `children` run as well, right after `text`. + await updateText.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( text ).toHaveText( 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + 'text, children', + ].join( ', ' ) + ); + } ); + + test( 'should not create a Directives component if none of the directives are registered', async ( { + page, + } ) => { + const nonExistentDirectives = page.getByTestId( + 'non-existent-directives' + ); + expect( + await nonExistentDirectives.evaluate( + // This returns undefined if type is a component. + ( node ) => { + return ( node as any ).__k.__k.__k[ 0 ].__k[ 0 ].__k[ 0 ] + .type; + } + ) + ).toBe( 'div' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-slots.spec.ts b/test/e2e/specs/interactivity/directive-slots.spec.ts new file mode 100644 index 00000000000000..d93e50f767215f --- /dev/null +++ b/test/e2e/specs/interactivity/directive-slots.spec.ts @@ -0,0 +1,186 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-slot', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-slots' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-slots' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should render the fill in its children by default', async ( { + page, + } ) => { + const slot1 = page.getByTestId( 'slot-1' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-1-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot1.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slot1 ).toHaveText( 'fill inside slot 1' ); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + 'fill inside slot 1', + '[2]', + '[3]', + '[4]', + '[5]', + ] ); + } ); + + test( 'should render the fill before if specified', async ( { page } ) => { + const slot2 = page.getByTestId( 'slot-2' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-2-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot2 ).toHaveText( '[2]' ); + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + 'fill inside slots', + '[2]', + '[3]', + '[4]', + '[5]', + ] ); + } ); + + test( 'should render the fill after if specified', async ( { page } ) => { + const slot3 = page.getByTestId( 'slot-3' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-3-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot3 ).toHaveText( '[3]' ); + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3]', + 'fill inside slots', + '[4]', + '[5]', + ] ); + } ); + + test( 'should render the fill in its children if specified', async ( { + page, + } ) => { + const slot4 = page.getByTestId( 'slot-4' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-4-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot4.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slot4 ).toHaveText( 'fill inside slot 4' ); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3]', + 'fill inside slot 4', + '[5]', + ] ); + } ); + + test( 'should be replaced by the fill if specified', async ( { page } ) => { + const slot5 = page.getByTestId( 'slot-5' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-5-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot5 ).not.toBeVisible(); + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3]', + '[4]', + 'fill inside slots', + ] ); + } ); + + test( 'should keep the fill in its original position if no slot matches', async ( { + page, + } ) => { + const fillContainer = page.getByTestId( 'fill-container' ); + await expect( fillContainer.getByTestId( 'fill' ) ).toBeVisible(); + + await page.getByTestId( 'slot-1-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + + await page.getByTestId( 'reset' ).click(); + + await expect( fillContainer.getByTestId( 'fill' ) ).toBeVisible(); + } ); + + test( 'should not be re-mounted when adding the fill before', async ( { + page, + } ) => { + const slot2 = page.getByTestId( 'slot-2' ); + const slots = page.getByTestId( 'slots' ); + + await expect( slot2 ).toHaveText( '[2]' ); + + await slot2.click(); + + await expect( slot2 ).toHaveText( '[2 updated]' ); + + await page.getByTestId( 'slot-2-button' ).click(); + + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + 'fill inside slots', + '[2 updated]', + '[3]', + '[4]', + '[5]', + ] ); + } ); + + test( 'should not be re-mounted when adding the fill after', async ( { + page, + } ) => { + const slot3 = page.getByTestId( 'slot-3' ); + const slots = page.getByTestId( 'slots' ); + + await expect( slot3 ).toHaveText( '[3]' ); + + await slot3.click(); + + await expect( slot3 ).toHaveText( '[3 updated]' ); + + await page.getByTestId( 'slot-3-button' ).click(); + + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3 updated]', + 'fill inside slots', + '[4]', + '[5]', + ] ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-body.spec.ts b/test/e2e/specs/interactivity/directives-body.spec.ts new file mode 100644 index 00000000000000..be11cfc556b591 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-body.spec.ts @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-body', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-body' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-body' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( "should move the element to the document's body", async ( { + page, + } ) => { + const container = page.getByTestId( 'container' ); + const parentTag = page + .getByTestId( 'element with data-wp-body' ) + .locator( 'xpath=..' ); + + await expect( container ).toBeEmpty(); + await expect( parentTag ).toHaveJSProperty( 'tagName', 'BODY' ); + } ); + + test( 'should make context accessible for inner elements', async ( { + page, + } ) => { + const text = page + .getByTestId( 'element with data-wp-body' ) + .getByTestId( 'text' ); + const toggle = page.getByTestId( 'toggle text' ); + + await expect( text ).toHaveText( 'text-1' ); + await toggle.click(); + await expect( text ).toHaveText( 'text-2' ); + await toggle.click(); + await expect( text ).toHaveText( 'text-1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-class.spec.ts b/test/e2e/specs/interactivity/directives-class.spec.ts new file mode 100644 index 00000000000000..b7e085aba15143 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-class.spec.ts @@ -0,0 +1,111 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-class', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-class' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-class' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'remove class if callback returns falsy value', async ( { + page, + } ) => { + const el = page.getByTestId( + 'remove class if callback returns falsy value' + ); + await expect( el ).toHaveClass( 'bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'bar' ); + } ); + + test( 'add class if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( + 'add class if callback returns truthy value' + ); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + } ); + + test( 'handles multiple classes and callbacks', async ( { page } ) => { + const el = page.getByTestId( 'handles multiple classes and callbacks' ); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'handles class names that are contained inside other class names', async ( { + page, + } ) => { + const el = page.getByTestId( + 'handles class names that are contained inside other class names' + ); + await expect( el ).toHaveClass( 'foo-bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo foo-bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'can toggle class in the middle', async ( { page } ) => { + const el = page.getByTestId( 'can toggle class in the middle' ); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + } ); + + test( 'can toggle class when class attribute is missing', async ( { + page, + } ) => { + const el = page.getByTestId( + 'can toggle class when class attribute is missing' + ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( '' ); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( '' ); + } ); + + test( 'can use BEM notation classes', async ( { page } ) => { + const el = page.getByTestId( 'can use BEM notation classes' ); + await expect( el ).toHaveClass( 'block__element--modifier' ); + } ); + + test( 'can use classes with several dashes', async ( { page } ) => { + const el = page.getByTestId( 'can use classes with several dashes' ); + await expect( el ).toHaveClass( 'main-bg----color' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts new file mode 100644 index 00000000000000..f94784865cb757 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-context.spec.ts @@ -0,0 +1,192 @@ +/** + * External dependencies + */ +import type { Locator } from '@playwright/test'; +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const parseContent = async ( loc: Locator ) => + JSON.parse( ( await loc.textContent() ) || '' ); + +test.describe( 'data-wp-context', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-context' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-context' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'is correctly initialized', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext ).toMatchObject( { + prop1: 'parent', + prop2: 'parent', + obj: { prop4: 'parent', prop5: 'parent' }, + array: [ 1, 2, 3 ], + } ); + } ); + + test( 'is correctly extended', async ( { page } ) => { + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext ).toMatchObject( { + prop1: 'parent', + prop2: 'child', + prop3: 'child', + obj: { prop4: 'parent', prop5: 'child', prop6: 'child' }, + array: [ 4, 5, 6 ], + } ); + } ); + + test( 'changes in inherited properties are reflected (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop1' ).click(); + await page.getByTestId( 'child obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + } ); + + test( 'changes in inherited properties are reflected (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop1' ).click(); + await page.getByTestId( 'parent obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'changes in shadowed properties do not leak (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop2' ).click(); + await page.getByTestId( 'child obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop5 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'parent' ); + expect( parentContext.obj.prop5 ).toBe( 'parent' ); + } ); + + test( 'changes in shadowed properties do not leak (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop2' ).click(); + await page.getByTestId( 'parent obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'child' ); + expect( childContext.obj.prop5 ).toBe( 'child' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop5 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'Array properties are shadowed', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( parentContext.array ).toMatchObject( [ 1, 2, 3 ] ); + expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] ); + } ); + + test( 'can be accessed in other directives on the same element', async ( { + page, + } ) => { + const element = page.getByTestId( 'context & other directives' ); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + await element.click(); + await expect( element ).toHaveText( 'Text 2' ); + await expect( element ).toHaveAttribute( 'value', 'Text 2' ); + await element.click(); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + } ); + + test( 'should replace values on navigation', async ( { page } ) => { + const element = page.getByTestId( 'navigation text' ); + await expect( element ).toHaveText( 'first page' ); + await page.getByTestId( 'toggle text' ).click(); + await expect( element ).toHaveText( 'changed dynamically' ); + await page.getByTestId( 'navigate' ).click(); + await expect( element ).toHaveText( 'second page' ); + } ); + + test( 'should preserve the previous context values', async ( { page } ) => { + const element = page.getByTestId( 'navigation new text' ); + await expect( element ).toHaveText( '' ); + await page.getByTestId( 'add new text' ).click(); + await expect( element ).toHaveText( 'some new text' ); + await page.getByTestId( 'navigate' ).click(); + await expect( element ).toHaveText( 'some new text' ); + } ); + + test( 'should maintain the same context reference on async actions', async ( { + page, + } ) => { + const element = page.getByTestId( 'navigation new text' ); + await expect( element ).toHaveText( '' ); + await page.getByTestId( 'async navigate' ).click(); + await expect( element ).toHaveText( 'changed from async action' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-style.spec.ts b/test/e2e/specs/interactivity/directives-style.spec.ts new file mode 100644 index 00000000000000..033f6b26057c50 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-style.spec.ts @@ -0,0 +1,118 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-style', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-style' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-style' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'dont change style if callback returns same value on hydration', async ( { + page, + } ) => { + const el = page.getByTestId( + 'dont change style if callback returns same value on hydration' + ); + await expect( el ).toHaveAttribute( + 'style', + 'color: red; background: green;' + ); + } ); + + test( 'remove style if callback returns falsy value on hydration', async ( { + page, + } ) => { + const el = page.getByTestId( + 'remove style if callback returns falsy value on hydration' + ); + await expect( el ).toHaveAttribute( 'style', 'background: green;' ); + } ); + + test( 'change style if callback returns a new value on hydration', async ( { + page, + } ) => { + const el = page.getByTestId( + 'change style if callback returns a new value on hydration' + ); + await expect( el ).toHaveAttribute( + 'style', + 'color: red; background: green;' + ); + } ); + + test( 'handles multiple styles and callbacks on hydration', async ( { + page, + } ) => { + const el = page.getByTestId( + 'handles multiple styles and callbacks on hydration' + ); + await expect( el ).toHaveAttribute( + 'style', + 'background: red; border: 2px solid yellow;' + ); + } ); + + test( 'can add style when style attribute is missing on hydration', async ( { + page, + } ) => { + const el = page.getByTestId( + 'can add style when style attribute is missing on hydration' + ); + await expect( el ).toHaveAttribute( 'style', 'color: red;' ); + } ); + + test( 'can toggle style', async ( { page } ) => { + const el = page.getByTestId( 'can toggle style' ); + await expect( el ).toHaveAttribute( 'style', 'color: red;' ); + await page.getByTestId( 'toggle color' ).click(); + await expect( el ).toHaveAttribute( 'style', 'color: blue;' ); + } ); + + test( 'can remove style', async ( { page } ) => { + const el = page.getByTestId( 'can remove style' ); + await expect( el ).toHaveAttribute( 'style', 'color: red;' ); + await page.getByTestId( 'switch color to false' ).click(); + await expect( el ).toHaveAttribute( 'style', '' ); + } ); + + test( 'can toggle style in the middle', async ( { page } ) => { + const el = page.getByTestId( 'can toggle style in the middle' ); + await expect( el ).toHaveAttribute( + 'style', + 'color: blue; background: red; border: 1px solid black;' + ); + await page.getByTestId( 'toggle color' ).click(); + await expect( el ).toHaveAttribute( + 'style', + 'color: blue; background: blue; border: 1px solid black;' + ); + } ); + + test( 'handles styles names with hyphens', async ( { page } ) => { + const el = page.getByTestId( 'handles styles names with hyphens' ); + await expect( el ).toHaveAttribute( 'style', 'background-color: red;' ); + await page.getByTestId( 'toggle color' ).click(); + await expect( el ).toHaveAttribute( + 'style', + 'background-color: blue;' + ); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toHaveAttribute( 'style', 'color: blue;' ); + await page.getByTestId( 'toggle context' ).click(); + await expect( el ).toHaveAttribute( 'style', 'color: red;' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-text.spec.ts b/test/e2e/specs/interactivity/directives-text.spec.ts new file mode 100644 index 00000000000000..8e83be26de15ca --- /dev/null +++ b/test/e2e/specs/interactivity/directives-text.spec.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-text', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-text' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-text' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show proper text reading from state', async ( { page } ) => { + const el = page.getByTestId( 'show state text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); + + test( 'show proper text reading from context', async ( { page } ) => { + const el = page.getByTestId( 'show context text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/fixtures/index.ts b/test/e2e/specs/interactivity/fixtures/index.ts new file mode 100644 index 00000000000000..607221ffb1ec43 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/index.ts @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import InteractivityUtils from './interactivity-utils'; + +type Fixtures = { + interactivityUtils: InteractivityUtils; +}; + +export const test = base.extend< Fixtures >( { + interactivityUtils: [ + async ( { requestUtils }, use ) => { + await use( new InteractivityUtils( { requestUtils } ) ); + }, + // @ts-ignore: The required type is 'test', but can be 'worker' too. See + // https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures + { scope: 'worker' }, + ], +} ); diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts new file mode 100644 index 00000000000000..fc0dc4b30d664e --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +type AddPostWithBlockOptions = { + alias?: string; + attributes?: Record< string, any >; +}; + +export default class InteractivityUtils { + links: Map< string, string >; + requestUtils: RequestUtils; + + constructor( { requestUtils }: { requestUtils: RequestUtils } ) { + this.links = new Map(); + this.requestUtils = requestUtils; + } + + getLink( blockName: string ) { + const link = this.links.get( blockName ); + if ( ! link ) { + throw new Error( + `No link found for post with block '${ blockName }'` + ); + } + + // Add an extra param to disable directives SSR. This is required at + // this moment, as SSR for directives is not stabilized yet and we need + // to ensure hydration works, even when the SSR'ed HTML is not correct. + const url = new URL( link ); + url.searchParams.append( 'disable_directives_ssr', 'true' ); + return url.href; + } + + async addPostWithBlock( + name: string, + { attributes, alias }: AddPostWithBlockOptions = {} + ) { + const block = attributes + ? `${ name } ${ JSON.stringify( attributes ) }` + : name; + + if ( ! alias ) alias = block; + + const payload = { + content: `<!-- wp:${ block } /-->`, + status: 'publish' as 'publish', + date_gmt: '2023-01-01T00:00:00', + }; + + const { link } = await this.requestUtils.createPost( payload ); + this.links.set( alias, link ); + return this.getLink( alias ); + } + + async deleteAllPosts() { + await this.requestUtils.deleteAllPosts(); + this.links.clear(); + } + + async activatePlugins() { + await this.requestUtils.activateTheme( 'emptytheme' ); + await this.requestUtils.activatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } + + async deactivatePlugins() { + await this.requestUtils.activateTheme( 'twentytwentyone' ); + await this.requestUtils.deactivatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } +} diff --git a/test/e2e/specs/interactivity/negation-operator.spec.ts b/test/e2e/specs/interactivity/negation-operator.spec.ts new file mode 100644 index 00000000000000..da5670a0e57824 --- /dev/null +++ b/test/e2e/specs/interactivity/negation-operator.spec.ts @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'negation-operator', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/negation-operator' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/negation-operator' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add hidden attribute when !state.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if state is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); + + test( 'add hidden attribute when !selectors.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if selector is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/router-regions.spec.ts b/test/e2e/specs/interactivity/router-regions.spec.ts new file mode 100644 index 00000000000000..cbe66b7bd1b217 --- /dev/null +++ b/test/e2e/specs/interactivity/router-regions.spec.ts @@ -0,0 +1,100 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Router regions', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + const next = await utils.addPostWithBlock( 'test/router-regions', { + alias: 'router regions - page 2', + attributes: { page: 2 }, + } ); + await utils.addPostWithBlock( 'test/router-regions', { + alias: 'router regions - page 1', + attributes: { page: 1, next }, + } ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'router regions - page 1' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should be the only part hydrated', async ( { page } ) => { + const region1Text = page.getByTestId( 'region-1-text' ); + const region2Text = page.getByTestId( 'region-2-text' ); + const noRegionText1 = page.getByTestId( 'no-region-text-1' ); + const noRegionText2 = page.getByTestId( 'no-region-text-2' ); + + await expect( region1Text ).toHaveText( 'hydrated' ); + await expect( region2Text ).toHaveText( 'hydrated' ); + await expect( noRegionText1 ).toHaveText( 'not hydrated' ); + await expect( noRegionText2 ).toHaveText( 'not hydrated' ); + } ); + + test( 'should update after navigation', async ( { page } ) => { + const region1Ssr = page.getByTestId( 'region-1-ssr' ); + const region2Ssr = page.getByTestId( 'region-2-ssr' ); + + await expect( region1Ssr ).toHaveText( 'content from page 1' ); + await expect( region2Ssr ).toHaveText( 'content from page 1' ); + + await page.getByTestId( 'next' ).click(); + + await expect( region1Ssr ).toHaveText( 'content from page 2' ); + await expect( region2Ssr ).toHaveText( 'content from page 2' ); + + await page.getByTestId( 'back' ).click(); + + await expect( region1Ssr ).toHaveText( 'content from page 1' ); + await expect( region2Ssr ).toHaveText( 'content from page 1' ); + } ); + + test( 'should preserve state across pages', async ( { page } ) => { + const counter = page.getByTestId( 'state-counter' ); + await expect( counter ).toHaveText( '0' ); + + await counter.click( { clickCount: 3, delay: 50 } ); + await expect( counter ).toHaveText( '3' ); + + await page.getByTestId( 'next' ).click(); + await counter.click( { clickCount: 3, delay: 50 } ); + await expect( counter ).toHaveText( '6' ); + + await page.getByTestId( 'back' ).click(); + await counter.click( { clickCount: 3, delay: 50 } ); + await expect( counter ).toHaveText( '9' ); + } ); + + test( 'should preserve context across pages', async ( { page } ) => { + const counter = page.getByTestId( 'context-counter' ); + await expect( counter ).toHaveText( '0' ); + + await counter.click( { clickCount: 3, delay: 50 } ); + await expect( counter ).toHaveText( '3' ); + + await page.getByTestId( 'next' ).click(); + await counter.click( { clickCount: 3, delay: 50 } ); + await expect( counter ).toHaveText( '6' ); + + await page.getByTestId( 'back' ).click(); + await counter.click( { clickCount: 3, delay: 50 } ); + await expect( counter ).toHaveText( '9' ); + } ); + + test( 'can be nested', async ( { page } ) => { + const nestedRegionSsr = page.getByTestId( 'nested-region-ssr' ); + await expect( nestedRegionSsr ).toHaveText( 'content from page 1' ); + + await page.getByTestId( 'next' ).click(); + await expect( nestedRegionSsr ).toHaveText( 'content from page 2' ); + + await page.getByTestId( 'back' ).click(); + await expect( nestedRegionSsr ).toHaveText( 'content from page 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/store-afterload.spec.ts b/test/e2e/specs/interactivity/store-afterload.spec.ts new file mode 100644 index 00000000000000..388e80177b0339 --- /dev/null +++ b/test/e2e/specs/interactivity/store-afterload.spec.ts @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'store afterLoad callbacks', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/store-afterload' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/store-afterload' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'run after the vdom and store are ready', async ( { page } ) => { + const allStoresReady = page.getByTestId( 'all-stores-ready' ); + const vdomReady = page.getByTestId( 'vdom-ready' ); + + await expect( allStoresReady ).toHaveText( 'true' ); + await expect( vdomReady ).toHaveText( 'true' ); + } ); + + test( 'run once even if shared between several store calls', async ( { + page, + } ) => { + const afterLoadTimes = page.getByTestId( 'after-load-exec-times' ); + const sharedAfterLoadTimes = page.getByTestId( + 'shared-after-load-exec-times' + ); + + await expect( afterLoadTimes ).toHaveText( '1' ); + await expect( sharedAfterLoadTimes ).toHaveText( '1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/store-tag.spec.ts b/test/e2e/specs/interactivity/store-tag.spec.ts new file mode 100644 index 00000000000000..80f9acfad93358 --- /dev/null +++ b/test/e2e/specs/interactivity/store-tag.spec.ts @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'store tag', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/store-tag {"condition":"ok"}' ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"missing"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"corrupted-json"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"invalid-state"}' + ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'hydrates when it is well defined', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"ok"}'; + await page.goto( utils.getLink( block ) ); + + const value = page.getByTestId( 'counter value' ); + const double = page.getByTestId( 'counter double' ); + const clicks = page.getByTestId( 'counter clicks' ); + + await expect( value ).toHaveText( '3' ); + await expect( double ).toHaveText( '6' ); + await expect( clicks ).toHaveText( '0' ); + + await page.getByTestId( 'counter button' ).click(); + + await expect( value ).toHaveText( '4' ); + await expect( double ).toHaveText( '8' ); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when missing', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"missing"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when corrupted', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"corrupted-json"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when it contains an invalid state', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"invalid-state"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom-islands.spec.ts b/test/e2e/specs/interactivity/tovdom-islands.spec.ts new file mode 100644 index 00000000000000..fcc7c6081077a6 --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom-islands.spec.ts @@ -0,0 +1,58 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom - islands', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom-islands' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom-islands' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'directives that are not inside islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'not inside an island' ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives that are inside islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'inside an island' ); + await expect( el ).toBeHidden(); + } ); + + test( 'directives that are inside inner blocks of isolated islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'inside an inner block of an isolated island' + ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives inside islands should not be hydrated twice', async ( { + page, + } ) => { + const el = page.getByTestId( 'island inside another island' ); + const templates = el.locator( 'template' ); + expect( await templates.count() ).toEqual( 1 ); + } ); + + test( 'islands inside inner blocks of isolated islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'island inside inner block of isolated island' + ); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom.spec.ts b/test/e2e/specs/interactivity/tovdom.spec.ts new file mode 100644 index 00000000000000..cf08fb115db4fd --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom.spec.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'it should delete comments', async ( { page } ) => { + const el = page.getByTestId( 'it should delete comments' ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between comments' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should delete processing instructions', async ( { page } ) => { + const el = page.getByTestId( + 'it should delete processing instructions' + ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between processing instructions' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should replace CDATA with text nodes', async ( { page } ) => { + const el = page.getByTestId( + 'it should replace CDATA with text nodes' + ); + const c = await el.innerHTML(); + expect( c ).toContain( '##1##' ); + expect( c ).toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between CDATA' + ); + await expect( el2 ).toBeVisible(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/behaviors.spec.js b/test/e2e/specs/site-editor/behaviors.spec.js new file mode 100644 index 00000000000000..829ff6af25ed4e --- /dev/null +++ b/test/e2e/specs/site-editor/behaviors.spec.js @@ -0,0 +1,410 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.use( { + behaviorUtils: async ( { page, requestUtils }, use ) => { + await use( new BehaviorUtils( { page, requestUtils } ) ); + }, +} ); + +const filename = '1024x768_e2e_test_image_size.jpeg'; + +class BehaviorUtils { + constructor( { page, requestUtils } ) { + this.page = page; + this.requestUtils = requestUtils; + + this.TEST_IMAGE_FILE_PATH = path.join( + __dirname, + '..', + '..', + 'assets', + filename + ); + } + + async createMedia() { + const media = await this.requestUtils.uploadMedia( + this.TEST_IMAGE_FILE_PATH + ); + return media; + } + + async setLightboxBehavior( value ) { + // Open the "Advanced" panel + const toggleButton = this.page.getByRole( 'button', { + name: 'Advanced', + } ); + + const isClosed = + ( await toggleButton.getAttribute( 'aria-expanded' ) ) === 'false'; + + if ( isClosed ) { + await toggleButton.click(); + } + + // Set the value of Behaviors + await this.page + .getByRole( 'combobox', { name: 'Behaviors' } ) + .selectOption( 'lightbox' ); + await this.page + .getByRole( 'combobox', { name: 'Animation' } ) + .selectOption( value ); + } + + async goToImageBlockStyles() { + const toggleButton = this.page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { + name: 'Styles', + disabled: false, + } ); + + const isClosed = + ( await toggleButton.getAttribute( 'aria-expanded' ) ) === 'false'; + + if ( isClosed ) await toggleButton.click(); + + await this.page + .getByRole( 'button', { name: 'Blocks styles' } ) + .click(); + await this.page + .getByRole( 'button', { name: 'Image block styles', exact: true } ) + .click(); + } +} + +test.describe( 'Site editor behaviors', () => { + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + await requestUtils.deleteAllPosts(); + } ); + + test.beforeEach( async ( { admin, page, requestUtils, editor } ) => { + await requestUtils.deleteAllMedia(); + await requestUtils.deleteAllPosts(); + + await admin.visitSiteEditor(); + await editor.canvas.click( 'body' ); + + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Styles' } ) + .click(); + + const revisionsMenu = page.getByRole( 'button', { name: 'Revisions' } ); + + // If the button is disabled, it means that there are no revisions so we can skip + if ( await revisionsMenu.isDisabled() ) return; + await revisionsMenu.click(); + + const resetButton = page.getByRole( 'menuitem', { + name: 'Reset to defaults', + } ); + + // If the button is disabled, it means that there is no revision to reset to so we can skip + if ( await resetButton.isDisabled() ) return; + await resetButton.click(); + + await editor.saveSiteEditorEntities(); + } ); + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentythree' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + await requestUtils.deleteAllPosts(); + } ); + + test( 'Behaviors should be set to "default" by default', async ( { + page, + admin, + editor, + behaviorUtils, + } ) => { + await admin.visitSiteEditor(); + await editor.canvas.click( 'body' ); + + // Navigate to Styles -> Blocks -> Image + await behaviorUtils.goToImageBlockStyles(); + + // Open the advanced panel + await page.getByRole( 'button', { name: 'Advanced' } ).click(); + + // Check that the behavior is set to "default" + await expect( + page.getByRole( 'combobox', { + name: 'Behavior', + } ) + ).toHaveValue( 'default' ); + } ); + + test( 'Test updating the behaviors', async ( { + page, + behaviorUtils, + admin, + editor, + } ) => { + // + // Post editor + // + + await admin.createNewPost(); + const media = await behaviorUtils.createMedia(); + + // First insert an image with the "default" behavior + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: 'default-image', + url: media.source_url, + }, + } ); + + // Second, insert an image with "zoom" behavior + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: 'zoom-image', + url: media.source_url, + behaviors: { + lightbox: { + enabled: true, + animation: 'zoom', + }, + }, + }, + } ); + + // + // Front-end + // + + // Go to the front-end + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // Lightbox should be initially hidden + const lightbox = page.getByRole( 'dialog', { name: 'Image' } ); + await expect( lightbox ).toBeHidden(); + + // Click on the default image (the first image, with the "default" behavior) + const defaultImage = page.locator( 'figure.wp-block-image' ).nth( 0 ); + await defaultImage.click(); + + // Lightbox should NOT appear + await expect( lightbox ).toBeHidden(); + + // Click on the image with the zoom behavior (the second image) + await page.locator( 'figure.wp-block-image' ).nth( 1 ).click(); + + // Lightbox should appear now! + await expect( lightbox ).toBeVisible(); + + // Click on the close button of the lightbox + const closeButton = lightbox.getByRole( 'button', { + name: 'Close', + } ); + await closeButton.click(); + + // + // Site editor + // + + // Go to the site editor + await admin.visitSiteEditor(); + await editor.canvas.click( 'body' ); + + // Navigate to Styles -> Blocks -> Image + await behaviorUtils.goToImageBlockStyles(); + + // Set the value of Behavior to "fade" + await behaviorUtils.setLightboxBehavior( 'fade' ); + + // Save the changes + await editor.saveSiteEditorEntities(); + + // + // Front-end + // + + // Open the post on the front-end again + await page.goto( `/?p=${ postId }` ); + + // Click on the default image + await defaultImage.click(); + + // Lightbox should appear now because it's using the defaults and we've + // changed the defaults in the Global Styles !!! + await expect( lightbox ).toBeVisible(); + + // Get the animation type from the image + const wpContext = await defaultImage.getAttribute( 'data-wp-context' ); + const animation = JSON.parse( wpContext ).core.image.lightboxAnimation; + + // Check that the lightbox is using the "fade" animation + expect( animation ).toBe( 'fade' ); + } ); + + test( 'Use the "Apply globally" button to apply the "fade" behavior as a default to all image blocks', async ( { + page, + editor, + admin, + behaviorUtils, + } ) => { + // + // Site editor + // + + // Go to the site editor + await admin.visitSiteEditor(); + await editor.canvas.click( 'body' ); + + let media = await behaviorUtils.createMedia(); + + // Insert a new image block with default behaviors + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: 'default-image', + url: media.source_url, + }, + } ); + + // Open the image block settings panel + await editor.openDocumentSettingsSidebar(); + + // Set the value of Behaviors to "fade" + await behaviorUtils.setLightboxBehavior( 'fade' ); + + // Click on the "Apply Globally" button + await page.getByRole( 'button', { name: 'Apply Globally' } ).click(); + + // Snackbar notification should appear + await expect( + page.getByText( 'Image behaviors applied.', { exact: true } ) + ).toBeVisible(); + + // Save the changes + await editor.saveSiteEditorEntities(); + + // + // Post editor + // + + // Create a new post and an image file + await admin.createNewPost(); + media = await behaviorUtils.createMedia(); + + // Insert a new image block with default behaviors + await editor.insertBlock( { + name: 'core/image', + attributes: { + alt: filename, + id: 'default-image', + url: media.source_url, + }, + } ); + + // Save & publish the post and go to the front-end + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + // + // Front end + // + + // Click on the image + const image = page.locator( 'figure.wp-block-image' ).nth( 0 ); + await image.click(); + + /// Lightbox should be hidden + const lightbox = page.getByRole( 'dialog', { name: 'Image' } ); + await expect( lightbox ).toBeVisible(); + + // Get the animation type from the image + const wpContext = await image.getAttribute( 'data-wp-context' ); + const animation = JSON.parse( wpContext ).core.image.lightboxAnimation; + + // Check that the lightbox is using the "fade" animation + expect( animation ).toBe( 'fade' ); + } ); + + test( 'Revisions work as expected with behaviors', async ( { + page, + editor, + admin, + behaviorUtils, + } ) => { + // Go to the site editor + await admin.visitSiteEditor(); + await editor.canvas.click( 'body' ); + + // Navigate to Styles -> Blocks -> Image + await behaviorUtils.goToImageBlockStyles(); + + // Set the value of Image Lightbox behavior to "fade" and then to "zoom" and + // save the changes each time. + await behaviorUtils.setLightboxBehavior( 'fade' ); + await editor.saveSiteEditorEntities(); + await behaviorUtils.setLightboxBehavior( 'zoom' ); + await editor.saveSiteEditorEntities(); + + // Go to the Revisions panel + await page + .getByRole( 'region', { name: 'Editor Settings' } ) + .getByRole( 'button', { name: 'Revisions' } ) + .click(); + + // Open the revision history + await page + .getByRole( 'menuitem', { name: 'Revision history' } ) + .click(); + + // Click on the previous revision + await page + .getByRole( 'group', { + name: 'Global styles revisions', + } ) + .getByRole( 'button', { name: 'Changes saved by admin' } ) + .nth( 1 ) + .click(); + + // Click on the "Apply" button + await page.getByRole( 'button', { name: 'Apply' } ).click(); + + // Click on the "Close Styles" button + await page + .getByRole( 'button', { name: 'Navigate to the previous view' } ) + .click(); + + // Navigate to Styles -> Blocks -> Image + await behaviorUtils.goToImageBlockStyles(); + + // Open the "Advanced" panel + await page + .getByRole( 'button', { + name: 'Advanced', + } ) + .click(); + + // Check that the value of the Image Lightbox behavior is "fade" + await expect( + page.getByRole( 'combobox', { + name: 'Animation', + } ) + ).toHaveValue( 'fade' ); + } ); +} ); diff --git a/test/e2e/specs/site-editor/block-list-panel-preference.spec.js b/test/e2e/specs/site-editor/block-list-panel-preference.spec.js deleted file mode 100644 index d933714017977e..00000000000000 --- a/test/e2e/specs/site-editor/block-list-panel-preference.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * WordPress dependencies - */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); - -test.describe( 'Block list view', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'emptytheme' ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test( 'Should open by default', async ( { admin, page, editor } ) => { - await admin.visitSiteEditor( { - postId: 'emptytheme//index', - postType: 'wp_template', - } ); - - await editor.canvas.click( 'body' ); - - // Should display the Preview button. - await expect( - page.locator( 'role=region[name="List View"i]' ) - ).not.toBeVisible(); - - // Turn on block list view by default. - await page.evaluate( () => { - window.wp.data - .dispatch( 'core/preferences' ) - .set( 'core/edit-site', 'showListViewByDefault', true ); - } ); - - await page.reload(); - - // Should display the Preview button. - await expect( - page.locator( 'role=region[name="List View"i]' ) - ).toBeVisible(); - } ); -} ); diff --git a/test/e2e/specs/site-editor/block-removal.spec.js b/test/e2e/specs/site-editor/block-removal.spec.js new file mode 100644 index 00000000000000..c1d6cdfea00222 --- /dev/null +++ b/test/e2e/specs/site-editor/block-removal.spec.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Site editor block removal prompt', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.beforeEach( async ( { admin, editor } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + } ); + await editor.canvas.click( 'body' ); + } ); + + test( 'should appear when attempting to remove Query Block', async ( { + page, + } ) => { + // Open and focus List View + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + await topBar.getByRole( 'button', { name: 'List View' } ).click(); + + // Select and try to remove Query Loop block + const listView = page.getByRole( 'region', { name: 'List View' } ); + await listView.getByRole( 'link', { name: 'Query Loop' } ).click(); + await page.keyboard.press( 'Backspace' ); + + // Expect the block removal prompt to have appeared + await expect( + page.getByText( 'Query Loop displays a list of posts or pages.' ) + ).toBeVisible(); + } ); + + test( 'should appear when attempting to remove Post Template Block', async ( { + page, + } ) => { + // Open and focus List View + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + await topBar.getByRole( 'button', { name: 'List View' } ).click(); + + // Select and open child blocks of Query Loop block + const listView = page.getByRole( 'region', { name: 'List View' } ); + await listView.getByRole( 'link', { name: 'Query Loop' } ).click(); + await page.keyboard.press( 'ArrowRight' ); + + // Select and try to remove Post Template block + await listView.getByRole( 'link', { name: 'Post Template' } ).click(); + await page.keyboard.press( 'Backspace' ); + + // Expect the block removal prompt to have appeared + await expect( + page.getByText( + 'Post Template displays each post or page in a Query Loop.' + ) + ).toBeVisible(); + } ); + + test( 'should not appear when attempting to remove something else', async ( { + editor, + page, + } ) => { + // Open and focus List View + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + await topBar.getByRole( 'button', { name: 'List View' } ).click(); + + // Select Query Loop list item + const listView = page.getByRole( 'region', { name: 'List View' } ); + await listView.getByRole( 'link', { name: 'Query Loop' } ).click(); + + // Reveal its inner blocks in the list view + await page.keyboard.press( 'ArrowRight' ); + + // Select its Post Template inner block + await listView.getByRole( 'link', { name: 'Post Template' } ).click(); + + // Reveal its inner blocks in the list view + await page.keyboard.press( 'ArrowRight' ); + + // Select and remove its Title inner block + await listView.getByRole( 'link', { name: 'Title' } ).click(); + await page.keyboard.press( 'Backspace' ); + + // Expect the block to have been removed with no prompt + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Title', + } ) + ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/command-center.spec.js b/test/e2e/specs/site-editor/command-center.spec.js index 9661a91a6abc78..9d22248bc2362a 100644 --- a/test/e2e/specs/site-editor/command-center.spec.js +++ b/test/e2e/specs/site-editor/command-center.spec.js @@ -3,7 +3,7 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -test.describe( 'Site editor command center', () => { +test.describe( 'Site editor command palette', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'emptytheme' ); } ); @@ -17,31 +17,34 @@ test.describe( 'Site editor command center', () => { await admin.visitSiteEditor(); } ); - test( 'Open the command center and navigate to the page create page', async ( { + test( 'Open the command palette and navigate to the page create page', async ( { page, } ) => { await page - .getByRole( 'button', { name: 'Open command center' } ) + .getByRole( 'button', { name: 'Open command palette' } ) .focus(); await page.keyboard.press( 'Meta+k' ); await page.keyboard.type( 'new page' ); await page.getByRole( 'option', { name: 'Add new page' } ).click(); await page.waitForSelector( 'iframe[name="editor-canvas"]' ); const frame = page.frame( 'editor-canvas' ); + await expect( page ).toHaveURL( + '/wp-admin/post-new.php?post_type=page' + ); await expect( frame.getByRole( 'textbox', { name: 'Add title' } ) ).toBeVisible(); } ); - test( 'Open the command center and navigate to a template', async ( { + test( 'Open the command palette and navigate to a template', async ( { page, } ) => { await page - .getByRole( 'button', { name: 'Open command center' } ) + .getByRole( 'button', { name: 'Open command palette' } ) .click(); await page.keyboard.type( 'index' ); await page.getByRole( 'option', { name: 'index' } ).click(); - await expect( page.getByRole( 'heading', { level: 2 } ) ).toHaveText( + await expect( page.getByRole( 'heading', { level: 1 } ) ).toHaveText( 'Index' ); } ); diff --git a/test/e2e/specs/site-editor/hybrid-theme.spec.js b/test/e2e/specs/site-editor/hybrid-theme.spec.js new file mode 100644 index 00000000000000..060daf508491aa --- /dev/null +++ b/test/e2e/specs/site-editor/hybrid-theme.spec.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Hybrid theme', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptyhybrid' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test( 'can access template parts list page', async ( { admin, page } ) => { + await admin.visitAdminPage( + 'site-editor.php', + 'postType=wp_template_part&path=/wp_template_part/all' + ); + + await expect( + page.getByRole( 'table' ).getByRole( 'link', { name: 'header' } ) + ).toBeVisible(); + } ); + + test( 'can view a template part', async ( { admin, editor, page } ) => { + await admin.visitAdminPage( + 'site-editor.php', + 'postType=wp_template_part&path=/wp_template_part/all' + ); + + const templatePart = page + .getByRole( 'table' ) + .getByRole( 'link', { name: 'header' } ); + + await expect( templatePart ).toBeVisible(); + await templatePart.click(); + + await expect( + page.getByRole( 'region', { name: 'Editor content' } ) + ).toBeVisible(); + + await editor.canvas.click( 'body' ); + + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Site Title', + } ) + ).toBeVisible(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/list-view.spec.js b/test/e2e/specs/site-editor/list-view.spec.js new file mode 100644 index 00000000000000..94bac47d0ecf58 --- /dev/null +++ b/test/e2e/specs/site-editor/list-view.spec.js @@ -0,0 +1,136 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Site Editor List View', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.beforeEach( async ( { admin, editor } ) => { + // Select a template part with a few blocks. + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + } ); + + test( 'should open by default when preference is enabled', async ( { + page, + } ) => { + await expect( + page.locator( 'role=region[name="List View"i]' ) + ).toBeHidden(); + + // Turn on block list view by default. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'showListViewByDefault', true ); + } ); + + await page.reload(); + + await expect( + page.locator( 'role=region[name="List View"i]' ) + ).toBeVisible(); + + // The preferences cleanup. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'showListViewByDefault', false ); + } ); + } ); + + // If list view sidebar is open and focus is not inside the sidebar, move + // focus to the sidebar when using the shortcut. If focus is inside the + // sidebar, shortcut should close the sidebar. + test( 'ensures List View global shortcut works properly', async ( { + editor, + page, + pageUtils, + } ) => { + // Current starting focus should be at Open Navigation button. + const openNavigationButton = page.getByRole( 'button', { + name: 'Open Navigation', + exact: true, + } ); + await openNavigationButton.focus(); + await expect( openNavigationButton ).toBeFocused(); + + // Open List View. + await pageUtils.pressKeys( 'access+o' ); + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + await expect( listView ).toBeVisible(); + + // The site title block should have focus. + await expect( + listView.getByRole( 'link', { + name: 'Site Title', + exact: true, + } ) + ).toBeFocused(); + + // Navigate to the site tagline block. + await page.keyboard.press( 'ArrowDown' ); + const siteTaglineItem = listView.getByRole( 'link', { + name: 'Site Tagline', + exact: true, + } ); + await expect( siteTaglineItem ).toBeFocused(); + + // Hit enter to focus the site tagline block in the canvas. + await page.keyboard.press( 'Enter' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Site Tagline', + } ) + ).toBeFocused(); + + // Since focus is now at the site tagline block in the canvas, + // pressing the list view shortcut should bring focus back to the site tagline + // block in the list view. + await pageUtils.pressKeys( 'access+o' ); + await expect( siteTaglineItem ).toBeFocused(); + + // Since focus is now inside the list view, the shortcut should close + // the sidebar. + await pageUtils.pressKeys( 'access+o' ); + await expect( listView ).not.toBeVisible(); + + // Focus should now be on the Open Navigation button since that is + // where we opened the list view sidebar. This is not a perfect + // solution, but current functionality prevents a better way at + // the moment. + await expect( openNavigationButton ).toBeFocused(); + + // Open List View. + await pageUtils.pressKeys( 'access+o' ); + await expect( listView ).toBeVisible(); + + // Focus the list view close button and make sure the shortcut will + // close the list view. This is to catch a bug where elements could be + // out of range of the sidebar region. Must shift+tab 1 time to reach + // close button before list view area. + await pageUtils.pressKeys( 'shift+Tab' ); + await expect( + page + .getByRole( 'region', { name: 'List View' } ) + .getByRole( 'button', { + name: 'Close', + } ) + ).toBeFocused(); + await pageUtils.pressKeys( 'access+o' ); + await expect( listView ).not.toBeVisible(); + await expect( openNavigationButton ).toBeFocused(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/multi-entity-saving.spec.js b/test/e2e/specs/site-editor/multi-entity-saving.spec.js new file mode 100644 index 00000000000000..b4d2f1dbda0d03 --- /dev/null +++ b/test/e2e/specs/site-editor/multi-entity-saving.spec.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Site Editor - Multi-entity save flow', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + ] ); + } ); + + test.beforeEach( async ( { admin, editor } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + } ); + await editor.canvas.click( 'body' ); + } ); + + test( 'save flow should work as expected', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Testing', + }, + } ); + + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save' } ) + ).toBeEnabled(); + await expect( + page + .getByRole( 'region', { name: 'Save panel' } ) + .getByRole( 'button', { name: 'Open save panel' } ) + ).toBeVisible(); + + await editor.saveSiteEditorEntities(); + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + } ); + + test( 'save flow should allow re-saving after changing the same block attribute', async ( { + editor, + page, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Testing', + }, + } ); + + const fontSizePicker = page + .getByRole( 'region', { name: 'Editor Settings' } ) + .getByRole( 'group', { name: 'Font size' } ); + + // Change font size. + await fontSizePicker.getByRole( 'radio', { name: 'Small' } ).click(); + + await editor.saveSiteEditorEntities(); + + // Change font size again. + await fontSizePicker.getByRole( 'radio', { name: 'Medium' } ).click(); + + // The save button has been re-enabled. + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save' } ) + ).toBeEnabled(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/pages.spec.js b/test/e2e/specs/site-editor/pages.spec.js new file mode 100644 index 00000000000000..1c0cb8c4b69686 --- /dev/null +++ b/test/e2e/specs/site-editor/pages.spec.js @@ -0,0 +1,128 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Pages', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.visitSiteEditor(); + } ); + + test( 'create a new page', async ( { page, editor } ) => { + // Draft a new page. + await page.getByRole( 'button', { name: 'Pages' } ).click(); + await page.getByRole( 'button', { name: 'Draft a new page' } ).click(); + await page + .locator( 'role=dialog[name="Draft a new page"i]' ) + .locator( 'role=textbox[name="Page title"i]' ) + .fill( 'Test Page' ); + await page.keyboard.press( 'Enter' ); + await expect( + page.locator( + `role=button[name="Dismiss this notice"i] >> text='"Test Page" successfully created.'` + ) + ).toBeVisible(); + + // Insert into Page Content using default block. + await editor.canvas + .getByRole( 'document', { + name: 'Empty block; start writing or type forward slash to choose a block', + } ) + .fill( 'Lorem ipsum dolor sit amet' ); + + // Insert into Page Content using global inserter. + await page + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + await page + .getByRole( 'option', { name: 'Heading', exact: true } ) + .click(); + await editor.canvas + .getByRole( 'document', { + name: 'Block: Heading', + } ) + .fill( 'Lorem ipsum' ); + + // Insert into Page Content using appender. + await page + .getByRole( 'region', { name: 'Editor footer' } ) + .getByRole( 'button', { name: 'Content' } ) + .click(); + await editor.canvas + .getByRole( 'button', { name: 'Add block' } ) + .click(); + await page.getByPlaceholder( 'Search' ).fill( 'list' ); + await page.getByRole( 'option', { name: 'List', exact: true } ).click(); + await page.keyboard.type( 'Lorem ipsum' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Dolor sit amet' ); + + // Selecting a block in the template should display a notice. + await editor.canvas + .getByRole( 'document', { + name: 'Block: Site Title', + } ) + .click( { force: true } ); + await expect( + page.locator( + 'role=button[name="Dismiss this notice"i] >> text="Edit your template to edit this block."' + ) + ).toBeVisible(); + + // Switch to template editing focus. + await editor.openDocumentSettingsSidebar(); + await expect( + page.locator( + '.edit-site-page-panels__edit-template-preview iframe' + ) + ).toBeVisible(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Edit template' } ) + .click(); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Content', + } ) + ).toContainText( + 'This is the Content block, it will display all the blocks in any single post or page.' + ); + await expect( + page.locator( + 'role=button[name="Dismiss this notice"i] >> text="You are editing a template."' + ) + ).toBeVisible(); + + // Edit a block that's in the template. + await editor.canvas + .getByRole( 'textbox', { name: 'Site title text' } ) + .fill( 'New Site Title' ); + + // Go back to page editing focus. + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Back' } ) + .click(); + + // Site Title and Page entities should have been modified. + await page.getByRole( 'button', { name: 'Save', exact: true } ).click(); + await expect( + page.locator( + 'role=region[name="Save panel"] >> role=checkbox[name="Title"]' + ) + ).toBeVisible(); + await expect( + page.locator( + 'role=region[name="Save panel"] >> role=checkbox[name="Test Page"]' + ) + ).toBeVisible(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/push-to-global-styles.spec.js b/test/e2e/specs/site-editor/push-to-global-styles.spec.js index 4f51cbd88aad67..937b2aea0fb454 100644 --- a/test/e2e/specs/site-editor/push-to-global-styles.spec.js +++ b/test/e2e/specs/site-editor/push-to-global-styles.spec.js @@ -5,11 +5,7 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'Push to Global Styles button', () => { test.beforeAll( async ( { requestUtils } ) => { - await Promise.all( [ - requestUtils.activateTheme( 'emptytheme' ), - requestUtils.deleteAllTemplates( 'wp_template' ), - requestUtils.deleteAllTemplates( 'wp_template_part' ), - ] ); + await requestUtils.activateTheme( 'emptytheme' ); } ); test.afterAll( async ( { requestUtils } ) => { @@ -17,7 +13,10 @@ test.describe( 'Push to Global Styles button', () => { } ); test.beforeEach( async ( { admin, editor } ) => { - await admin.visitSiteEditor(); + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + } ); await editor.canvas.click( 'body' ); } ); @@ -29,8 +28,10 @@ test.describe( 'Push to Global Styles button', () => { await editor.insertBlock( { name: 'core/heading' } ); await page.keyboard.type( 'A heading' ); + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + // Navigate to Styles -> Blocks -> Heading -> Typography - await page.getByRole( 'button', { name: 'Styles' } ).click(); + await topBar.getByRole( 'button', { name: 'Styles' } ).click(); await page.getByRole( 'button', { name: 'Blocks styles' } ).click(); await page .getByRole( 'button', { name: 'Heading block styles' } ) @@ -42,7 +43,7 @@ test.describe( 'Push to Global Styles button', () => { ).toHaveAttribute( 'aria-pressed', 'false' ); // Go to block settings and open the Advanced panel - await page.getByRole( 'button', { name: 'Settings' } ).click(); + await topBar.getByRole( 'button', { name: 'Settings' } ).click(); await page.getByRole( 'button', { name: 'Advanced' } ).click(); // Push button should be disabled diff --git a/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js b/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js index 5bd1fbd29d52b7..c16411d45ce799 100644 --- a/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js +++ b/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js @@ -62,8 +62,12 @@ test.describe( 'Site editor url navigation', () => { page, } ) => { await admin.visitSiteEditor(); - await page.click( 'role=button[name="Library"i]' ); - await page.click( 'role=button[name="Add New"i]' ); + await page.click( 'role=button[name="Patterns"i]' ); + await page.click( 'role=button[name="Create pattern"i]' ); + await page + .getByRole( 'menu', { name: 'Create pattern' } ) + .getByRole( 'menuitem', { name: 'Create template part' } ) + .click(); // Fill in a name in the dialog that pops up. await page.type( 'role=dialog >> role=textbox[name="Name"i]', diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js index 47001a48edbd10..eeaa1270f3ff9e 100644 --- a/test/e2e/specs/site-editor/style-book.spec.js +++ b/test/e2e/specs/site-editor/style-book.spec.js @@ -42,7 +42,7 @@ test.describe( 'Style Book', () => { ).not.toBeVisible(); await expect( page.locator( 'role=button[name="View"i]' ) - ).not.toBeVisible(); + ).toBeDisabled(); } ); test( 'should have tabs containing block examples', async ( { page } ) => { diff --git a/test/e2e/specs/site-editor/template-part.spec.js b/test/e2e/specs/site-editor/template-part.spec.js index 5623c1171861ef..cd81a616b1fee1 100644 --- a/test/e2e/specs/site-editor/template-part.spec.js +++ b/test/e2e/specs/site-editor/template-part.spec.js @@ -147,7 +147,7 @@ test.describe( 'Template Part', () => { await editor.selectBlocks( paragraphBlock1, paragraphBlock2 ); // Convert block to a template part. - await editor.clickBlockOptionsMenuItem( 'Create Template part' ); + await editor.clickBlockOptionsMenuItem( 'Create template part' ); await page.type( 'role=dialog >> role=textbox[name="Name"i]', 'Test' ); await page.keyboard.press( 'Enter' ); @@ -293,7 +293,8 @@ test.describe( 'Template Part', () => { await expect( paragraph ).toBeVisible(); } ); - test( 'can import a widget area into an empty template part', async ( { + // Reason: https://github.com/WordPress/gutenberg/issues/47003. + test.skip( 'can import a widget area into an empty template part', async ( { admin, editor, page, @@ -391,7 +392,7 @@ test.describe( 'Template Part', () => { await editor.selectBlocks( siteTitleInGroup ); // Change heading level of the Site Title block. - await editor.clickBlockToolbarButton( 'Change heading level' ); + await editor.clickBlockToolbarButton( 'Change level' ); const Heading3Button = page.getByRole( 'menuitemradio', { name: 'Heading 3', } ); @@ -401,7 +402,7 @@ test.describe( 'Template Part', () => { await pageUtils.pressKeys( 'primary+z' ); await expect( - page.locator( 'role=button[name="Change heading level"i]' ) + page.locator( 'role=button[name="Change level"i]' ) ).toBeFocused(); } ); } ); diff --git a/test/e2e/specs/site-editor/template-parts-mode.spec.js b/test/e2e/specs/site-editor/template-parts-mode.spec.js deleted file mode 100644 index a8945794ae6a8d..00000000000000 --- a/test/e2e/specs/site-editor/template-parts-mode.spec.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * WordPress dependencies - */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); - -test.describe( 'Template Parts for Classic themes', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'emptyhybrid' ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test( 'can access template parts list page', async ( { admin, page } ) => { - await admin.visitAdminPage( - 'site-editor.php', - 'postType=wp_template_part&path=/wp_template_part/all' - ); - - await expect( - page.getByRole( 'table' ).getByRole( 'link', { name: 'header' } ) - ).toBeVisible(); - } ); - - test( 'can view a template part', async ( { admin, editor, page } ) => { - await admin.visitAdminPage( - 'site-editor.php', - 'postType=wp_template_part&path=/wp_template_part/all' - ); - - const templatePart = page - .getByRole( 'table' ) - .getByRole( 'link', { name: 'header' } ); - - await expect( templatePart ).toBeVisible(); - await templatePart.click(); - - await expect( - page.getByRole( 'region', { name: 'Editor content' } ) - ).toBeVisible(); - - await editor.canvas.click( 'body' ); - - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Site Title', - } ) - ).toBeVisible(); - } ); -} ); diff --git a/test/e2e/specs/site-editor/title.spec.js b/test/e2e/specs/site-editor/title.spec.js index 21cfc544829705..aa2942670c5a85 100644 --- a/test/e2e/specs/site-editor/title.spec.js +++ b/test/e2e/specs/site-editor/title.spec.js @@ -26,7 +26,7 @@ test.describe( 'Site editor title', () => { 'role=region[name="Editor top bar"i] >> role=heading[level=1]' ); - await expect( title ).toHaveText( 'Editing template: Index' ); + await expect( title ).toHaveText( 'Editing template:Index' ); } ); test( 'displays the selected template name in the title for the header template', async ( { @@ -43,6 +43,6 @@ test.describe( 'Site editor title', () => { 'role=region[name="Editor top bar"i] >> role=heading[level=1]' ); - await expect( title ).toHaveText( 'Editing template part: header' ); + await expect( title ).toHaveText( 'Editing template part:header' ); } ); } ); diff --git a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js index e9d5fb0af833a2..73d49741b825b3 100644 --- a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js +++ b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js @@ -22,16 +22,16 @@ test.describe( 'Global styles revisions', () => { await requestUtils.activateTheme( 'twentytwentyone' ); } ); - test.beforeEach( async ( { admin, editor } ) => { + test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor(); - await editor.canvas.click( 'body' ); } ); - test( 'should display revisions UI when there is more than 1 revision', async ( { + test( 'should display revisions UI when there is 1 revision', async ( { page, editor, userGlobalStylesRevisions, } ) => { + await editor.canvas.click( 'body' ); const currentRevisions = await userGlobalStylesRevisions.getGlobalStylesRevisions(); await userGlobalStylesRevisions.openStylesPanel(); @@ -39,19 +39,6 @@ test.describe( 'Global styles revisions', () => { // Change a style and save it. await page.getByRole( 'button', { name: 'Colors styles' } ).click(); - await page - .getByRole( 'button', { name: 'Color Background styles' } ) - .click(); - await page - .getByRole( 'button', { name: 'Color: Black' } ) - .click( { force: true } ); - - await editor.saveSiteEditorEntities(); - - /* - * Change a style and save it again. - * We need more than 2 revisions to show the UI. - */ await page .getByRole( 'button', { name: 'Color Background styles' } ) .click(); @@ -68,8 +55,9 @@ test.describe( 'Global styles revisions', () => { name: /^Changes saved by /, } ); + // There should be 2 revisions not including the reset to theme defaults button. await expect( revisionButtons ).toHaveCount( - currentRevisions.length + 2 + currentRevisions.length + 1 ); } ); @@ -78,6 +66,7 @@ test.describe( 'Global styles revisions', () => { editor, userGlobalStylesRevisions, } ) => { + await editor.canvas.click( 'body' ); await userGlobalStylesRevisions.openStylesPanel(); await page.getByRole( 'button', { name: 'Colors styles' } ).click(); await page @@ -105,7 +94,7 @@ test.describe( 'Global styles revisions', () => { const confirm = page.getByRole( 'dialog' ); await expect( confirm ).toBeVisible(); await expect( confirm ).toHaveText( - /^Loading this revision will discard all unsaved changes/ + /^Any unsaved changes will be lost when you apply this revision./ ); // This is to make sure there are no lingering unsaved changes. @@ -115,6 +104,25 @@ test.describe( 'Global styles revisions', () => { .click(); await editor.saveSiteEditorEntities(); } ); + + test( 'should have a reset to defaults button', async ( { + page, + editor, + userGlobalStylesRevisions, + } ) => { + await editor.canvas.click( 'body' ); + await userGlobalStylesRevisions.openStylesPanel(); + await userGlobalStylesRevisions.openRevisions(); + const lastRevisionButton = page + .getByLabel( 'Global styles revisions' ) + .getByRole( 'button' ) + .last(); + await expect( lastRevisionButton ).toContainText( 'Default styles' ); + await lastRevisionButton.click(); + await expect( + page.getByRole( 'button', { name: 'Reset to defaults' } ) + ).toBeVisible(); + } ); } ); class UserGlobalStylesRevisions { @@ -136,9 +144,12 @@ class UserGlobalStylesRevisions { async openRevisions() { await this.page - .getByRole( 'button', { name: 'Styles actions' } ) + .getByRole( 'menubar', { name: 'Styles actions' } ) + .click(); + await this.page.getByRole( 'button', { name: 'Revisions' } ).click(); + await this.page + .getByRole( 'menuitem', { name: /^Revision history/ } ) .click(); - await this.page.getByRole( 'menuitem', { name: 'Revisions' } ).click(); } async openStylesPanel() { diff --git a/test/e2e/specs/widgets/customizing-widgets.spec.js b/test/e2e/specs/widgets/customizing-widgets.spec.js index 110ce28948f228..8b2a585dc4e66d 100644 --- a/test/e2e/specs/widgets/customizing-widgets.spec.js +++ b/test/e2e/specs/widgets/customizing-widgets.spec.js @@ -36,7 +36,11 @@ test.describe( 'Widgets Customizer', () => { await requestUtils.activateTheme( 'twentytwentyone' ); } ); - test( 'should add blocks', async ( { page, widgetsCustomizerPage } ) => { + test( 'should add blocks', async ( { + page, + widgetsCustomizerPage, + editor, + } ) => { const previewFrame = widgetsCustomizerPage.previewFrame; await widgetsCustomizerPage.visitCustomizerPage(); @@ -82,7 +86,7 @@ test.describe( 'Widgets Customizer', () => { await page.click( 'role=option[name="Search"i]' ); - await page.focus( + await editor.canvas.focus( 'role=document[name="Block: Search"i] >> role=textbox[name="Label text"i]' ); @@ -229,6 +233,7 @@ test.describe( 'Widgets Customizer', () => { page, requestUtils, widgetsCustomizerPage, + editor, } ) => { await requestUtils.addWidgetBlock( `<!-- wp:paragraph -->\n<p>First Paragraph</p>\n<!-- /wp:paragraph -->`, @@ -277,7 +282,7 @@ test.describe( 'Widgets Customizer', () => { await headingWidget.click(); // noop click on the widget text to unfocus the editor and hide toolbar await editHeadingWidget.click(); - const headingBlock = page.locator( + const headingBlock = editor.canvas.locator( 'role=document[name="Block: Heading"i] >> text="First Heading"' ); await expect( headingBlock ).toBeFocused(); @@ -583,12 +588,13 @@ test.describe( 'Widgets Customizer', () => { test( 'preserves content in the Custom HTML block', async ( { page, widgetsCustomizerPage, + editor, } ) => { await widgetsCustomizerPage.visitCustomizerPage(); await widgetsCustomizerPage.expandWidgetArea( 'Footer #1' ); await widgetsCustomizerPage.addBlock( 'Custom HTML' ); - const HTMLBlockTextarea = page.locator( + const HTMLBlockTextarea = editor.canvas.locator( 'role=document[name="Block: Custom HTML"i] >> role=textbox[name="HTML"i]' ); await HTMLBlockTextarea.type( 'hello' ); diff --git a/test/gutenberg-test-themes/behaviors-enabled/templates/single.html b/test/gutenberg-test-themes/behaviors-enabled/templates/single.html new file mode 100644 index 00000000000000..3d3bd7cfb9613a --- /dev/null +++ b/test/gutenberg-test-themes/behaviors-enabled/templates/single.html @@ -0,0 +1 @@ +<!-- wp:post-content {"layout":{"type":"constrained"}} /--> diff --git a/test/gutenberg-test-themes/behaviors-enabled/theme.json b/test/gutenberg-test-themes/behaviors-enabled/theme.json index f49129622d9f6d..8e7ce39023fd35 100644 --- a/test/gutenberg-test-themes/behaviors-enabled/theme.json +++ b/test/gutenberg-test-themes/behaviors-enabled/theme.json @@ -3,7 +3,10 @@ "behaviors": { "blocks": { "core/image": { - "lightbox": true + "lightbox": { + "enabled": true, + "animation": "zoom" + } } } } diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json index a9f920f6dd0abc..cc4b0882fd22c3 100644 --- a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json +++ b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json @@ -3,9 +3,7 @@ "settings": { "blocks": { "core/image": { - "behaviors": { - "lightbox": false - } + "behaviors": false } } } diff --git a/test/integration/blocks-raw-handling.test.js b/test/integration/blocks-raw-handling.test.js index 2a31d0b0ceaa28..733ae308c851ae 100644 --- a/test/integration/blocks-raw-handling.test.js +++ b/test/integration/blocks-raw-handling.test.js @@ -291,9 +291,11 @@ describe( 'Blocks raw handling', () => { HTML: '<h1>FOO</h1>', plainText: 'FOO\n', mode: 'AUTO', - } ); + } ) + .map( getBlockContent ) + .join( '' ); - expect( filtered ).toBe( 'FOO' ); + expect( filtered ).toBe( '<h1 class="wp-block-heading">FOO</h1>' ); expect( console ).toHaveLogged(); } ); diff --git a/test/integration/fixtures/blocks/core__details.html b/test/integration/fixtures/blocks/core__details.html new file mode 100644 index 00000000000000..855ea3f0a4f556 --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.html @@ -0,0 +1,7 @@ +<!-- wp:details {"summary":"Details Summary"} --> +<details class="wp-block-details"><summary>Details Summary</summary> + <!-- wp:paragraph {"placeholder":"Type / to add a hidden block"} --> + <p>Details Content</p> + <!-- /wp:paragraph --> +</details> +<!-- /wp:details --> diff --git a/test/integration/fixtures/blocks/core__details.json b/test/integration/fixtures/blocks/core__details.json new file mode 100644 index 00000000000000..e3873e4702db3f --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.json @@ -0,0 +1,22 @@ +[ + { + "name": "core/details", + "isValid": true, + "attributes": { + "showContent": false, + "summary": "Details Summary" + }, + "innerBlocks": [ + { + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": "Details Content", + "dropCap": false, + "placeholder": "Type / to add a hidden block" + }, + "innerBlocks": [] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__details.parsed.json b/test/integration/fixtures/blocks/core__details.parsed.json new file mode 100644 index 00000000000000..3240c013e8e866 --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.parsed.json @@ -0,0 +1,25 @@ +[ + { + "blockName": "core/details", + "attrs": { + "summary": "Details Summary" + }, + "innerBlocks": [ + { + "blockName": "core/paragraph", + "attrs": { + "placeholder": "Type / to add a hidden block" + }, + "innerBlocks": [], + "innerHTML": "\n\t<p>Details Content</p>\n\t", + "innerContent": [ "\n\t<p>Details Content</p>\n\t" ] + } + ], + "innerHTML": "\n<details class=\"wp-block-details\"><summary>Details Summary</summary>\n\t\n</details>\n", + "innerContent": [ + "\n<details class=\"wp-block-details\"><summary>Details Summary</summary>\n\t", + null, + "\n</details>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__details.serialized.html b/test/integration/fixtures/blocks/core__details.serialized.html new file mode 100644 index 00000000000000..a3e979414ac0d0 --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.serialized.html @@ -0,0 +1,5 @@ +<!-- wp:details --> +<details class="wp-block-details"><summary>Details Summary</summary><!-- wp:paragraph {"placeholder":"Type / to add a hidden block"} --> +<p>Details Content</p> +<!-- /wp:paragraph --></details> +<!-- /wp:details --> diff --git a/test/integration/fixtures/blocks/core__footnotes.html b/test/integration/fixtures/blocks/core__footnotes.html new file mode 100644 index 00000000000000..2a8de4f0687041 --- /dev/null +++ b/test/integration/fixtures/blocks/core__footnotes.html @@ -0,0 +1 @@ +<!-- wp:footnotes /--> diff --git a/test/integration/fixtures/blocks/core__footnotes.json b/test/integration/fixtures/blocks/core__footnotes.json new file mode 100644 index 00000000000000..eba2053ad810f4 --- /dev/null +++ b/test/integration/fixtures/blocks/core__footnotes.json @@ -0,0 +1,8 @@ +[ + { + "name": "core/footnotes", + "isValid": true, + "attributes": {}, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__footnotes.parsed.json b/test/integration/fixtures/blocks/core__footnotes.parsed.json new file mode 100644 index 00000000000000..22375acad02853 --- /dev/null +++ b/test/integration/fixtures/blocks/core__footnotes.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "core/footnotes", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__footnotes.serialized.html b/test/integration/fixtures/blocks/core__footnotes.serialized.html new file mode 100644 index 00000000000000..2a8de4f0687041 --- /dev/null +++ b/test/integration/fixtures/blocks/core__footnotes.serialized.html @@ -0,0 +1 @@ +<!-- wp:footnotes /--> diff --git a/test/integration/fixtures/blocks/core__gallery__deprecated-7.serialized.html b/test/integration/fixtures/blocks/core__gallery__deprecated-7.serialized.html index 0aed527cce98c9..b0f29b4126b333 100644 --- a/test/integration/fixtures/blocks/core__gallery__deprecated-7.serialized.html +++ b/test/integration/fixtures/blocks/core__gallery__deprecated-7.serialized.html @@ -12,7 +12,7 @@ <!-- /wp:image --></figure> <!-- /wp:gallery --> -<!-- wp:gallery {"ids":[],"shortCodeTransforms":[],"linkTo":"media"} --> +<!-- wp:gallery {"linkTo":"media"} --> <figure class="wp-block-gallery has-nested-images columns-default is-cropped"><!-- wp:image {"id":705,"sizeSlug":"large","linkDestination":"media"} --> <figure class="wp-block-image size-large"><a href="http://wptest.local/wp-content/uploads/2020/09/test-image-edited-1-682x1024.jpg"><img src="http://wptest.local/wp-content/uploads/2020/09/test-image-edited-1-682x1024.jpg" alt="" class="wp-image-705"/></a></figure> <!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-1.json b/test/integration/fixtures/blocks/core__image__deprecated-1.json deleted file mode 100644 index e5d1dcba576aaf..00000000000000 --- a/test/integration/fixtures/blocks/core__image__deprecated-1.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "name": "core/image", - "isValid": true, - "attributes": { - "align": "left", - "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", - "alt": "", - "caption": "" - }, - "innerBlocks": [] - } -] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-2.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-2.serialized.html deleted file mode 100644 index eb06f249748638..00000000000000 --- a/test/integration/fixtures/blocks/core__image__deprecated-2.serialized.html +++ /dev/null @@ -1,3 +0,0 @@ -<!-- wp:image {"align":"left","width":100,"height":100} --> -<figure class="wp-block-image alignleft is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" width="100" height="100"/></figure> -<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-3.json b/test/integration/fixtures/blocks/core__image__deprecated-3.json deleted file mode 100644 index bae213510011ac..00000000000000 --- a/test/integration/fixtures/blocks/core__image__deprecated-3.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "name": "core/image", - "isValid": true, - "attributes": { - "align": "left", - "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", - "alt": "", - "caption": "", - "width": 100, - "height": 100 - }, - "innerBlocks": [] - } -] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-3.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-3.serialized.html deleted file mode 100644 index eb06f249748638..00000000000000 --- a/test/integration/fixtures/blocks/core__image__deprecated-3.serialized.html +++ /dev/null @@ -1,3 +0,0 @@ -<!-- wp:image {"align":"left","width":100,"height":100} --> -<figure class="wp-block-image alignleft is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" width="100" height="100"/></figure> -<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.serialized.html deleted file mode 100644 index 5ba1eb754e83f6..00000000000000 --- a/test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.serialized.html +++ /dev/null @@ -1,3 +0,0 @@ -<!-- wp:image {"align":"left","id":13,"width":358,"height":164,"sizeSlug":"large","linkDestination":"none"} --> -<figure class="wp-block-image alignleft size-large is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" class="wp-image-13" width="358" height="164"/></figure> -<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-1.html b/test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-1.html rename to test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.json b/test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.json new file mode 100644 index 00000000000000..6b2f1a80f52c6d --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.json @@ -0,0 +1,13 @@ +[ + { + "name": "core/image", + "isValid": true, + "attributes": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", + "alt": "", + "caption": [], + "align": "left" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-1.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.parsed.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-1.parsed.json rename to test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.parsed.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-1.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.serialized.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-1.serialized.html rename to test/integration/fixtures/blocks/core__image__deprecated-v1-add-responsive-class.serialized.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-2.html b/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-2.html rename to test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.json b/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.json new file mode 100644 index 00000000000000..a0ad3b755587b8 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.json @@ -0,0 +1,15 @@ +[ + { + "name": "core/image", + "isValid": true, + "attributes": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", + "alt": "", + "caption": [], + "align": "left", + "width": 100, + "height": 100 + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-2.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.parsed.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-2.parsed.json rename to test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.parsed.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.serialized.html new file mode 100644 index 00000000000000..9a66da6c018989 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v2-add-is-resized-class.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"align":"left","width":100,"height":100} --> +<figure class="wp-block-image alignleft is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" style="width:100px;height:100px"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-3.html b/test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-3.html rename to test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-2.json b/test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-2.json rename to test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-3.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.parsed.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-3.parsed.json rename to test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.parsed.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.serialized.html new file mode 100644 index 00000000000000..9a66da6c018989 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v3-add-align-wrapper.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"align":"left","width":100,"height":100} --> +<figure class="wp-block-image alignleft is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" style="width:100px;height:100px"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.html b/test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.html rename to test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.json b/test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.json rename to test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.parsed.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-align-wrapper.parsed.json rename to test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.parsed.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.serialized.html new file mode 100644 index 00000000000000..99da2155bce88f --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v4-remove-align-wrapper.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"align":"left","id":13,"width":358,"height":164,"sizeSlug":"large","linkDestination":"none"} --> +<figure class="wp-block-image alignleft size-large is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" class="wp-image-13" style="width:358px;height:164px"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.html b/test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.html rename to test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.json b/test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.json rename to test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.parsed.json similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.parsed.json rename to test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.parsed.json diff --git a/test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.serialized.html similarity index 100% rename from test/integration/fixtures/blocks/core__image__deprecated-border-radius-5.serialized.html rename to test/integration/fixtures/blocks/core__image__deprecated-v5-move-border-radius-style.serialized.html diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.html b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.html new file mode 100644 index 00000000000000..6c113ee0f09ee0 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.html @@ -0,0 +1,3 @@ +<!-- wp:image {"align":"left","width":164,"height":164,"sizeSlug":"large","style":{"border":{"radius":"100%"}},"className":"is-style-rounded"} --> +<figure class="wp-block-image alignleft size-large is-resized has-custom-border is-style-rounded"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" style="border-radius:100%" width="164" height="164"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.json b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.json new file mode 100644 index 00000000000000..7f83baa81fc635 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.json @@ -0,0 +1,22 @@ +[ + { + "name": "core/image", + "isValid": true, + "attributes": { + "align": "left", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", + "alt": "", + "caption": "", + "width": 164, + "height": 164, + "sizeSlug": "large", + "className": "is-style-rounded", + "style": { + "border": { + "radius": "100%" + } + } + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.parsed.json new file mode 100644 index 00000000000000..e9c061bb19125e --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.parsed.json @@ -0,0 +1,22 @@ +[ + { + "blockName": "core/image", + "attrs": { + "align": "left", + "width": 164, + "height": 164, + "sizeSlug": "large", + "style": { + "border": { + "radius": "100%" + } + }, + "className": "is-style-rounded" + }, + "innerBlocks": [], + "innerHTML": "\n<figure class=\"wp-block-image alignleft size-large is-resized has-custom-border is-style-rounded\"><img src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==\" alt=\"\" style=\"border-radius:100%\" width=\"164\" height=\"164\"/></figure>\n", + "innerContent": [ + "\n<figure class=\"wp-block-image alignleft size-large is-resized has-custom-border is-style-rounded\"><img src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==\" alt=\"\" style=\"border-radius:100%\" width=\"164\" height=\"164\"/></figure>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.serialized.html new file mode 100644 index 00000000000000..807ba3abc9f9ce --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v6-add-style-width-height.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"align":"left","width":164,"height":164,"sizeSlug":"large","className":"is-style-rounded","style":{"border":{"radius":"100%"}}} --> +<figure class="wp-block-image alignleft size-large is-resized has-custom-border is-style-rounded"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" style="border-radius:100%;width:164px;height:164px"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.html b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.html new file mode 100644 index 00000000000000..7210aee3564b5f --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.html @@ -0,0 +1,3 @@ +<!-- wp:image {"width":164,"height":164,"sizeSlug":"large"} --> +<figure class="wp-block-image size-large is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" style="width:164px;height:164px;" width="164" height="164"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.json b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.json new file mode 100644 index 00000000000000..b49c515f8e5ab6 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.json @@ -0,0 +1,15 @@ +[ + { + "name": "core/image", + "isValid": true, + "attributes": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", + "alt": "", + "caption": "", + "sizeSlug": "large", + "width": "164px", + "height": "164px" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.parsed.json b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.parsed.json new file mode 100644 index 00000000000000..fbd8627807b3da --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.parsed.json @@ -0,0 +1,15 @@ +[ + { + "blockName": "core/image", + "attrs": { + "width": 164, + "height": 164, + "sizeSlug": "large" + }, + "innerBlocks": [], + "innerHTML": "\n<figure class=\"wp-block-image size-large is-resized\"><img src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==\" alt=\"\" style=\"width:164px;height:164px;\" width=\"164\" height=\"164\"/></figure>\n", + "innerContent": [ + "\n<figure class=\"wp-block-image size-large is-resized\"><img src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==\" alt=\"\" style=\"width:164px;height:164px;\" width=\"164\" height=\"164\"/></figure>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.serialized.html b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.serialized.html new file mode 100644 index 00000000000000..5392d261af89d6 --- /dev/null +++ b/test/integration/fixtures/blocks/core__image__deprecated-v7-string-width-height-attributes.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"width":"164px","height":"164px","sizeSlug":"large"} --> +<figure class="wp-block-image size-large is-resized"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" alt="" style="width:164px;height:164px"/></figure> +<!-- /wp:image --> diff --git a/test/integration/fixtures/blocks/core__list__ol-with-type.html b/test/integration/fixtures/blocks/core__list__ol-with-type.html new file mode 100644 index 00000000000000..9969965e5a56ce --- /dev/null +++ b/test/integration/fixtures/blocks/core__list__ol-with-type.html @@ -0,0 +1,7 @@ +<!-- wp:list {"ordered":true,"type":"A"} --> +<ol type="A"> + <!-- wp:list-item --> + <li>Item 1</li> + <!-- /wp:list-item --> +</ol> +<!-- /wp:list --> diff --git a/test/integration/fixtures/blocks/core__list__ol-with-type.json b/test/integration/fixtures/blocks/core__list__ol-with-type.json new file mode 100644 index 00000000000000..a15b1546b0ed4c --- /dev/null +++ b/test/integration/fixtures/blocks/core__list__ol-with-type.json @@ -0,0 +1,21 @@ +[ + { + "name": "core/list", + "isValid": true, + "attributes": { + "ordered": true, + "values": "", + "type": "A" + }, + "innerBlocks": [ + { + "name": "core/list-item", + "isValid": true, + "attributes": { + "content": "Item 1" + }, + "innerBlocks": [] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__list__ol-with-type.parsed.json b/test/integration/fixtures/blocks/core__list__ol-with-type.parsed.json new file mode 100644 index 00000000000000..7ddda9e08da5ab --- /dev/null +++ b/test/integration/fixtures/blocks/core__list__ol-with-type.parsed.json @@ -0,0 +1,20 @@ +[ + { + "blockName": "core/list", + "attrs": { + "ordered": true, + "type": "A" + }, + "innerBlocks": [ + { + "blockName": "core/list-item", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n\t<li>Item 1</li>\n\t", + "innerContent": [ "\n\t<li>Item 1</li>\n\t" ] + } + ], + "innerHTML": "\n<ol type=\"A\">\n\t\n</ol>\n", + "innerContent": [ "\n<ol type=\"A\">\n\t", null, "\n</ol>\n" ] + } +] diff --git a/test/integration/fixtures/blocks/core__list__ol-with-type.serialized.html b/test/integration/fixtures/blocks/core__list__ol-with-type.serialized.html new file mode 100644 index 00000000000000..a785d55d92bcee --- /dev/null +++ b/test/integration/fixtures/blocks/core__list__ol-with-type.serialized.html @@ -0,0 +1,5 @@ +<!-- wp:list {"ordered":true,"type":"A"} --> +<ol type="A"><!-- wp:list-item --> +<li>Item 1</li> +<!-- /wp:list-item --></ol> +<!-- /wp:list --> diff --git a/test/integration/fixtures/blocks/core__media-text__deprecated-v2.html b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.html new file mode 100644 index 00000000000000..e258c04a8a8f83 --- /dev/null +++ b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.html @@ -0,0 +1,12 @@ +<!-- wp:media-text {"mediaType":"image"} --> +<div class="wp-block-media-text"> + <figure class="wp-block-media-text__media"> + <img src="data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=" alt=""/> + </figure> + <div class="wp-block-media-text__content"> + <!-- wp:paragraph {"placeholder":"Content…"} --> + <p>My Content</p> + <!-- /wp:paragraph --> + </div> +</div> +<!-- /wp:media-text --> diff --git a/test/integration/fixtures/blocks/core__media-text__deprecated-v2.json b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.json new file mode 100644 index 00000000000000..ca2b0016872687 --- /dev/null +++ b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.json @@ -0,0 +1,27 @@ +[ + { + "name": "core/media-text", + "isValid": true, + "attributes": { + "align": "wide", + "mediaAlt": "", + "mediaPosition": "left", + "mediaType": "image", + "mediaWidth": 50, + "isStackedOnMobile": false, + "mediaUrl": "data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=" + }, + "innerBlocks": [ + { + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": "My Content", + "dropCap": false, + "placeholder": "Content…" + }, + "innerBlocks": [] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__media-text__deprecated-v2.parsed.json b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.parsed.json new file mode 100644 index 00000000000000..46b59fa2cac5f3 --- /dev/null +++ b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.parsed.json @@ -0,0 +1,25 @@ +[ + { + "blockName": "core/media-text", + "attrs": { + "mediaType": "image" + }, + "innerBlocks": [ + { + "blockName": "core/paragraph", + "attrs": { + "placeholder": "Content…" + }, + "innerBlocks": [], + "innerHTML": "\n\t\t<p>My Content</p>\n\t\t", + "innerContent": [ "\n\t\t<p>My Content</p>\n\t\t" ] + } + ], + "innerHTML": "\n<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=\" alt=\"\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>\n", + "innerContent": [ + "\n<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=\" alt=\"\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t", + null, + "\n\t</div>\n</div>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__media-text__deprecated-v2.serialized.html b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.serialized.html new file mode 100644 index 00000000000000..0c0fb613d64b53 --- /dev/null +++ b/test/integration/fixtures/blocks/core__media-text__deprecated-v2.serialized.html @@ -0,0 +1,5 @@ +<!-- wp:media-text {"align":"wide","mediaType":"image","isStackedOnMobile":false} --> +<div class="wp-block-media-text alignwide"><figure class="wp-block-media-text__media"><img src="data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=" alt=""/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"Content…"} --> +<p>My Content</p> +<!-- /wp:paragraph --></div></div> +<!-- /wp:media-text --> diff --git a/test/integration/fixtures/blocks/core__query-pagination-numbers.json b/test/integration/fixtures/blocks/core__query-pagination-numbers.json index e5ef189320be2f..16ac2fb469f98a 100644 --- a/test/integration/fixtures/blocks/core__query-pagination-numbers.json +++ b/test/integration/fixtures/blocks/core__query-pagination-numbers.json @@ -2,7 +2,9 @@ { "name": "core/query-pagination-numbers", "isValid": true, - "attributes": {}, + "attributes": { + "midSize": 2 + }, "innerBlocks": [] } ] diff --git a/test/integration/fixtures/blocks/core__query-pagination.json b/test/integration/fixtures/blocks/core__query-pagination.json index 66b604f14b8a17..49d6992bac3a53 100644 --- a/test/integration/fixtures/blocks/core__query-pagination.json +++ b/test/integration/fixtures/blocks/core__query-pagination.json @@ -3,7 +3,8 @@ "name": "core/query-pagination", "isValid": true, "attributes": { - "paginationArrow": "none" + "paginationArrow": "none", + "showLabel": true }, "innerBlocks": [] } diff --git a/test/integration/fixtures/blocks/core__query.json b/test/integration/fixtures/blocks/core__query.json index 4c7ce920a04506..b050aaa2b5b1fd 100644 --- a/test/integration/fixtures/blocks/core__query.json +++ b/test/integration/fixtures/blocks/core__query.json @@ -19,9 +19,7 @@ "parents": [] }, "tagName": "div", - "displayLayout": { - "type": "list" - } + "enhancedPagination": false }, "innerBlocks": [] } diff --git a/test/integration/fixtures/blocks/core__query.serialized.html b/test/integration/fixtures/blocks/core__query.serialized.html index 049ea7dd2bb73d..3bc4085f4f090d 100644 --- a/test/integration/fixtures/blocks/core__query.serialized.html +++ b/test/integration/fixtures/blocks/core__query.serialized.html @@ -1,3 +1,3 @@ -<!-- wp:query {"query":{"perPage":null,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true,"taxQuery":null,"parents":[]},"displayLayout":{"type":"list"}} --> +<!-- wp:query --> <div class="wp-block-query"></div> <!-- /wp:query --> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-1.serialized.html b/test/integration/fixtures/blocks/core__query__deprecated-1.serialized.html index 39f889cfae97e1..915726d992a8f9 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-1.serialized.html +++ b/test/integration/fixtures/blocks/core__query__deprecated-1.serialized.html @@ -1,3 +1,3 @@ -<!-- wp:query {"query":{"perPage":null,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"displayLayout":{"type":"list"}} --> +<!-- wp:query {"query":{"perPage":null,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true}} --> <div class="wp-block-query"></div> <!-- /wp:query --> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.json b/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.json index 82bc41a40fb1b5..8a048667f55afd 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.json +++ b/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.json @@ -21,10 +21,7 @@ "post_tag": [ 6 ] } }, - "tagName": "div", - "displayLayout": { - "type": "list" - } + "tagName": "div" }, "innerBlocks": [ { @@ -50,7 +47,11 @@ { "name": "core/post-template", "isValid": true, - "attributes": {}, + "attributes": { + "layout": { + "type": "default" + } + }, "innerBlocks": [ { "name": "core/post-title", diff --git a/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.serialized.html b/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.serialized.html index f86b4f26ecc1d1..b9e6b50deb0677 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.serialized.html +++ b/test/integration/fixtures/blocks/core__query__deprecated-2-with-colors.serialized.html @@ -1,6 +1,6 @@ -<!-- wp:query {"queryId":19,"query":{"perPage":3,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":false,"taxQuery":{"category":[3,5],"post_tag":[6]}},"displayLayout":{"type":"list"}} --> +<!-- wp:query {"queryId":19,"query":{"perPage":3,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":false,"taxQuery":{"category":[3,5],"post_tag":[6]}}} --> <div class="wp-block-query"><!-- wp:group {"textColor":"pale-cyan-blue","style":{"color":{"background":"#284d5f"},"elements":{"link":{"color":{"text":"var:preset|color|luminous-vivid-amber"}}}}} --> -<div class="wp-block-group has-pale-cyan-blue-color has-text-color has-background has-link-color" style="background-color:#284d5f"><!-- wp:post-template --> +<div class="wp-block-group has-pale-cyan-blue-color has-text-color has-background has-link-color" style="background-color:#284d5f"><!-- wp:post-template {"layout":{"type":"default"}} --> <!-- wp:post-title /--> <!-- /wp:post-template --></div> <!-- /wp:group --></div> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-2.json b/test/integration/fixtures/blocks/core__query__deprecated-2.json index a63ad1c007b6b1..b0a1aea41ea506 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-2.json +++ b/test/integration/fixtures/blocks/core__query__deprecated-2.json @@ -21,16 +21,17 @@ "post_tag": [ 6 ] } }, - "tagName": "div", - "displayLayout": { - "type": "list" - } + "tagName": "div" }, "innerBlocks": [ { "name": "core/post-template", "isValid": true, - "attributes": {}, + "attributes": { + "layout": { + "type": "default" + } + }, "innerBlocks": [ { "name": "core/post-title", diff --git a/test/integration/fixtures/blocks/core__query__deprecated-2.serialized.html b/test/integration/fixtures/blocks/core__query__deprecated-2.serialized.html index 5804c54e577f14..2016bea9635928 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-2.serialized.html +++ b/test/integration/fixtures/blocks/core__query__deprecated-2.serialized.html @@ -1,5 +1,5 @@ -<!-- wp:query {"queryId":19,"query":{"perPage":3,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":false,"taxQuery":{"category":[3,5],"post_tag":[6]}},"displayLayout":{"type":"list"}} --> -<div class="wp-block-query"><!-- wp:post-template --> +<!-- wp:query {"queryId":19,"query":{"perPage":3,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":false,"taxQuery":{"category":[3,5],"post_tag":[6]}}} --> +<div class="wp-block-query"><!-- wp:post-template {"layout":{"type":"default"}} --> <!-- wp:post-title /--> <!-- /wp:post-template --></div> <!-- /wp:query --> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-3.json b/test/integration/fixtures/blocks/core__query__deprecated-3.json index 2c682de49bdda6..d41ce2c5c826b7 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-3.json +++ b/test/integration/fixtures/blocks/core__query__deprecated-3.json @@ -18,10 +18,6 @@ "inherit": false }, "tagName": "div", - "displayLayout": { - "type": "flex", - "columns": 3 - }, "align": "wide" }, "innerBlocks": [ @@ -49,7 +45,11 @@ "name": "core/post-template", "isValid": true, "attributes": { - "fontSize": "large" + "fontSize": "large", + "layout": { + "type": "grid", + "columnCount": 3 + } }, "innerBlocks": [ { @@ -87,7 +87,8 @@ "name": "core/query-pagination", "isValid": true, "attributes": { - "paginationArrow": "none" + "paginationArrow": "none", + "showLabel": true }, "innerBlocks": [ { @@ -99,7 +100,9 @@ { "name": "core/query-pagination-numbers", "isValid": true, - "attributes": {}, + "attributes": { + "midSize": 2 + }, "innerBlocks": [] }, { diff --git a/test/integration/fixtures/blocks/core__query__deprecated-3.serialized.html b/test/integration/fixtures/blocks/core__query__deprecated-3.serialized.html index edbf5b1a0557b3..86c87dde71c3bd 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-3.serialized.html +++ b/test/integration/fixtures/blocks/core__query__deprecated-3.serialized.html @@ -1,6 +1,6 @@ -<!-- wp:query {"queryId":3,"query":{"perPage":3,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":false},"displayLayout":{"type":"flex","columns":3},"align":"wide"} --> +<!-- wp:query {"queryId":3,"query":{"perPage":3,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":false},"align":"wide"} --> <div class="wp-block-query alignwide"><!-- wp:group {"textColor":"pale-cyan-blue","style":{"color":{"background":"#284d5f"},"elements":{"link":{"color":{"text":"var:preset|color|luminous-vivid-amber"}}}}} --> -<div class="wp-block-group has-pale-cyan-blue-color has-text-color has-background has-link-color" style="background-color:#284d5f"><!-- wp:post-template {"fontSize":"large"} --> +<div class="wp-block-group has-pale-cyan-blue-color has-text-color has-background has-link-color" style="background-color:#284d5f"><!-- wp:post-template {"fontSize":"large","layout":{"type":"grid","columnCount":3}} --> <!-- wp:post-title /--> <!-- wp:post-date /--> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-4.html b/test/integration/fixtures/blocks/core__query__deprecated-4.html index 20aec9638688e5..9a2b39db018f56 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-4.html +++ b/test/integration/fixtures/blocks/core__query__deprecated-4.html @@ -1,4 +1,4 @@ -<!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"tagName":"main","displayLayout":{"type":"list"},"layout":{"inherit":true}} --> +<!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"displayLayout":{"type":"list"},"layout":{"inherit":true}} --> <div class="wp-block-query"><!-- wp:post-template --> <!-- wp:post-title /--> <!-- /wp:post-template --> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-4.json b/test/integration/fixtures/blocks/core__query__deprecated-4.json index 2870009875a018..620e1ecefef130 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-4.json +++ b/test/integration/fixtures/blocks/core__query__deprecated-4.json @@ -1,7 +1,7 @@ [ { "name": "core/query", - "isValid": false, + "isValid": true, "attributes": { "queryId": 0, "query": { @@ -17,19 +17,21 @@ "sticky": "", "inherit": true }, - "tagName": "main", - "displayLayout": { - "type": "list" - }, + "tagName": "div", "layout": { - "inherit": true + "contentSize": null, + "type": "constrained" } }, "innerBlocks": [ { "name": "core/post-template", "isValid": true, - "attributes": {}, + "attributes": { + "layout": { + "type": "default" + } + }, "innerBlocks": [ { "name": "core/post-title", diff --git a/test/integration/fixtures/blocks/core__query__deprecated-4.parsed.json b/test/integration/fixtures/blocks/core__query__deprecated-4.parsed.json index 529a440b12eeb2..1ce908fa81ac70 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-4.parsed.json +++ b/test/integration/fixtures/blocks/core__query__deprecated-4.parsed.json @@ -16,7 +16,6 @@ "sticky": "", "inherit": true }, - "tagName": "main", "displayLayout": { "type": "list" }, diff --git a/test/integration/fixtures/blocks/core__query__deprecated-4.serialized.html b/test/integration/fixtures/blocks/core__query__deprecated-4.serialized.html index f86c70b104550d..1ab2470d36159a 100644 --- a/test/integration/fixtures/blocks/core__query__deprecated-4.serialized.html +++ b/test/integration/fixtures/blocks/core__query__deprecated-4.serialized.html @@ -1,7 +1,5 @@ -<!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"tagName":"main","displayLayout":{"type":"list"},"layout":{"inherit":true}} --> -<div class="wp-block-query"> -<!-- wp:post-template --> +<!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"layout":{"contentSize":null,"type":"constrained"}} --> +<div class="wp-block-query"><!-- wp:post-template {"layout":{"type":"default"}} --> <!-- wp:post-title /--> -<!-- /wp:post-template --> -</div> +<!-- /wp:post-template --></div> <!-- /wp:query --> diff --git a/test/integration/fixtures/blocks/core__query__deprecated-5.html b/test/integration/fixtures/blocks/core__query__deprecated-5.html new file mode 100644 index 00000000000000..d040961172cb98 --- /dev/null +++ b/test/integration/fixtures/blocks/core__query__deprecated-5.html @@ -0,0 +1,6 @@ +<!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"displayLayout":{"type":"list"},"layout":{"type":"constrained"}} --> +<div class="wp-block-query"><!-- wp:post-template --> + <!-- wp:post-title /--> + <!-- /wp:post-template --> +</div> +<!-- /wp:query --> \ No newline at end of file diff --git a/test/integration/fixtures/blocks/core__query__deprecated-5.json b/test/integration/fixtures/blocks/core__query__deprecated-5.json new file mode 100644 index 00000000000000..f74e9496b23902 --- /dev/null +++ b/test/integration/fixtures/blocks/core__query__deprecated-5.json @@ -0,0 +1,50 @@ +[ + { + "name": "core/query", + "isValid": true, + "attributes": { + "queryId": 0, + "query": { + "perPage": 10, + "pages": 0, + "offset": 0, + "postType": "post", + "order": "desc", + "orderBy": "date", + "author": "", + "search": "", + "exclude": [], + "sticky": "", + "inherit": true + }, + "tagName": "div", + "layout": { + "type": "constrained" + } + }, + "innerBlocks": [ + { + "name": "core/post-template", + "isValid": true, + "attributes": { + "layout": { + "type": "default" + } + }, + "innerBlocks": [ + { + "name": "core/post-title", + "isValid": true, + "attributes": { + "level": 2, + "isLink": false, + "rel": "", + "linkTarget": "_self" + }, + "innerBlocks": [] + } + ] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__query__deprecated-5.parsed.json b/test/integration/fixtures/blocks/core__query__deprecated-5.parsed.json new file mode 100644 index 00000000000000..54a9d08581cb90 --- /dev/null +++ b/test/integration/fixtures/blocks/core__query__deprecated-5.parsed.json @@ -0,0 +1,50 @@ +[ + { + "blockName": "core/query", + "attrs": { + "queryId": 0, + "query": { + "perPage": 10, + "pages": 0, + "offset": 0, + "postType": "post", + "order": "desc", + "orderBy": "date", + "author": "", + "search": "", + "exclude": [], + "sticky": "", + "inherit": true + }, + "displayLayout": { + "type": "list" + }, + "layout": { + "type": "constrained" + } + }, + "innerBlocks": [ + { + "blockName": "core/post-template", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/post-title", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } + ], + "innerHTML": "\n \n ", + "innerContent": [ "\n ", null, "\n " ] + } + ], + "innerHTML": "\n<div class=\"wp-block-query\">\n</div>\n", + "innerContent": [ + "\n<div class=\"wp-block-query\">", + null, + "\n</div>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__query__deprecated-5.serialized.html b/test/integration/fixtures/blocks/core__query__deprecated-5.serialized.html new file mode 100644 index 00000000000000..a185cf3285299b --- /dev/null +++ b/test/integration/fixtures/blocks/core__query__deprecated-5.serialized.html @@ -0,0 +1,5 @@ +<!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true},"layout":{"type":"constrained"}} --> +<div class="wp-block-query"><!-- wp:post-template {"layout":{"type":"default"}} --> +<!-- wp:post-title /--> +<!-- /wp:post-template --></div> +<!-- /wp:query --> diff --git a/test/integration/fixtures/blocks/core__search.json b/test/integration/fixtures/blocks/core__search.json index 7b6125b5016b37..f692eac10993d8 100644 --- a/test/integration/fixtures/blocks/core__search.json +++ b/test/integration/fixtures/blocks/core__search.json @@ -7,7 +7,9 @@ "placeholder": "", "buttonPosition": "button-outside", "buttonUseIcon": false, - "query": {} + "query": {}, + "buttonBehavior": "expand-searchfield", + "isSearchFieldHidden": false }, "innerBlocks": [] } diff --git a/test/integration/fixtures/blocks/core__search__custom-text.json b/test/integration/fixtures/blocks/core__search__custom-text.json index 6e874946117966..c763cb60f65e86 100644 --- a/test/integration/fixtures/blocks/core__search__custom-text.json +++ b/test/integration/fixtures/blocks/core__search__custom-text.json @@ -9,7 +9,9 @@ "buttonText": "Custom button text", "buttonPosition": "button-outside", "buttonUseIcon": false, - "query": {} + "query": {}, + "buttonBehavior": "expand-searchfield", + "isSearchFieldHidden": false }, "innerBlocks": [] } diff --git a/test/integration/fixtures/blocks/core__social-link-threads.html b/test/integration/fixtures/blocks/core__social-link-threads.html new file mode 100644 index 00000000000000..3189c247c825fb --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-threads.html @@ -0,0 +1 @@ +<!-- wp:social-link-threads {"url":"https://example.com/"} /--> diff --git a/test/integration/fixtures/blocks/core__social-link-threads.json b/test/integration/fixtures/blocks/core__social-link-threads.json new file mode 100644 index 00000000000000..334725c6441810 --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-threads.json @@ -0,0 +1,11 @@ +[ + { + "name": "core/social-link", + "isValid": true, + "attributes": { + "url": "https://example.com/", + "service": "threads" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__social-link-threads.parsed.json b/test/integration/fixtures/blocks/core__social-link-threads.parsed.json new file mode 100644 index 00000000000000..31274f0ac3e943 --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-threads.parsed.json @@ -0,0 +1,11 @@ +[ + { + "blockName": "core/social-link-threads", + "attrs": { + "url": "https://example.com/" + }, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__social-link-threads.serialized.html b/test/integration/fixtures/blocks/core__social-link-threads.serialized.html new file mode 100644 index 00000000000000..cc93afc92ce47b --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-threads.serialized.html @@ -0,0 +1 @@ +<!-- wp:social-link {"url":"https://example.com/","service":"threads"} /--> diff --git a/test/integration/fixtures/blocks/core__video__deprecated-1.serialized.html b/test/integration/fixtures/blocks/core__video__deprecated-1.serialized.html index b5ee414dc68910..6a1cb7ab46c8d7 100644 --- a/test/integration/fixtures/blocks/core__video__deprecated-1.serialized.html +++ b/test/integration/fixtures/blocks/core__video__deprecated-1.serialized.html @@ -1,3 +1,3 @@ -<!-- wp:video {"tracks":[]} --> +<!-- wp:video --> <figure class="wp-block-video"><video controls src="data:video/mp4;base64,AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAC721kYXQhEAUgpBv/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3pwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcCEQBSCkG//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADengAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAsJtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAALwABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAB7HRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAIAAAAAAAAALwAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAC8AAAAAAAEAAAAAAWRtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAKxEAAAIAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAEPbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAADTc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAAKxEAAAAAAAzZXNkcwAAAAADgICAIgACAASAgIAUQBUAAAAAAfQAAAHz+QWAgIACEhAGgICAAQIAAAAYc3R0cwAAAAAAAAABAAAAAgAABAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAIAAAABAAAAHHN0c3oAAAAAAAAAAAAAAAIAAAFzAAABdAAAABRzdGNvAAAAAAAAAAEAAAAsAAAAYnVkdGEAAABabWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAAtaWxzdAAAACWpdG9vAAAAHWRhdGEAAAABAAAAAExhdmY1Ni40MC4xMDE="></video><figcaption class="wp-element-caption">My video</figcaption></figure> <!-- /wp:video --> diff --git a/test/integration/helpers/integration-test-editor.js b/test/integration/helpers/integration-test-editor.js index 6b954a895a51ac..7c2fba15060b45 100644 --- a/test/integration/helpers/integration-test-editor.js +++ b/test/integration/helpers/integration-test-editor.js @@ -9,15 +9,12 @@ import userEvent from '@testing-library/user-event'; */ import { useState, useEffect } from '@wordpress/element'; import { - BlockEditorKeyboardShortcuts, BlockEditorProvider, BlockList, BlockTools, BlockInspector, WritingFlow, - ObserveTyping, } from '@wordpress/block-editor'; -import { Popover, SlotFillProvider } from '@wordpress/components'; import { registerCoreBlocks } from '@wordpress/block-library'; import { ShortcutProvider } from '@wordpress/keyboard-shortcuts'; import '@wordpress/format-library'; @@ -70,26 +67,19 @@ export function Editor( { testBlocks, settings = {} } ) { return ( <ShortcutProvider> - <SlotFillProvider> - <BlockEditorProvider - value={ currentBlocks } - onInput={ updateBlocks } - onChange={ updateBlocks } - settings={ settings } - > - <BlockInspector /> - <BlockTools> - <BlockEditorKeyboardShortcuts.Register /> - <WritingFlow> - <ObserveTyping> - <BlockList /> - </ObserveTyping> - </WritingFlow> - </BlockTools> - - <Popover.Slot /> - </BlockEditorProvider> - </SlotFillProvider> + <BlockEditorProvider + value={ currentBlocks } + onInput={ updateBlocks } + onChange={ updateBlocks } + settings={ settings } + > + <BlockInspector /> + <BlockTools> + <WritingFlow> + <BlockList /> + </WritingFlow> + </BlockTools> + </BlockEditorProvider> </ShortcutProvider> ); } diff --git a/test/native/__mocks__/react-native-hr/index.js b/test/native/__mocks__/react-native-hr/index.js deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/test/native/__mocks__/styleMock.js b/test/native/__mocks__/styleMock.js index f52f60f233560b..8b682ef005e496 100644 --- a/test/native/__mocks__/styleMock.js +++ b/test/native/__mocks__/styleMock.js @@ -187,4 +187,25 @@ module.exports = { placeholderColor: { color: 'gray', }, + 'rich-text-selection': { + color: 'black', + }, + 'header-toolbar__keyboard-hide-shadow--light': { + color: 'black', + }, + 'header-toolbar__keyboard-hide-shadow--solid': { + color: 'black', + }, + 'media-placeholder__header-icon': { + fill: 'black', + }, + embed__icon: { + fill: 'black', + }, + picker: {}, + pickerPointer: {}, + columnsContainer: { + marginLeft: 16, + minWidth: 32, + }, }; diff --git a/test/native/helpers.js b/test/native/helpers.js index 9a2bfda90c104d..74c2fce2e45330 100644 --- a/test/native/helpers.js +++ b/test/native/helpers.js @@ -1,7 +1,3 @@ export * from '@testing-library/react-native'; export { measurePerformance } from 'reassure'; - export * from './integration-test-helpers'; - -// Override `waitFor` export with custom implementation -export { waitFor } from './integration-test-helpers'; diff --git a/test/native/integration-test-helpers/README.md b/test/native/integration-test-helpers/README.md index 49b63794ed8ff6..b80370f79929c9 100644 --- a/test/native/integration-test-helpers/README.md +++ b/test/native/integration-test-helpers/README.md @@ -94,10 +94,6 @@ Waits for a modal to be visible. Executes a function that triggers store resolvers and waits for them to be finished. -### [`waitFor`](https://github.com/WordPress/gutenberg/blob/HEAD/test/native/integration-test-helpers/wait-for.js) - -Custom implementation of the "waitFor" utility from `@testing-library/react-native` library. - ### [`withFakeTimers`](https://github.com/WordPress/gutenberg/blob/HEAD/test/native/integration-test-helpers/with-fake-timers.js) Set up fake timers for executing a function and restores them afterwards. diff --git a/test/native/integration-test-helpers/add-block.js b/test/native/integration-test-helpers/add-block.js index 197968be3edbf2..5a15cb59fc6e16 100644 --- a/test/native/integration-test-helpers/add-block.js +++ b/test/native/integration-test-helpers/add-block.js @@ -12,7 +12,6 @@ import { AccessibilityInfo } from 'react-native'; /** * Internal dependencies */ -import { waitFor } from './wait-for'; import { withFakeTimers } from './with-fake-timers'; /** @@ -28,13 +27,11 @@ export const addBlock = async ( blockName, { isPickerOpened } = {} ) => { - const { getByLabelText, getByTestId, getByText } = screen; - if ( ! isPickerOpened ) { - fireEvent.press( getByLabelText( 'Add block' ) ); + fireEvent.press( screen.getByLabelText( 'Add block' ) ); } - const blockList = getByTestId( 'InserterUI-Blocks' ); + const blockList = screen.getByTestId( 'InserterUI-Blocks' ); // onScroll event used to force the FlatList to render all items fireEvent.scroll( blockList, { nativeEvent: { @@ -44,14 +41,25 @@ export const addBlock = async ( }, } ); - fireEvent.press( await waitFor( () => getByText( blockName ) ) ); + const blockButton = await screen.findByText( blockName ); + // Blocks can perform belated state updates after they are inserted. + // To avoid potential `act` warnings, we ensure that all timers and queued + // microtasks are executed. + await withFakeTimers( async () => { + fireEvent.press( blockButton ); - // On iOS the action for inserting a block is delayed (https://bit.ly/3AVALqH). - // Hence, we need to wait for the different steps until the the block is inserted. - if ( Platform.isIOS ) { - await withFakeTimers( async () => { + // On iOS the action for inserting a block is delayed (https://bit.ly/3AVALqH). + // Hence, we need to wait for the different steps until the the block is inserted. + if ( Platform.isIOS ) { await AccessibilityInfo.isScreenReaderEnabled(); act( () => jest.runOnlyPendingTimers() ); - } ); - } + } + + // Run all timers, in case any performs a state updates. + // Column block example: https://t.ly/NjTs + act( () => jest.runOnlyPendingTimers() ); + // Let potential queued microtasks (like Promises) to be executed. + // Inner blocks example: https://t.ly/b95nA + await act( async () => {} ); + } ); }; diff --git a/test/native/integration-test-helpers/index.js b/test/native/integration-test-helpers/index.js index 13e74a69c4a668..5aca46049715cd 100644 --- a/test/native/integration-test-helpers/index.js +++ b/test/native/integration-test-helpers/index.js @@ -24,6 +24,5 @@ export { transformBlock } from './transform-block'; export { triggerBlockListLayout } from './trigger-block-list-layout'; export { waitForModalVisible } from './wait-for-modal-visible'; export { waitForStoreResolvers } from './wait-for-store-resolvers'; -export { waitFor } from './wait-for'; export { withFakeTimers } from './with-fake-timers'; export { withReanimatedTimer } from './with-reanimated-timer'; diff --git a/test/native/integration-test-helpers/rich-text-select-range.js b/test/native/integration-test-helpers/rich-text-select-range.js index 1ff5025d3df14a..0e44a5af3a1d2c 100644 --- a/test/native/integration-test-helpers/rich-text-select-range.js +++ b/test/native/integration-test-helpers/rich-text-select-range.js @@ -9,7 +9,6 @@ import { typeInRichText } from './rich-text-type'; * @param {import('react-test-renderer').ReactTestInstance} richText RichText test instance. * @param {number} start Selection start position. * @param {number} end Selection end position. - * */ export const selectRangeInRichText = ( richText, start, end = start ) => { if ( typeof start !== 'number' ) { diff --git a/test/native/integration-test-helpers/setup-api-fetch.js b/test/native/integration-test-helpers/setup-api-fetch.js index e8b20e261630bb..6a5c5c97a934fd 100644 --- a/test/native/integration-test-helpers/setup-api-fetch.js +++ b/test/native/integration-test-helpers/setup-api-fetch.js @@ -33,7 +33,6 @@ import apiFetch from '@wordpress/api-fetch'; * expect( apiFetch ).toHaveBeenCalledWith( responses[1].request ); * * @param {object[]} responses Array with the potential responses to return upon requests. - * */ export function setupApiFetch( responses ) { apiFetch.mockImplementation( async ( options ) => { diff --git a/test/native/integration-test-helpers/wait-for-modal-visible.js b/test/native/integration-test-helpers/wait-for-modal-visible.js index 1222fa5748bb9b..045b50a60cdbba 100644 --- a/test/native/integration-test-helpers/wait-for-modal-visible.js +++ b/test/native/integration-test-helpers/wait-for-modal-visible.js @@ -1,7 +1,7 @@ /** - * Internal dependencies + * External dependencies */ -import { waitFor } from './wait-for'; +import { waitFor } from '@testing-library/react-native'; /** * Waits for a modal to be visible. @@ -9,5 +9,7 @@ import { waitFor } from './wait-for'; * @param {import('react-test-renderer').ReactTestInstance} modalInstance Modal test instance. */ export const waitForModalVisible = async ( modalInstance ) => { - return waitFor( () => modalInstance.props.isVisible ); + return waitFor( () => + expect( modalInstance.props.isVisible ).toBe( true ) + ); }; diff --git a/test/native/integration-test-helpers/wait-for.js b/test/native/integration-test-helpers/wait-for.js deleted file mode 100644 index 31afcdd1cdb5cf..00000000000000 --- a/test/native/integration-test-helpers/wait-for.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * External dependencies - */ -import { act } from '@testing-library/react-native'; - -/** - * Custom implementation of the "waitFor" utility to - * prevent the issue: https://git.io/JYYGE - * - * @param {Function} cb Callback on which to wait. - * @param {Object} options Configuration options for waiting. - * @param {number} [options.timeout] Total time to wait for. If the execution exceeds this time, the call will be considered rejected. - * @param {number} [options.interval] Time to wait between calls. - * @return {*} Result of calling the callback function if it's not rejected. - */ -export function waitFor( - cb, - { timeout, interval } = { timeout: 1000, interval: 50 } -) { - let result; - let lastError; - const check = ( resolve, reject, time = 0 ) => { - try { - result = cb(); - } catch ( error ) { - lastError = error; - } - if ( ! result && time < timeout ) { - setTimeout( - () => check( resolve, reject, time + interval ), - interval - ); - return; - } - resolve( result ); - }; - return new Promise( ( resolve, reject ) => - act( - () => new Promise( ( internalResolve ) => check( internalResolve ) ) - ).then( () => { - if ( ! result ) { - reject( - `waitFor timed out after ${ timeout }ms for callback:\n${ cb }\n${ lastError.toString() }` - ); - return; - } - resolve( result ); - } ) - ); -} diff --git a/test/native/integration-test-helpers/with-reanimated-timer.js b/test/native/integration-test-helpers/with-reanimated-timer.js index e9832652d91c6a..667e2237939661 100644 --- a/test/native/integration-test-helpers/with-reanimated-timer.js +++ b/test/native/integration-test-helpers/with-reanimated-timer.js @@ -24,11 +24,19 @@ export async function withReanimatedTimer( fn ) { global.requestAnimationFrame = ( callback ) => setTimeout( callback, FRAME_TIME ); + // Reanimated uses a custom `now` function to advance animations. In order to be able to use + // Jest timer functions to advance animations we need to set the fake timers' internal clock. + // Reference: https://t.ly/0S__f + const reanimatedNowMockCopy = global.ReanimatedDataMock.now; + global.ReanimatedDataMock.now = jest.now; + const result = await fn(); // As part of the clean up, we run all pending timers that might have been derived from animations. act( () => jest.runOnlyPendingTimers() ); + global.ReanimatedDataMock.now = reanimatedNowMockCopy; + return result; } ); } diff --git a/test/native/integration/editor-history.native.js b/test/native/integration/editor-history.native.js index 35ab4d54a0a08e..9b2c212d17ee75 100644 --- a/test/native/integration/editor-history.native.js +++ b/test/native/integration/editor-history.native.js @@ -2,6 +2,7 @@ * External dependencies */ import { + act, addBlock, dismissModal, getBlock, @@ -14,9 +15,37 @@ import { within, } from 'test/helpers'; +/** + * WordPress dependencies + */ +import { + subscribeOnUndoPressed, + subscribeOnRedoPressed, +} from '@wordpress/react-native-bridge'; + setupCoreBlocks(); describe( 'Editor History', () => { + let toggleUndo; + let toggleRedo; + + beforeAll( () => { + subscribeOnUndoPressed.mockImplementation( ( callback ) => { + toggleUndo = () => { + act( () => { + callback(); + } ); + }; + } ); + subscribeOnRedoPressed.mockImplementation( ( callback ) => { + toggleRedo = () => { + act( () => { + callback(); + } ); + }; + } ); + } ); + it( 'should remove and add blocks', async () => { // Arrange const screen = await initializeEditor(); @@ -42,17 +71,17 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Undo' ) ); - fireEvent.press( screen.getByLabelText( 'Undo' ) ); - fireEvent.press( screen.getByLabelText( 'Undo' ) ); + toggleUndo(); + toggleUndo(); + toggleUndo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( `""` ); // Act - fireEvent.press( screen.getByLabelText( 'Redo' ) ); - fireEvent.press( screen.getByLabelText( 'Redo' ) ); - fireEvent.press( screen.getByLabelText( 'Redo' ) ); + toggleRedo(); + toggleRedo(); + toggleRedo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -80,12 +109,10 @@ describe( 'Editor History', () => { fireEvent.press( paragraphBlock ); const paragraphTextInput = within( paragraphBlock ).getByPlaceholderText( 'Start writing…' ); - typeInRichText( - paragraphTextInput, - 'A quick brown fox jumps over the lazy dog.' - ); - - // TODO: Determine a way to type multiple times within a given block. + typeInRichText( paragraphTextInput, 'A quick brown fox' ); + // Artifical delay to create two history entries for typing + await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); + typeInRichText( paragraphTextInput, ' jumps over the lazy dog.' ); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -95,7 +122,17 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Undo' ) ); + toggleUndo(); + + // Assert + expect( getEditorHtml() ).toMatchInlineSnapshot( ` + "<!-- wp:paragraph --> + <p>A quick brown fox</p> + <!-- /wp:paragraph -->" + ` ); + + // Act + toggleUndo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -105,7 +142,17 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Redo' ) ); + toggleRedo(); + + // Assert + expect( getEditorHtml() ).toMatchInlineSnapshot( ` + "<!-- wp:paragraph --> + <p>A quick brown fox</p> + <!-- /wp:paragraph -->" + ` ); + + // Act + toggleRedo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -130,13 +177,11 @@ describe( 'Editor History', () => { 'A quick brown fox jumps over the lazy dog.', { finalSelectionStart: 2, finalSelectionEnd: 7 } ); - // Artifical delay to create two history entries for typing and bolding. + // Artifical delay to create two history entries for typing and formatting. await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); fireEvent.press( screen.getByLabelText( 'Bold' ) ); fireEvent.press( screen.getByLabelText( 'Italic' ) ); - // TODO: Determine a way to type multiple times within a given block. - // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` "<!-- wp:paragraph --> @@ -145,7 +190,7 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Undo' ) ); + toggleUndo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -155,7 +200,7 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Undo' ) ); + toggleUndo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -165,8 +210,8 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Redo' ) ); - fireEvent.press( screen.getByLabelText( 'Redo' ) ); + toggleRedo(); + toggleRedo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -212,8 +257,8 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Undo' ) ); - fireEvent.press( screen.getByLabelText( 'Undo' ) ); + toggleUndo(); + toggleUndo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` @@ -223,8 +268,8 @@ describe( 'Editor History', () => { ` ); // Act - fireEvent.press( screen.getByLabelText( 'Redo' ) ); - fireEvent.press( screen.getByLabelText( 'Redo' ) ); + toggleRedo(); + toggleRedo(); // Assert expect( getEditorHtml() ).toMatchInlineSnapshot( ` diff --git a/test/native/jest.config.js b/test/native/jest.config.js index 22bd62065ca7d8..7ecbf8036a03c4 100644 --- a/test/native/jest.config.js +++ b/test/native/jest.config.js @@ -2,6 +2,7 @@ * External dependencies */ const glob = require( 'glob' ).sync; +const path = require( 'path' ); const defaultPlatform = 'android'; const rnPlatform = process.env.TEST_RN_PLATFORM || defaultPlatform; @@ -13,51 +14,58 @@ if ( process.env.TEST_RN_PLATFORM ) { console.log( 'Setting RN platform to: default (' + defaultPlatform + ')' ); } -const transpiledPackageNames = glob( '../../packages/*/src/index.{js,ts}' ).map( - ( fileName ) => fileName.split( '/' )[ 3 ] +const transpiledPackageNames = glob( 'packages/*/src/index.{js,ts}' ).map( + ( fileName ) => fileName.split( '/' )[ 1 ] ); module.exports = { - verbose: true, - rootDir: '.', - roots: [ '<rootDir>/../..' ], + rootDir: '../../', // Automatically clear mock calls and instances between every test. clearMocks: true, preset: 'react-native', - setupFiles: [ '<rootDir>/setup.js' ], - setupFilesAfterEnv: [ '<rootDir>/setup-after-env.js' ], + setupFiles: [ '<rootDir>/test/native/setup.js' ], + setupFilesAfterEnv: [ '<rootDir>/test/native/setup-after-env.js' ], testMatch: [ - '<rootDir>/../../test/**/*.native.[jt]s?(x)', - '<rootDir>/../../**/test/!(helper)*.native.[jt]s?(x)', - '<rootDir>/../../packages/react-native-*/**/?(*.)+(spec|test).[jt]s?(x)', + '<rootDir>/test/**/*.native.[jt]s?(x)', + '<rootDir>/**/test/!(helper)*.native.[jt]s?(x)', + '<rootDir>/packages/react-native-*/**/?(*.)+(spec|test).[jt]s?(x)', ], testPathIgnorePatterns: [ '/node_modules/', '/__device-tests__/' ], testEnvironmentOptions: { url: 'http://localhost/', }, - resolver: '<rootDir>/../../test/unit/scripts/resolver.js', + resolver: '<rootDir>/test/unit/scripts/resolver.js', // Add the `Libraries/Utilities` subfolder to the module directories, otherwise haste/jest doesn't find Platform.js on Travis, // and add it first so https://github.com/facebook/react-native/blob/v0.60.0/Libraries/react-native/react-native-implementation.js#L324-L326 doesn't pick up the Platform npm module. moduleDirectories: [ - '../../node_modules/react-native/Libraries/Utilities', - '../../node_modules', + 'node_modules/react-native/Libraries/Utilities', + 'node_modules', ], moduleNameMapper: { // Mock the CSS modules. See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets - '\\.(scss)$': '<rootDir>/__mocks__/styleMock.js', + '\\.(scss)$': '<rootDir>/test/native/__mocks__/styleMock.js', '\\.(eot|otf|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': - '<rootDir>/__mocks__/fileMock.js', + '<rootDir>/test/native/__mocks__/fileMock.js', [ `@wordpress\\/(${ transpiledPackageNames.join( '|' ) })$` ]: - '<rootDir>/../../packages/$1/src', - 'test/helpers$': '<rootDir>/../../test/native/helpers.js', + '<rootDir>/packages/$1/src', + 'test/helpers$': '<rootDir>/test/native/helpers.js', }, modulePathIgnorePatterns: [ - '<rootDir>/../../packages/react-native-editor/node_modules', + '<rootDir>/packages/react-native-editor/node_modules', ], haste: { defaultPlatform: rnPlatform, platforms: [ 'android', 'ios', 'native' ], }, + transform: { + '\\.[jt]sx?$': [ + 'babel-jest', + // https://git.io/JYiYc + { + configFile: path.resolve( __dirname, 'babel.config.js' ), + }, + ], + }, transformIgnorePatterns: [ // This is required for now to have jest transform some of our modules // See: https://github.com/wordpress-mobile/gutenberg-mobile/pull/257#discussion_r234978268 @@ -71,4 +79,8 @@ module.exports = { printBasicPrototype: false, }, reporters: [ 'default', 'jest-junit' ], + watchPlugins: [ + 'jest-watch-typeahead/filename', + 'jest-watch-typeahead/testname', + ], }; diff --git a/test/native/setup-after-env.js b/test/native/setup-after-env.js index 5e71eb5dbd861f..bddd547faf5b80 100644 --- a/test/native/setup-after-env.js +++ b/test/native/setup-after-env.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import '@wordpress/jest-console'; + /** * Internal dependencies */ diff --git a/test/native/setup.js b/test/native/setup.js index be643a1fb90175..60b42dc25d2fe1 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -2,7 +2,8 @@ * External dependencies */ import 'react-native-gesture-handler/jestSetup'; -import { Image, NativeModules as RNNativeModules } from 'react-native'; +import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; +import { Image, Linking } from 'react-native'; // React Native sets up a global navigator, but that is not executed in the // testing environment: https://github.com/facebook/react-native/blob/6c19dc3266b84f47a076b647a1c93b3c3b69d2c5/Libraries/Core/setUpNavigator.js#L17 @@ -19,24 +20,37 @@ global.ReanimatedDataMock = { now: () => 0, }; -RNNativeModules.UIManager = RNNativeModules.UIManager || {}; -RNNativeModules.UIManager.RCTView = RNNativeModules.UIManager.RCTView || {}; -RNNativeModules.RNGestureHandlerModule = - RNNativeModules.RNGestureHandlerModule || { - State: { - BEGAN: 'BEGAN', - FAILED: 'FAILED', - ACTIVE: 'ACTIVE', - END: 'END', - }, - attachGestureHandler: jest.fn(), - createGestureHandler: jest.fn(), - dropGestureHandler: jest.fn(), - updateGestureHandler: jest.fn(), +jest.mock( 'react-native', () => { + const ReactNative = jest.requireActual( 'react-native' ); + const RNNativeModules = ReactNative.NativeModules; + + // Mock React Native modules + RNNativeModules.UIManager = RNNativeModules.UIManager || {}; + RNNativeModules.UIManager.RCTView = RNNativeModules.UIManager.RCTView || {}; + RNNativeModules.RNGestureHandlerModule = + RNNativeModules.RNGestureHandlerModule || { + State: { + BEGAN: 'BEGAN', + FAILED: 'FAILED', + ACTIVE: 'ACTIVE', + END: 'END', + }, + attachGestureHandler: jest.fn(), + createGestureHandler: jest.fn(), + dropGestureHandler: jest.fn(), + updateGestureHandler: jest.fn(), + }; + RNNativeModules.PlatformConstants = RNNativeModules.PlatformConstants || { + forceTouchAvailable: false, }; -RNNativeModules.PlatformConstants = RNNativeModules.PlatformConstants || { - forceTouchAvailable: false, -}; + + // Mock WebView native module from `react-native-webview` + RNNativeModules.RNCWebView = { + isFileUploadSupported: jest.fn(), + }; + + return ReactNative; +} ); // Mock component to render with props rather than merely a string name so that // we may assert against it. ...args is used avoid warnings about ignoring @@ -91,6 +105,8 @@ jest.mock( '@wordpress/react-native-bridge', () => { subscribeShowNotice: jest.fn(), subscribeParentGetHtml: jest.fn(), subscribeShowEditorHelp: jest.fn(), + subscribeOnUndoPressed: jest.fn(), + subscribeOnRedoPressed: jest.fn(), editorDidMount: jest.fn(), editorDidAutosave: jest.fn(), subscribeMediaUpload: jest.fn(), @@ -111,6 +127,8 @@ jest.mock( '@wordpress/react-native-bridge', () => { fetchRequest: jest.fn(), requestPreview: jest.fn(), generateHapticFeedback: jest.fn(), + toggleUndoButton: jest.fn(), + toggleRedoButton: jest.fn(), }; } ); @@ -120,8 +138,6 @@ jest.mock( props.isVisible ? mockComponent( 'Modal' )( props ) : null ); -jest.mock( 'react-native-hr', () => () => 'Hr' ); - jest.mock( 'react-native-svg', () => { const { forwardRef } = require( 'react' ); return { @@ -157,21 +173,7 @@ jest.mock( 'react-native-safe-area', () => { }; } ); -// To be replaced with built in mocks when we upgrade to the latest version -jest.mock( 'react-native-safe-area-context', () => { - const inset = { top: 0, right: 0, bottom: 0, left: 0 }; - const frame = { x: 0, y: 0, width: 0, height: 0 }; - return { - SafeAreaProvider: jest - .fn() - .mockImplementation( ( { children } ) => children ), - SafeAreaConsumer: jest - .fn() - .mockImplementation( ( { children } ) => children( inset ) ), - useSafeAreaInsets: jest.fn().mockImplementation( () => inset ), - useSafeAreaFrame: jest.fn().mockImplementation( () => frame ), - }; -} ); +jest.mock( 'react-native-safe-area-context', () => mockSafeAreaContext ); jest.mock( '@react-native-community/slider', @@ -186,9 +188,11 @@ jest.mock( 'react-native-linear-gradient', () => () => 'LinearGradient', { virtual: true, } ); -jest.mock( 'react-native-hsv-color-picker', () => () => 'HsvColorPicker', { - virtual: true, -} ); +jest.mock( + 'react-native-hsv-color-picker', + () => jest.fn( () => 'HsvColorPicker' ), + { virtual: true } +); jest.mock( '@react-native-community/blur', () => () => 'BlurView', { virtual: true, @@ -231,6 +235,7 @@ jest.mock( jest.mock( 'react-native/Libraries/ActionSheetIOS/ActionSheetIOS', () => ( { showActionSheetWithOptions: jest.fn(), } ) ); +Linking.addEventListener.mockReturnValue( { remove: jest.fn() } ); // The mock provided by the package itself does not appear to work correctly. // Specifically, the mock provides a named export, where the module itself uses diff --git a/packages/e2e-tests/assets/large-post.html b/test/performance/assets/large-post.html similarity index 100% rename from packages/e2e-tests/assets/large-post.html rename to test/performance/assets/large-post.html diff --git a/packages/e2e-tests/assets/small-post-with-containers.html b/test/performance/assets/small-post-with-containers.html similarity index 100% rename from packages/e2e-tests/assets/small-post-with-containers.html rename to test/performance/assets/small-post-with-containers.html diff --git a/test/performance/config/global-setup.ts b/test/performance/config/global-setup.ts new file mode 100644 index 00000000000000..787488ac72fcab --- /dev/null +++ b/test/performance/config/global-setup.ts @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { request } from '@playwright/test'; +import type { FullConfig } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +async function globalSetup( config: FullConfig ) { + const { storageState, baseURL } = config.projects[ 0 ].use; + const storageStatePath = + typeof storageState === 'string' ? storageState : undefined; + + const requestContext = await request.newContext( { + baseURL, + } ); + + const requestUtils = new RequestUtils( requestContext, { + storageStatePath, + } ); + + // Authenticate and save the storageState to disk. + await requestUtils.setupRest(); + + // Reset the test environment before running the tests. + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + // Disable this test plugin as it's conflicting with some of the tests. + // We already have reduced motion enabled and Playwright will wait for most of the animations anyway. + requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-disables-the-css-animations' + ), + requestUtils.deleteAllPosts(), + requestUtils.deleteAllBlocks(), + requestUtils.resetPreferences(), + ] ); + + await requestContext.dispose(); +} + +export default globalSetup; diff --git a/test/performance/config/performance-reporter.ts b/test/performance/config/performance-reporter.ts new file mode 100644 index 00000000000000..e2e5804b962603 --- /dev/null +++ b/test/performance/config/performance-reporter.ts @@ -0,0 +1,197 @@ +/** + * External dependencies + */ +import path from 'path'; +import { writeFileSync } from 'fs'; +import type { + Reporter, + FullResult, + TestCase, + TestResult, +} from '@playwright/test/reporter'; + +/** + * Internal dependencies + */ +import { average, median, minimum, maximum, round } from '../utils'; + +export interface WPRawPerformanceResults { + timeToFirstByte: number[]; + largestContentfulPaint: number[]; + lcpMinusTtfb: number[]; + serverResponse: number[]; + firstPaint: number[]; + domContentLoaded: number[]; + loaded: number[]; + firstContentfulPaint: number[]; + firstBlock: number[]; + type: number[]; + typeContainer: number[]; + focus: number[]; + inserterOpen: number[]; + inserterSearch: number[]; + inserterHover: number[]; + listViewOpen: number[]; +} + +export interface WPPerformanceResults { + timeToFirstByte?: number; + largestContentfulPaint?: number; + lcpMinusTtfb?: number; + serverResponse?: number; + firstPaint?: number; + domContentLoaded?: number; + loaded?: number; + firstContentfulPaint?: number; + firstBlock?: number; + type?: number; + minType?: number; + maxType?: number; + typeContainer?: number; + minTypeContainer?: number; + maxTypeContainer?: number; + focus?: number; + minFocus?: number; + maxFocus?: number; + inserterOpen?: number; + minInserterOpen?: number; + maxInserterOpen?: number; + inserterSearch?: number; + minInserterSearch?: number; + maxInserterSearch?: number; + inserterHover?: number; + minInserterHover?: number; + maxInserterHover?: number; + listViewOpen?: number; + minListViewOpen?: number; + maxListViewOpen?: number; +} + +/** + * Curate the raw performance results. + * + * @param {WPRawPerformanceResults} results + * + * @return {WPPerformanceResults} Curated Performance results. + */ +export function curateResults( + results: WPRawPerformanceResults +): WPPerformanceResults { + const output = { + timeToFirstByte: median( results.timeToFirstByte ), + largestContentfulPaint: median( results.largestContentfulPaint ), + lcpMinusTtfb: median( results.lcpMinusTtfb ), + serverResponse: average( results.serverResponse ), + firstPaint: average( results.firstPaint ), + domContentLoaded: average( results.domContentLoaded ), + loaded: average( results.loaded ), + firstContentfulPaint: average( results.firstContentfulPaint ), + firstBlock: average( results.firstBlock ), + type: average( results.type ), + minType: minimum( results.type ), + maxType: maximum( results.type ), + typeContainer: average( results.typeContainer ), + minTypeContainer: minimum( results.typeContainer ), + maxTypeContainer: maximum( results.typeContainer ), + focus: average( results.focus ), + minFocus: minimum( results.focus ), + maxFocus: maximum( results.focus ), + inserterOpen: average( results.inserterOpen ), + minInserterOpen: minimum( results.inserterOpen ), + maxInserterOpen: maximum( results.inserterOpen ), + inserterSearch: average( results.inserterSearch ), + minInserterSearch: minimum( results.inserterSearch ), + maxInserterSearch: maximum( results.inserterSearch ), + inserterHover: average( results.inserterHover ), + minInserterHover: minimum( results.inserterHover ), + maxInserterHover: maximum( results.inserterHover ), + listViewOpen: average( results.listViewOpen ), + minListViewOpen: minimum( results.listViewOpen ), + maxListViewOpen: maximum( results.listViewOpen ), + }; + + return ( + Object.entries( output ) + // Reduce the output to contain taken metrics only. + .filter( ( [ _, value ] ) => typeof value === 'number' ) + .reduce( + ( acc, [ key, value ] ) => ( { + ...acc, + [ key ]: round( value ), + } ), + {} + ) + ); +} +class PerformanceReporter implements Reporter { + private results: Record< string, WPPerformanceResults >; + + constructor() { + this.results = {}; + } + + onTestEnd( test: TestCase, result: TestResult ): void { + for ( const attachment of result.attachments ) { + if ( attachment.name !== 'results' ) { + continue; + } + + if ( ! attachment.body ) { + throw new Error( 'Empty results attachment' ); + } + + const testSuite = path.basename( test.location.file, '.spec.js' ); + const resultsId = process.env.RESULTS_ID || testSuite; + const resultsPath = process.env.WP_ARTIFACTS_PATH as string; + const resultsBody = attachment.body.toString(); + + // Save raw results to file. + writeFileSync( + path.join( + resultsPath, + `${ resultsId }.performance-results.raw.json` + ), + resultsBody + ); + + const curatedResults = curateResults( JSON.parse( resultsBody ) ); + + // Save curated results to file. + writeFileSync( + path.join( + resultsPath, + `${ resultsId }.performance-results.json` + ), + JSON.stringify( curatedResults, null, 2 ) + ); + + this.results[ testSuite ] = curatedResults; + } + } + + onEnd( result: FullResult ) { + if ( result.status !== 'passed' ) { + return; + } + + if ( process.env.CI ) { + return; + } + + // Print the results. + for ( const [ testSuite, results ] of Object.entries( this.results ) ) { + const printableResults: Record< string, { value: string } > = {}; + + for ( const [ key, value ] of Object.entries( results ) ) { + printableResults[ key ] = { value: `${ value } ms` }; + } + + // eslint-disable-next-line no-console + console.log( `\n${ testSuite }\n` ); + // eslint-disable-next-line no-console + console.table( printableResults ); + } + } +} + +export default PerformanceReporter; diff --git a/test/performance/playwright.config.ts b/test/performance/playwright.config.ts new file mode 100644 index 00000000000000..e17d3c7fc31ca1 --- /dev/null +++ b/test/performance/playwright.config.ts @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { defineConfig, devices } from '@playwright/test'; + +process.env.WP_ARTIFACTS_PATH ??= path.join( process.cwd(), 'artifacts' ); +process.env.STORAGE_STATE_PATH ??= path.join( + process.env.WP_ARTIFACTS_PATH, + 'storage-states/admin.json' +); +process.env.ASSETS_PATH = path.join( __dirname, 'assets' ); + +const config = defineConfig( { + reporter: process.env.CI + ? './config/performance-reporter.ts' + : [ [ 'list' ], [ './config/performance-reporter.ts' ] ], + forbidOnly: !! process.env.CI, + fullyParallel: false, + workers: 1, + retries: 0, + timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 600_000, // Defaults to 10 minutes. + reportSlowTests: null, + testDir: fileURLToPath( new URL( './specs', 'file:' + __filename ).href ), + outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), + snapshotPathTemplate: + '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', + globalSetup: fileURLToPath( + new URL( './config/global-setup.ts', 'file:' + __filename ).href + ), + use: { + baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', + headless: true, + viewport: { + width: 960, + height: 700, + }, + ignoreHTTPSErrors: true, + locale: 'en-US', + contextOptions: { + reducedMotion: 'reduce', + strictSelectors: true, + }, + storageState: process.env.STORAGE_STATE_PATH, + actionTimeout: 10_000, // 10 seconds. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'off', + }, + webServer: { + command: 'npm run wp-env start', + port: 8889, + timeout: 120_000, // 120 seconds. + reuseExistingServer: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices[ 'Desktop Chrome' ] }, + }, + ], +} ); + +export default config; diff --git a/test/performance/specs/front-end-block-theme.spec.js b/test/performance/specs/front-end-block-theme.spec.js new file mode 100644 index 00000000000000..6ceedba9bd6d5f --- /dev/null +++ b/test/performance/specs/front-end-block-theme.spec.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; + +test.describe( 'Front End Performance', () => { + test.use( { storageState: {} } ); // User will be logged out. + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentythree' ); + } ); + + test.afterAll( async ( { requestUtils }, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + const samples = 16; + const throwaway = 0; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + test( `Measure TTFB, LCP, and LCP-TTFB (${ + i + 1 + } of ${ rounds })`, async ( { page } ) => { + // Go to the base URL. + await page.goto( '/', { waitUntil: 'networkidle' } ); + + // Take the measurements. + const [ lcp, ttfb ] = await page.evaluate( () => { + return Promise.all( [ + // Measure the Largest Contentful Paint time. + // Based on https://www.checklyhq.com/learn/headless/basics-performance#largest-contentful-paint-api-largest-contentful-paint + new Promise( ( resolve ) => { + new PerformanceObserver( ( entryList ) => { + const entries = entryList.getEntries(); + // The last entry is the largest contentful paint. + const largestPaintEntry = entries.at( -1 ); + + resolve( largestPaintEntry.startTime ); + } ).observe( { + type: 'largest-contentful-paint', + buffered: true, + } ); + } ), + // Measure the Time To First Byte. + // Based on https://web.dev/ttfb/#measure-ttfb-in-javascript + new Promise( ( resolve ) => { + new PerformanceObserver( ( entryList ) => { + const [ pageNav ] = + entryList.getEntriesByType( 'navigation' ); + + resolve( pageNav.responseStart ); + } ).observe( { + type: 'navigation', + buffered: true, + } ); + } ), + ] ); + } ); + + // Ensure the numbers are valid. + expect( lcp ).toBeGreaterThan( 0 ); + expect( ttfb ).toBeGreaterThan( 0 ); + + // Save the results. + if ( i >= throwaway ) { + results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); + results.lcpMinusTtfb.push( lcp - ttfb ); + } + } ); + } +} ); diff --git a/test/performance/specs/front-end-classic-theme.spec.js b/test/performance/specs/front-end-classic-theme.spec.js new file mode 100644 index 00000000000000..880da94a11c603 --- /dev/null +++ b/test/performance/specs/front-end-classic-theme.spec.js @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; + +test.describe( 'Front End Performance', () => { + test.use( { storageState: {} } ); // User will be logged out. + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.afterAll( async ( {}, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + } ); + + const samples = 16; + const throwaway = 0; + const rounds = samples + throwaway; + for ( let i = 1; i <= rounds; i++ ) { + test( `Report TTFB, LCP, and LCP-TTFB (${ i } of ${ rounds })`, async ( { + page, + } ) => { + // Go to the base URL. + await page.goto( '/', { waitUntil: 'networkidle' } ); + + // Take the measurements. + const [ lcp, ttfb ] = await page.evaluate( () => { + return Promise.all( [ + // Measure the Largest Contentful Paint time. + // Based on https://www.checklyhq.com/learn/headless/basics-performance#largest-contentful-paint-api-largest-contentful-paint + new Promise( ( resolve ) => { + new PerformanceObserver( ( entryList ) => { + const entries = entryList.getEntries(); + // The last entry is the largest contentful paint. + const largestPaintEntry = entries.at( -1 ); + + resolve( largestPaintEntry.startTime ); + } ).observe( { + type: 'largest-contentful-paint', + buffered: true, + } ); + } ), + // Measure the Time To First Byte. + // Based on https://web.dev/ttfb/#measure-ttfb-in-javascript + new Promise( ( resolve ) => { + new PerformanceObserver( ( entryList ) => { + const [ pageNav ] = + entryList.getEntriesByType( 'navigation' ); + + resolve( pageNav.responseStart ); + } ).observe( { + type: 'navigation', + buffered: true, + } ); + } ), + ] ); + } ); + + // Ensure the numbers are valid. + expect( lcp ).toBeGreaterThan( 0 ); + expect( ttfb ).toBeGreaterThan( 0 ); + + // Save the results. + if ( i >= throwaway ) { + results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); + results.lcpMinusTtfb.push( lcp - ttfb ); + } + } ); + } +} ); diff --git a/test/performance/specs/post-editor.spec.js b/test/performance/specs/post-editor.spec.js new file mode 100644 index 00000000000000..f808a4076d39fc --- /dev/null +++ b/test/performance/specs/post-editor.spec.js @@ -0,0 +1,402 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { + getTypingEventDurations, + getClickEventDurations, + getHoverEventDurations, + getSelectionEventDurations, + getLoadingDurations, + loadBlocksFromHtml, + load1000Paragraphs, + sum, +} = require( '../utils' ); + +// See https://github.com/WordPress/gutenberg/issues/51383#issuecomment-1613460429 +const BROWSER_IDLE_WAIT = 1000; + +const results = { + serverResponse: [], + firstPaint: [], + domContentLoaded: [], + loaded: [], + firstContentfulPaint: [], + firstBlock: [], + type: [], + typeContainer: [], + focus: [], + listViewOpen: [], + inserterOpen: [], + inserterHover: [], + inserterSearch: [], +}; + +test.describe( 'Post Editor Performance', () => { + test.afterAll( async ( {}, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + } ); + + test.beforeEach( async ( { admin, page } ) => { + await admin.createNewPost(); + // Disable auto-save to avoid impacting the metrics. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).updateEditorSettings( { + autosaveInterval: 100000000000, + localAutosaveInterval: 100000000000, + } ); + } ); + } ); + + test( 'Loading', async ( { browser, page } ) => { + // Turn the large post HTML into blocks and insert. + await loadBlocksFromHtml( + page, + path.join( process.env.ASSETS_PATH, 'large-post.html' ) + ); + + // Save the draft. + await page + .getByRole( 'button', { name: 'Save draft' } ) + .click( { timeout: 60_000 } ); + await expect( + page.getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + + // Get the URL that we will be testing against. + const draftURL = page.url(); + + // Start the measurements. + const samples = 10; + const throwaway = 1; + const rounds = throwaway + samples; + for ( let i = 0; i < rounds; i++ ) { + // Open a fresh page in a new context to prevent caching. + const testPage = await browser.newPage(); + + // Go to the test page URL. + await testPage.goto( draftURL ); + + // Get canvas (handles both legacy and iframed canvas). + const canvas = await Promise.any( [ + ( async () => { + const legacyCanvasLocator = page.locator( + '.wp-block-post-content' + ); + await legacyCanvasLocator.waitFor(); + return legacyCanvasLocator; + } )(), + ( async () => { + const iframedCanvasLocator = page.frameLocator( + '[name=editor-canvas]' + ); + await iframedCanvasLocator.locator( 'body' ).waitFor(); + return iframedCanvasLocator; + } )(), + ] ); + + await canvas.locator( '.wp-block' ).first().waitFor( { + timeout: 120_000, + } ); + + // Save the results. + if ( i >= throwaway ) { + const loadingDurations = await getLoadingDurations( testPage ); + Object.entries( loadingDurations ).forEach( + ( [ metric, duration ] ) => { + results[ metric ].push( duration ); + } + ); + } + + await testPage.close(); + } + } ); + + test( 'Typing', async ( { browser, page, editor } ) => { + // Load the large post fixture. + await loadBlocksFromHtml( + page, + path.join( process.env.ASSETS_PATH, 'large-post.html' ) + ); + + // Append an empty paragraph. + await editor.insertBlock( { name: 'core/paragraph' } ); + + // Start tracing. + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + // The first character typed triggers a longer time (isTyping change). + // It can impact the stability of the metric, so we exclude it. It + // probably deserves a dedicated metric itself, though. + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + + // Type the testing sequence into the empty paragraph. + await page.keyboard.type( 'x'.repeat( rounds ), { + delay: BROWSER_IDLE_WAIT, + } ); + + // Stop tracing and save results. + const traceBuffer = await browser.stopTracing(); + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ keyDownEvents, keyPressEvents, keyUpEvents ] = + getTypingEventDurations( traceResults ); + + for ( let i = throwaway; i < rounds; i++ ) { + results.type.push( + keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] + ); + } + } ); + + test( 'Typing within containers', async ( { browser, page, editor } ) => { + await loadBlocksFromHtml( + page, + path.join( + process.env.ASSETS_PATH, + 'small-post-with-containers.html' + ) + ); + + // Select the block where we type in + await editor.canvas + .getByRole( 'document', { name: 'Paragraph block' } ) + .first() + .click(); + + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + const samples = 10; + // The first character typed triggers a longer time (isTyping change). + // It can impact the stability of the metric, so we exclude it. It + // probably deserves a dedicated metric itself, though. + const throwaway = 1; + const rounds = samples + throwaway; + await page.keyboard.type( 'x'.repeat( rounds ), { + delay: BROWSER_IDLE_WAIT, + } ); + + const traceBuffer = await browser.stopTracing(); + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ keyDownEvents, keyPressEvents, keyUpEvents ] = + getTypingEventDurations( traceResults ); + + for ( let i = throwaway; i < rounds; i++ ) { + results.typeContainer.push( + keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] + ); + } + } ); + + test( 'Selecting blocks', async ( { browser, page, editor } ) => { + await load1000Paragraphs( page ); + const paragraphs = editor.canvas.locator( '.wp-block' ); + + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + // Wait for the browser to be idle before starting the monitoring. + // eslint-disable-next-line no-restricted-syntax + await page.waitForTimeout( BROWSER_IDLE_WAIT ); + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + await paragraphs.nth( i ).click(); + + const traceBuffer = await browser.stopTracing(); + + if ( i >= throwaway ) { + const traceResults = JSON.parse( traceBuffer.toString() ); + const allDurations = getSelectionEventDurations( traceResults ); + results.focus.push( + allDurations.reduce( ( acc, eventDurations ) => { + return acc + sum( eventDurations ); + }, 0 ) + ); + } + } + } ); + + test( 'Opening persistent list view', async ( { browser, page } ) => { + await load1000Paragraphs( page ); + const listViewToggle = page.getByRole( 'button', { + name: 'Document Overview', + } ); + + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + // Wait for the browser to be idle before starting the monitoring. + // eslint-disable-next-line no-restricted-syntax + await page.waitForTimeout( BROWSER_IDLE_WAIT ); + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + // Open List View + await listViewToggle.click(); + + const traceBuffer = await browser.stopTracing(); + + if ( i >= throwaway ) { + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ mouseClickEvents ] = + getClickEventDurations( traceResults ); + results.listViewOpen.push( mouseClickEvents[ 0 ] ); + } + + // Close List View + await listViewToggle.click(); + } + } ); + + test( 'Opening the inserter', async ( { browser, page } ) => { + await load1000Paragraphs( page ); + const globalInserterToggle = page.getByRole( 'button', { + name: 'Toggle block inserter', + } ); + + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + // Wait for the browser to be idle before starting the monitoring. + // eslint-disable-next-line no-restricted-syntax + await page.waitForTimeout( BROWSER_IDLE_WAIT ); + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + // Open Inserter. + await globalInserterToggle.click(); + + const traceBuffer = await browser.stopTracing(); + + if ( i >= throwaway ) { + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ mouseClickEvents ] = + getClickEventDurations( traceResults ); + results.inserterOpen.push( mouseClickEvents[ 0 ] ); + } + + // Close Inserter. + await globalInserterToggle.click(); + } + } ); + + test( 'Searching the inserter', async ( { browser, page } ) => { + await load1000Paragraphs( page ); + const globalInserterToggle = page.getByRole( 'button', { + name: 'Toggle block inserter', + } ); + + // Open Inserter. + await globalInserterToggle.click(); + + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + // Wait for the browser to be idle before starting the monitoring. + // eslint-disable-next-line no-restricted-syntax + await page.waitForTimeout( BROWSER_IDLE_WAIT ); + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + await page.keyboard.type( 'p' ); + + const traceBuffer = await browser.stopTracing(); + + if ( i >= throwaway ) { + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ keyDownEvents, keyPressEvents, keyUpEvents ] = + getTypingEventDurations( traceResults ); + results.inserterSearch.push( + keyDownEvents[ 0 ] + keyPressEvents[ 0 ] + keyUpEvents[ 0 ] + ); + } + + await page.keyboard.press( 'Backspace' ); + } + + // Close Inserter. + await globalInserterToggle.click(); + } ); + + test( 'Hovering Inserter Items', async ( { browser, page } ) => { + await load1000Paragraphs( page ); + const globalInserterToggle = page.getByRole( 'button', { + name: 'Toggle block inserter', + } ); + const paragraphBlockItem = page.locator( + '.block-editor-inserter__menu .editor-block-list-item-paragraph' + ); + const headingBlockItem = page.locator( + '.block-editor-inserter__menu .editor-block-list-item-heading' + ); + + // Open Inserter. + await globalInserterToggle.click(); + + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + // Wait for the browser to be idle before starting the monitoring. + // eslint-disable-next-line no-restricted-syntax + await page.waitForTimeout( BROWSER_IDLE_WAIT ); + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + // Hover Items. + await paragraphBlockItem.hover(); + await headingBlockItem.hover(); + + const traceBuffer = await browser.stopTracing(); + + if ( i >= throwaway ) { + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ mouseOverEvents, mouseOutEvents ] = + getHoverEventDurations( traceResults ); + for ( let k = 0; k < mouseOverEvents.length; k++ ) { + results.inserterHover.push( + mouseOverEvents[ k ] + mouseOutEvents[ k ] + ); + } + } + } + + // Close Inserter. + await globalInserterToggle.click(); + } ); +} ); diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js new file mode 100644 index 00000000000000..2a96cf30892235 --- /dev/null +++ b/test/performance/specs/site-editor.spec.js @@ -0,0 +1,218 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { + getTypingEventDurations, + getLoadingDurations, + loadBlocksFromHtml, +} = require( '../utils' ); + +// See https://github.com/WordPress/gutenberg/issues/51383#issuecomment-1613460429 +const BROWSER_IDLE_WAIT = 1000; + +const results = { + serverResponse: [], + firstPaint: [], + domContentLoaded: [], + loaded: [], + firstContentfulPaint: [], + firstBlock: [], + type: [], + typeContainer: [], + focus: [], + inserterOpen: [], + inserterHover: [], + inserterSearch: [], + listViewOpen: [], +}; + +let testPageId; + +test.describe( 'Site Editor Performance', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.deleteAllTemplates( 'wp_template' ); + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + } ); + + test.afterAll( async ( { requestUtils }, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + + await requestUtils.deleteAllTemplates( 'wp_template' ); + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.beforeEach( async ( { admin, page } ) => { + // Start a new page. + await admin.createNewPost( { postType: 'page' } ); + + // Disable auto-save to avoid impacting the metrics. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).updateEditorSettings( { + autosaveInterval: 100000000000, + localAutosaveInterval: 100000000000, + } ); + } ); + } ); + + test( 'Loading', async ( { browser, page, admin } ) => { + // Load the large post fixture. + await loadBlocksFromHtml( + page, + path.join( process.env.ASSETS_PATH, 'large-post.html' ) + ); + + // Save the draft. + await page + .getByRole( 'button', { name: 'Save draft' } ) + .click( { timeout: 60_000 } ); + await expect( + page.getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + + // Get the ID of the saved page. + testPageId = await page.evaluate( () => + new URL( document.location ).searchParams.get( 'post' ) + ); + + // Open the test page in Site Editor. + await admin.visitSiteEditor( { + postId: testPageId, + postType: 'page', + } ); + + // Get the URL that we will be testing against. + const draftURL = page.url(); + + // Start the measurements. + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + for ( let i = 0; i < rounds; i++ ) { + // Open a fresh page in a new context to prevent caching. + const testPage = await browser.newPage(); + + // Go to the test page URL. + await testPage.goto( draftURL ); + + // Wait for the canvas to appear. + await testPage + .locator( '.edit-site-canvas-loader' ) + .waitFor( { state: 'hidden', timeout: 60_000 } ); + + // Wait for the first block. + await testPage + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.wp-block' ) + .first() + .waitFor( { timeout: 60_000 } ); + + // Save the results. + if ( i >= throwaway ) { + const loadingDurations = await getLoadingDurations( testPage ); + Object.entries( loadingDurations ).forEach( + ( [ metric, duration ] ) => { + results[ metric ].push( duration ); + } + ); + } + + await testPage.close(); + } + } ); + + test( 'Typing', async ( { browser, page, admin, editor } ) => { + // Load the large post fixture. + await loadBlocksFromHtml( + page, + path.join( process.env.ASSETS_PATH, 'large-post.html' ) + ); + + // Save the draft. + await page + .getByRole( 'button', { name: 'Save draft' } ) + // Loading the large post HTML can take some time so we need a higher + // timeout value here. + .click( { timeout: 60_000 } ); + await expect( + page.getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + + // Get the ID of the saved page. + testPageId = new URL( page.url() ).searchParams.get( 'post' ); + + // Open the test page in Site Editor. + await admin.visitSiteEditor( { + postId: testPageId, + postType: 'page', + } ); + + // Wait for the first paragraph to be ready. + const firstParagraph = editor.canvas + .getByText( 'Lorem ipsum dolor sit amet' ) + .first(); + await firstParagraph.waitFor( { timeout: 60_000 } ); + + // Enter edit mode. + await editor.canvas.locator( 'body' ).click(); + // Second click is needed for the legacy edit mode. + await editor.canvas + .getByRole( 'document', { name: /Block:( Post)? Content/ } ) + .click(); + + // Append an empty paragraph. + // Since `editor.insertBlock( { name: 'core/paragraph' } )` is not + // working in page edit mode, we need to _manually_ insert a new + // paragraph. + await editor.canvas + .getByText( 'Quamquam tu hanc copiosiorem etiam soles dicere.' ) + .last() + .click(); // Enters edit mode for the last post's element, which is a list item. + + await page.keyboard.press( 'Enter' ); // Creates a new list item. + await page.keyboard.press( 'Enter' ); // Exits the list and creates a new paragraph. + + // Start tracing. + await browser.startTracing( page, { + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + + // The first character typed triggers a longer time (isTyping change). + // It can impact the stability of the metric, so we exclude it. It + // probably deserves a dedicated metric itself, though. + const samples = 10; + const throwaway = 1; + const rounds = samples + throwaway; + + // Type the testing sequence into the empty paragraph. + await page.keyboard.type( 'x'.repeat( rounds ), { + delay: BROWSER_IDLE_WAIT, + } ); + + // Stop tracing and save results. + const traceBuffer = await browser.stopTracing(); + const traceResults = JSON.parse( traceBuffer.toString() ); + const [ keyDownEvents, keyPressEvents, keyUpEvents ] = + getTypingEventDurations( traceResults ); + for ( let i = throwaway; i < rounds; i++ ) { + results.type.push( + keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] + ); + } + } ); +} ); diff --git a/test/performance/tsconfig.json b/test/performance/tsconfig.json new file mode 100644 index 00000000000000..7f855fd0ba69c8 --- /dev/null +++ b/test/performance/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false, + "allowJs": true, + "checkJs": false + }, + "include": [ "**/*" ], + "exclude": [] +} diff --git a/test/performance/utils.js b/test/performance/utils.js new file mode 100644 index 00000000000000..b86d09a10b301d --- /dev/null +++ b/test/performance/utils.js @@ -0,0 +1,198 @@ +/** + * External dependencies + */ +import { existsSync, readFileSync, unlinkSync } from 'fs'; + +export function sum( array ) { + if ( ! array || ! array.length ) return undefined; + + return array.reduce( ( a, b ) => a + b, 0 ); +} + +export function average( array ) { + if ( ! array || ! array.length ) return undefined; + + return sum( array ) / array.length; +} + +export function median( array ) { + if ( ! array || ! array.length ) return undefined; + + const numbers = [ ...array ].sort( ( a, b ) => a - b ); + const middleIndex = Math.floor( numbers.length / 2 ); + + if ( numbers.length % 2 === 0 ) { + return ( numbers[ middleIndex - 1 ] + numbers[ middleIndex ] ) / 2; + } + return numbers[ middleIndex ]; +} + +export function minimum( array ) { + if ( ! array || ! array.length ) return undefined; + + return Math.min( ...array ); +} + +export function maximum( array ) { + if ( ! array || ! array.length ) return undefined; + + return Math.max( ...array ); +} + +export function round( number, decimalPlaces = 2 ) { + const factor = Math.pow( 10, decimalPlaces ); + + return Math.round( number * factor ) / factor; +} + +export function readFile( filePath ) { + if ( ! existsSync( filePath ) ) { + throw new Error( `File does not exist: ${ filePath }` ); + } + + return readFileSync( filePath, 'utf8' ).trim(); +} + +export function deleteFile( filePath ) { + if ( existsSync( filePath ) ) { + unlinkSync( filePath ); + } +} + +function isEvent( item ) { + return ( + item.cat === 'devtools.timeline' && + item.name === 'EventDispatch' && + item.dur && + item.args && + item.args.data + ); +} + +function isKeyDownEvent( item ) { + return isEvent( item ) && item.args.data.type === 'keydown'; +} + +function isKeyPressEvent( item ) { + return isEvent( item ) && item.args.data.type === 'keypress'; +} + +function isKeyUpEvent( item ) { + return isEvent( item ) && item.args.data.type === 'keyup'; +} + +function isFocusEvent( item ) { + return isEvent( item ) && item.args.data.type === 'focus'; +} + +function isFocusInEvent( item ) { + return isEvent( item ) && item.args.data.type === 'focusin'; +} + +function isClickEvent( item ) { + return isEvent( item ) && item.args.data.type === 'click'; +} + +function isMouseOverEvent( item ) { + return isEvent( item ) && item.args.data.type === 'mouseover'; +} + +function isMouseOutEvent( item ) { + return isEvent( item ) && item.args.data.type === 'mouseout'; +} + +function getEventDurationsForType( trace, filterFunction ) { + return trace.traceEvents + .filter( filterFunction ) + .map( ( item ) => item.dur / 1000 ); +} + +export function getTypingEventDurations( trace ) { + return [ + getEventDurationsForType( trace, isKeyDownEvent ), + getEventDurationsForType( trace, isKeyPressEvent ), + getEventDurationsForType( trace, isKeyUpEvent ), + ]; +} + +export function getSelectionEventDurations( trace ) { + return [ + getEventDurationsForType( trace, isFocusEvent ), + getEventDurationsForType( trace, isFocusInEvent ), + ]; +} + +export function getClickEventDurations( trace ) { + return [ getEventDurationsForType( trace, isClickEvent ) ]; +} + +export function getHoverEventDurations( trace ) { + return [ + getEventDurationsForType( trace, isMouseOverEvent ), + getEventDurationsForType( trace, isMouseOutEvent ), + ]; +} + +export async function getLoadingDurations( page ) { + return await page.evaluate( () => { + const [ + { + requestStart, + responseStart, + responseEnd, + domContentLoadedEventEnd, + loadEventEnd, + }, + ] = performance.getEntriesByType( 'navigation' ); + const paintTimings = performance.getEntriesByType( 'paint' ); + return { + // Server side metric. + serverResponse: responseStart - requestStart, + // For client side metrics, consider the end of the response (the + // browser receives the HTML) as the start time (0). + firstPaint: + paintTimings.find( ( { name } ) => name === 'first-paint' ) + .startTime - responseEnd, + domContentLoaded: domContentLoadedEventEnd - responseEnd, + loaded: loadEventEnd - responseEnd, + firstContentfulPaint: + paintTimings.find( + ( { name } ) => name === 'first-contentful-paint' + ).startTime - responseEnd, + // This is evaluated right after Playwright found the block selector. + firstBlock: performance.now() - responseEnd, + }; + } ); +} + +export async function loadBlocksFromHtml( page, filepath ) { + if ( ! existsSync( filepath ) ) { + throw new Error( `File not found (${ filepath })` ); + } + + return await page.evaluate( ( html ) => { + const { parse } = window.wp.blocks; + const { dispatch } = window.wp.data; + const blocks = parse( html ); + + blocks.forEach( ( block ) => { + if ( block.name === 'core/image' ) { + delete block.attributes.id; + delete block.attributes.url; + } + } ); + + dispatch( 'core/block-editor' ).resetBlocks( blocks ); + }, readFile( filepath ) ); +} + +export async function load1000Paragraphs( page ) { + await page.evaluate( () => { + const { createBlock } = window.wp.blocks; + const { dispatch } = window.wp.data; + const blocks = Array.from( { length: 1000 } ).map( () => + createBlock( 'core/paragraph' ) + ); + dispatch( 'core/block-editor' ).resetBlocks( blocks ); + } ); +} diff --git a/test/php/gutenberg-coding-standards/.gitignore b/test/php/gutenberg-coding-standards/.gitignore new file mode 100644 index 00000000000000..bfec4c3c303b1f --- /dev/null +++ b/test/php/gutenberg-coding-standards/.gitignore @@ -0,0 +1,5 @@ +vendor +composer.lock +phpunit.xml +phpcs.xml +.phpcs.xml diff --git a/test/php/gutenberg-coding-standards/.phpcs.xml.dist b/test/php/gutenberg-coding-standards/.phpcs.xml.dist new file mode 100644 index 00000000000000..d6862f9a00fa9e --- /dev/null +++ b/test/php/gutenberg-coding-standards/.phpcs.xml.dist @@ -0,0 +1,88 @@ +<?xml version="1.0"?> +<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="WordPress Coding Standards" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/squizlabs/PHP_CodeSniffer/master/phpcs.xsd"> + + <description>The Coding standard for the Gutenberg Coding Standards itself.</description> + + <!-- + ############################################################################# + COMMAND LINE ARGUMENTS + https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-Ruleset + ############################################################################# + --> + + <file>.</file> + + <!-- Exclude Composer vendor directory. --> + <exclude-pattern>*/vendor/*</exclude-pattern> + + <!-- Only check PHP files. --> + <arg name="extensions" value="php"/> + + <!-- Show progress, show the error codes for each message (source). --> + <arg value="ps"/> + + <!-- Strip the filepaths down to the relevant bit. --> + <arg name="basepath" value="."/> + + <!-- Check up to 8 files simultaneously. --> + <arg name="parallel" value="8"/> + + + <!-- + ############################################################################# + SET UP THE RULESETS + ############################################################################# + --> + + <rule ref="WordPress"> + <!-- This project needs to comply with naming standards from PHPCS, not WP. --> + <exclude name="WordPress.Files.FileName"/> + <exclude name="WordPress.NamingConventions.ValidVariableName"/> + + <!-- While conditions with assignments are a typical way to walk the token stream. --> + <exclude name="Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition"/> + + <!-- The code in this project is run in the context of PHPCS, not WP. --> + <exclude name="WordPress.DateTime"/> + <exclude name="WordPress.DB"/> + <exclude name="WordPress.Security"/> + <exclude name="WordPress.WP"/> + + <!-- Linting is done in a separate CI job, no need to duplicate it. --> + <exclude name="Generic.PHP.Syntax"/> + <!--This rule should only be applied to code that is intended to be merged into Core. --> + <exclude name="Gutenberg.CodeAnalysis.GuardedFunctionAndClassNames"/> + </rule> + + <!-- Check code for cross-version PHP compatibility. --> + <config name="testVersion" value="5.4-"/> + <rule ref="PHPCompatibility"> + <!-- Exclude PHP constants back-filled by PHPCS. --> + <exclude name="PHPCompatibility.Constants.NewConstants.t_finallyFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_yieldFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_ellipsisFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_powFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_pow_equalFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_spaceshipFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_coalesceFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_coalesce_equalFound"/> + <exclude name="PHPCompatibility.Constants.NewConstants.t_yield_fromFound"/> + </rule> + + <!-- Enforce PSR1 compatible namespaces. --> + <rule ref="PSR1.Classes.ClassDeclaration"/> + + <!-- + ############################################################################# + SNIFF SPECIFIC CONFIGURATION + ############################################################################# + --> + + <rule ref="WordPress.Arrays.MultipleStatementAlignment"> + <properties> + <property name="alignMultilineItems" value="!=100"/> + <property name="exact" value="false" phpcs-only="true"/> + </properties> + </rule> + +</ruleset> diff --git a/test/php/gutenberg-coding-standards/Gutenberg/Sniffs/CodeAnalysis/GuardedFunctionAndClassNamesSniff.php b/test/php/gutenberg-coding-standards/Gutenberg/Sniffs/CodeAnalysis/GuardedFunctionAndClassNamesSniff.php new file mode 100644 index 00000000000000..65c42071192b4e --- /dev/null +++ b/test/php/gutenberg-coding-standards/Gutenberg/Sniffs/CodeAnalysis/GuardedFunctionAndClassNamesSniff.php @@ -0,0 +1,221 @@ +<?php +/** + * Gutenberg Coding Standards. + * + * @package gutenberg/gutenberg-coding-standards + * @link https://github.com/WordPress/gutenberg + * @license https://opensource.org/licenses/MIT MIT + */ + +namespace GutenbergCS\Gutenberg\Sniffs\CodeAnalysis; + +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Sniffs\Sniff; + +/** + * The sniff implements the Gutenberg coding standard to verify whether functions and classes + * are enclosed with function_exists() and !class_exists(). This check ensures that the functions + * and classes are not already defined, and recommends the use of function_exists() and class_exists() + * to prevent fatal errors during the integration of the feature to the Core. + * + * @link https://github.com/WordPress/gutenberg/blob/trunk/lib/README.md#wrap-functions-and-classes-with--function_exists-and--class_exists + * + * @package gutenberg/gutenberg-coding-standards + * + * @since 1.0.0 + */ +final class GuardedFunctionAndClassNamesSniff implements Sniff { + /** + * A list of functions to ignore. + * + * @var integer + */ + public $functionsWhiteList = array(); + + /** + * A list of classes to ignore. + * + * @var integer + */ + public $classesWhiteList = array(); + + /** + * Registers the tokens that this sniff wants to listen for. + * + * @since 3.0.0 + * + * @return array + */ + public function register() { + $this->onRegisterEvent(); + + return array( T_FUNCTION, T_CLASS ); + } + + /** + * Processes function and class tokens. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position of the current token + * in the stack passed in $tokens. + * + * @return void + */ + public function process( File $phpcsFile, $stackPtr ) { + $tokens = $phpcsFile->getTokens(); + $token = $tokens[ $stackPtr ]; + + if ( 'T_FUNCTION' === $token['type'] ) { + $this->processFunctionToken( $phpcsFile, $stackPtr ); + return; + } + + if ( 'T_CLASS' === $token['type'] ) { + $this->processClassToken( $phpcsFile, $stackPtr ); + } + } + + /** + * Functions should be wrapped with !function_exists() to avoid fatal errors. + * E.g.: + * if ( ! function_exists( 'wp_get_navigation' ) ) { + * function wp_get_navigation( $slug ) { ... } + * } + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPointer The position of the current token + * in the stack passed in $tokens. + * + * @return void + */ + private function processFunctionToken( File $phpcsFile, $stackPointer ) { + $tokens = $phpcsFile->getTokens(); + $functionToken = $phpcsFile->findNext( T_STRING, $stackPointer ); + + $wrappingTokensToCheck = array( + T_CLASS, + T_INTERFACE, + T_TRAIT, + ); + + foreach ( $wrappingTokensToCheck as $wrappingTokenToCheck ) { + if ( false !== $phpcsFile->getCondition( $functionToken, $wrappingTokenToCheck, false ) ) { + // This sniff only processes functions, not class methods. + return; + } + } + + $name = $tokens[ $functionToken ]['content']; + foreach ( $this->functionsWhiteList as $functionRegExp ) { + if ( preg_match( $functionRegExp, $name ) ) { + // Ignore whitelisted function names. + return; + } + } + + $errorMessage = sprintf( 'The "%s()" function should be guarded against redeclaration.', $name ); + + $wrappingIfToken = $phpcsFile->getCondition( $functionToken, T_IF, false ); + if ( false === $wrappingIfToken ) { + $phpcsFile->addError( $errorMessage, $functionToken, 'FunctionNotGuardedAgainstRedeclaration' ); + + return; + } + + $content = $phpcsFile->getTokensAsString( $wrappingIfToken, $functionToken - $wrappingIfToken ); + + $regexp = sprintf( '/if\s*\(\s*!\s*function_exists\s*\(\s*(\'|")%s(\'|")/', preg_quote( $name, '/' ) ); + $result = preg_match( $regexp, $content ); + if ( 1 !== $result ) { + $phpcsFile->addError( $errorMessage, $functionToken, 'FunctionNotGuardedAgainstRedeclaration' ); + } + } + + /** + * Classes should be wrapped with !function_exists() to avoid fatal errors. + * E.g.: + * if ( class_exists( 'WP_Navigation' ) ) { + * return; + * } + * + * Alternatively: + * + * if ( ! class_exists( 'WP_Navigation' ) ) { + * class WP_Navigation { ... } + * } + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPointer The position of the current token + * in the stack passed in $tokens. + * + * @return void + */ + private function processClassToken( File $phpcsFile, $stackPointer ) { + $tokens = $phpcsFile->getTokens(); + $classToken = $phpcsFile->findNext( T_STRING, $stackPointer ); + $className = $tokens[ $classToken ]['content']; + + foreach ( $this->classesWhiteList as $classnameRegExp ) { + if ( preg_match( $classnameRegExp, $className ) ) { + // Ignore whitelisted class names. + return; + } + } + + $errorMessage = sprintf( 'The "%s" class should be guarded against redeclaration.', $className ); + + $wrappingIfToken = $phpcsFile->getCondition( $classToken, T_IF, false ); + if ( false !== $wrappingIfToken ) { + $endOfWrappingIfToken = $phpcsFile->findEndOfStatement( $wrappingIfToken ); + $content = $phpcsFile->getTokensAsString( $wrappingIfToken, $endOfWrappingIfToken - $wrappingIfToken ); + $regexp = sprintf( '/if\s*\(\s*!\s*class_exists\s*\(\s*(\'|")%s(\'|")/', preg_quote( $className, '/' ) ); + $result = preg_match( $regexp, $content ); + if ( 1 === $result ) { + return; + } + } + + $previousIfToken = $phpcsFile->findPrevious( T_IF, $classToken ); + if ( false === $previousIfToken ) { + $phpcsFile->addError( $errorMessage, $classToken, 'ClassNotGuardedAgainstRedeclaration' ); + + return; + } + + $endOfPreviousIfToken = $phpcsFile->findEndOfStatement( $previousIfToken ); + $content = $phpcsFile->getTokensAsString( $previousIfToken, $endOfPreviousIfToken - $previousIfToken ); + $regexp = sprintf( '/if\s*\(\s*class_exists\s*\(\s*(\'|")%s(\'|")/', preg_quote( $className, '/' ) ); + $result = preg_match( $regexp, $content ); + + if ( 1 === $result ) { + $returnToken = $phpcsFile->findNext( T_RETURN, $previousIfToken, $endOfPreviousIfToken ); + if ( false !== $returnToken ) { + return; + } + } + + $phpcsFile->addError( $errorMessage, $classToken, 'ClassNotGuardedAgainstRedeclaration' ); + } + + /** + * The purpose of this method is to sanitize the input data + * after the properties have been set. + */ + private function onRegisterEvent() { + $this->functionsWhiteList = self::sanitize( $this->functionsWhiteList ); + $this->classesWhiteList = self::sanitize( $this->classesWhiteList ); + } + + /** + * Input data needs to be sanitized. + * + * @param array $values The values being sanitized. + * + * @return array + */ + private static function sanitize( $values ) { + $values = array_map( 'trim', $values ); + + return array_filter( $values ); + } +} diff --git a/test/php/gutenberg-coding-standards/Gutenberg/Sniffs/NamingConventions/ValidBlockLibraryFunctionNameSniff.php b/test/php/gutenberg-coding-standards/Gutenberg/Sniffs/NamingConventions/ValidBlockLibraryFunctionNameSniff.php new file mode 100644 index 00000000000000..5f72e378501278 --- /dev/null +++ b/test/php/gutenberg-coding-standards/Gutenberg/Sniffs/NamingConventions/ValidBlockLibraryFunctionNameSniff.php @@ -0,0 +1,173 @@ +<?php +/** + * Gutenberg Coding Standards. + * + * @package gutenberg/gutenberg-coding-standards + * @link https://github.com/WordPress/gutenberg + * @license https://opensource.org/licenses/MIT MIT + */ + +namespace GutenbergCS\Gutenberg\Sniffs\NamingConventions; + +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Sniffs\Sniff; + +/** + * + * This sniff checks function names to ensure they adhere to specified prefixes + * determined by the parent directory name. It enforces that function names start + * with one of the allowed prefixes defined in the sniffer configuration. + * + * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/README.md#naming-convention-for-php-functions + * + * @package gutenberg/gutenberg-coding-standards + * + * @since 1.0.0 + */ +final class ValidBlockLibraryFunctionNameSniff implements Sniff { + /** + * Target prefixes. + * + * @var array + */ + public $prefixes = array(); + + /** + * These functions are considered permissible and will be ignored by the sniffer. + * + * @var array + */ + public $allowed_functions = array(); + + /** + * Registers the tokens that this sniff wants to listen for. + * + * @return array + */ + public function register() { + $this->onRegisterEvent(); + + return array( T_FUNCTION ); + } + + /** + * Processes function tokens. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position of the current token + * in the stack passed in $tokens. + * + * @return void + */ + public function process( File $phpcsFile, $stackPtr ) { + $tokens = $phpcsFile->getTokens(); + $token = $tokens[ $stackPtr ]; + + if ( 'T_FUNCTION' !== $token['type'] ) { + return; + } + + $this->processFunctionToken( $phpcsFile, $stackPtr ); + } + + /** + * This method analyzes the function token and its name within the provided file. + * It checks if the function name adheres to allowed prefixes based on the parent directory name. + * If the function name is not valid, an error message is added to the code sniffer report. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPointer The position of the current token + * in the stack passed in $tokens. + * + * @return void + */ + private function processFunctionToken( File $phpcsFile, $stackPointer ) { + + if ( empty( $this->prefixes ) ) { + // Nothing to process. + return; + } + + $tokens = $phpcsFile->getTokens(); + $function_token = $phpcsFile->findNext( T_STRING, $stackPointer ); + + $wrapping_tokens_to_check = array( + T_CLASS, + T_INTERFACE, + T_TRAIT, + ); + + foreach ( $wrapping_tokens_to_check as $wrapping_token_to_check ) { + if ( false !== $phpcsFile->getCondition( $function_token, $wrapping_token_to_check, false ) ) { + // This sniff only processes functions, not class methods. + return; + } + } + + $function_name = $tokens[ $function_token ]['content']; + + if ( in_array( $function_name, $this->allowed_functions, true ) ) { + // The function name is included in the list of allowed functions; bypassing further checks. + return; + } + + $parent_directory_name = basename( dirname( $phpcsFile->getFilename() ) ); + + $allowed_function_prefixes = array(); + $is_function_name_valid = false; + foreach ( $this->prefixes as $prefix ) { + $prefix = rtrim( $prefix, '_' ); + $allowed_function_prefix = $prefix . '_' . self::sanitize_directory_name( $parent_directory_name ); + $allowed_function_prefixes[] = $allowed_function_prefix; + // Validate the name's correctness and ensure it does not end with an underscore. + $regexp = sprintf( '/^%s(|_.+)$/', preg_quote( $allowed_function_prefix, '/' ) ); + $is_function_name_valid |= ( 1 === preg_match( $regexp, $function_name ) ); + } + + if ( $is_function_name_valid ) { + return; + } + + $error_message = "The function name `{$function_name}()` is invalid because PHP function names in this file should start with one of the following prefixes: `" + . implode( '`, `', $allowed_function_prefixes ) . '`.'; + $phpcsFile->addError( $error_message, $function_token, 'FunctionNameInvalid' ); + } + + /** + * The purpose of this method is to run callbacks + * after the class properties have been set. + */ + private function onRegisterEvent() { + $this->prefixes = self::sanitize( $this->prefixes ); + $this->allowed_functions = self::sanitize( $this->allowed_functions ); + } + + /** + * Sanitize a directory name by converting it to lowercase and replacing non-letter + * and non-digit characters with underscores. + * + * @param string $directory_name + * + * @return string + */ + private static function sanitize_directory_name( $directory_name ) { + // Convert to lowercase. + $directory_name = strtolower( $directory_name ); + + // Replace non-letter and non-digit characters with underscores. + return preg_replace( '/[^a-z0-9]/', '_', $directory_name ); + } + + /** + * Sanitize an array of values by trimming each element and removing empty elements. + * + * @param array $values The values being sanitized. + * + * @return array + */ + private static function sanitize( $values ) { + $values = array_map( 'trim', $values ); + + return array_filter( $values ); + } +} diff --git a/test/php/gutenberg-coding-standards/Gutenberg/Tests/CodeAnalysis/GuardedFunctionAndClassNamesUnitTest.inc b/test/php/gutenberg-coding-standards/Gutenberg/Tests/CodeAnalysis/GuardedFunctionAndClassNamesUnitTest.inc new file mode 100644 index 00000000000000..5db9946d68ae31 --- /dev/null +++ b/test/php/gutenberg-coding-standards/Gutenberg/Tests/CodeAnalysis/GuardedFunctionAndClassNamesUnitTest.inc @@ -0,0 +1,26 @@ +<?php + +if ( class_exists( 'Foo' ) ) { + return; +} + +class Foo { +} + +if ( ! class_exists( 'Bar' ) ) { + class Bar { + public function baz() { + } + } +} + +class Baz { +} + +if ( ! function_exists( 'quux' ) ) { + function quux() { + } +} + +function blarg() { +} \ No newline at end of file diff --git a/test/php/gutenberg-coding-standards/Gutenberg/Tests/CodeAnalysis/GuardedFunctionAndClassNamesUnitTest.php b/test/php/gutenberg-coding-standards/Gutenberg/Tests/CodeAnalysis/GuardedFunctionAndClassNamesUnitTest.php new file mode 100644 index 00000000000000..fccc5e3d9cdfdd --- /dev/null +++ b/test/php/gutenberg-coding-standards/Gutenberg/Tests/CodeAnalysis/GuardedFunctionAndClassNamesUnitTest.php @@ -0,0 +1,39 @@ +<?php +/** + * Unit test class for Gutenberg Coding Standard. + * + * @package gutenberg-coding-standards/gbc + * @link https://github.com/WordPress/gutenberg + * @license https://opensource.org/licenses/MIT MIT + */ + +namespace GutenbergCS\Gutenberg\Tests\CodeAnalysis; + +use PHP_CodeSniffer\Tests\Standards\AbstractSniffUnitTest; + +/** + * Unit test class for the GuardedFunctionAndClassNames sniff. + */ +final class GuardedFunctionAndClassNamesUnitTest extends AbstractSniffUnitTest { + + /** + * Returns the lines where errors should occur. + * + * @return array <int line number> => <int number of errors> + */ + public function getErrorList() { + return array( + 17 => 1, + 25 => 1, + ); + } + + /** + * Returns the lines where warnings should occur. + * + * @return array <int line number> => <int number of warnings> + */ + public function getWarningList() { + return array(); + } +} diff --git a/test/php/gutenberg-coding-standards/Gutenberg/ruleset.xml b/test/php/gutenberg-coding-standards/Gutenberg/ruleset.xml new file mode 100644 index 00000000000000..784dfd9a1b2be3 --- /dev/null +++ b/test/php/gutenberg-coding-standards/Gutenberg/ruleset.xml @@ -0,0 +1,9 @@ +<?xml version="1.0"?> +<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="Gutenberg" namespace="GutenbergCS\Gutenberg" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/squizlabs/PHP_CodeSniffer/master/phpcs.xsd"> + + <description>Gutenberg Coding Standards</description> + + <rule ref="Gutenberg.CodeAnalysis.GuardedFunctionAndClassNames"/> + <rule ref="Gutenberg.NamingConventions.ValidBlockLibraryFunctionName"/> + +</ruleset> diff --git a/test/php/gutenberg-coding-standards/README.md b/test/php/gutenberg-coding-standards/README.md new file mode 100644 index 00000000000000..51f6574fa20534 --- /dev/null +++ b/test/php/gutenberg-coding-standards/README.md @@ -0,0 +1,3 @@ +# Gutenberg Coding Standards for Gutenberg + +This project is a collection of [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) rules (sniffs) to validate code developed for Gutenberg. It ensures code quality and adherence to coding conventions. \ No newline at end of file diff --git a/test/php/gutenberg-coding-standards/Tests/bootstrap.php b/test/php/gutenberg-coding-standards/Tests/bootstrap.php new file mode 100644 index 00000000000000..f28528bc1b9726 --- /dev/null +++ b/test/php/gutenberg-coding-standards/Tests/bootstrap.php @@ -0,0 +1,86 @@ +<?php +/** + * Gutenberg Coding Standard. + * + * Bootstrap file for running the tests. + * + * - Load the PHPCS PHPUnit bootstrap file providing cross-version PHPUnit support. + * {@link https://github.com/squizlabs/PHP_CodeSniffer/pull/1384} + * - Load the Composer autoload file. + * - Automatically limit the testing to the WordPressCS tests. + * + * @package gutenberg/gutenberg-coding-standards + * @link https://github.com/WordPress/gutenberg + * @license https://opensource.org/licenses/MIT MIT + */ + +if ( ! defined( 'PHP_CODESNIFFER_IN_TESTS' ) ) { + define( 'PHP_CODESNIFFER_IN_TESTS', true ); +} + +$ds = DIRECTORY_SEPARATOR; + +/* + * Load the necessary PHPCS files. + */ +// Get the PHPCS dir from an environment variable. +$phpcsDir = getenv( 'PHPCS_DIR' ); +$composerPHPCSPath = dirname( __DIR__ ) . $ds . 'vendor' . $ds . 'squizlabs' . $ds . 'php_codesniffer'; + +if ( false === $phpcsDir && is_dir( $composerPHPCSPath ) ) { + // PHPCS installed via Composer. + $phpcsDir = $composerPHPCSPath; +} elseif ( false !== $phpcsDir ) { + /* + * PHPCS in a custom directory. + * For this to work, the `PHPCS_DIR` needs to be set in a custom `phpunit.xml` file. + */ + $phpcsDir = realpath( $phpcsDir ); +} + +// Try and load the PHPCS autoloader. +if ( false !== $phpcsDir + && file_exists( $phpcsDir . $ds . 'autoload.php' ) + && file_exists( $phpcsDir . $ds . 'tests' . $ds . 'bootstrap.php' ) +) { + require_once $phpcsDir . $ds . 'autoload.php'; + require_once $phpcsDir . $ds . 'tests' . $ds . 'bootstrap.php'; // PHPUnit 6.x+ support. +} else { + echo 'Uh oh... can\'t find PHPCS. + +If you use Composer, please run `composer install`. +Otherwise, make sure you set a `PHPCS_DIR` environment variable in your phpunit.xml file +pointing to the PHPCS directory and that PHPCSUtils is included in the `installed_paths` +for that PHPCS install. +'; + + die( 1 ); +} + + +/* + * Set the PHPCS_IGNORE_TEST environment variable to ignore tests from other standards. + */ +$gbcsStandards = array( + 'Gutenberg' => true, +); + +$allStandards = PHP_CodeSniffer\Util\Standards::getInstalledStandards(); +$allStandards[] = 'Generic'; + +$standardsToIgnore = array(); +foreach ( $allStandards as $standard ) { + if ( isset( $gbcsStandards[ $standard ] ) === true ) { + continue; + } + + $standardsToIgnore[] = $standard; +} + +$standardsToIgnoreString = implode( ',', $standardsToIgnore ); + +// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_putenv -- This is not production, but test code. +putenv( "PHPCS_IGNORE_TESTS={$standardsToIgnoreString}" ); + +// Clean up. +unset( $ds, $phpcsDir, $composerPHPCSPath, $allStandards, $standardsToIgnore, $standard, $standardsToIgnoreString ); diff --git a/test/php/gutenberg-coding-standards/composer.json b/test/php/gutenberg-coding-standards/composer.json new file mode 100644 index 00000000000000..f4ca88e49e2ae0 --- /dev/null +++ b/test/php/gutenberg-coding-standards/composer.json @@ -0,0 +1,65 @@ +{ + "name": "gutenberg/gutenberg-coding-standards", + "type": "phpcodesniffer-standard", + "description": "PHP_CodeSniffer rules (sniffs) to enforce Gutenberg coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "Gutenberg" + ], + "license": "MIT", + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/gutenberg/graphs/contributors" + } + ], + "require": { + "php": ">=5.4", + "ext-filter": "*", + "squizlabs/php_codesniffer": "^3.7.2" + }, + "require-dev": { + "phpcompatibility/php-compatibility": "^9.0", + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "dealerdirect/phpcodesniffer-composer-installer": "*", + "wp-coding-standards/wpcs": "^2.2" + }, + "suggest": { + "ext-mbstring": "For improved results" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "lint": [ + "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --show-deprecated --exclude vendor --exclude .git" + ], + "check-cs": [ + "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs" + ], + "fix-cs": [ + "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf" + ], + "run-tests": [ + "@php ./vendor/phpunit/phpunit/phpunit -c phpunit.xml.dist --filter Gutenberg ./vendor/squizlabs/php_codesniffer/tests/AllTests.php" + ], + "check-all": [ + "@lint", + "@check-cs", + "@run-tests" + ] + }, + "scripts-descriptions": { + "lint": "Lint PHP files against parse errors.", + "check-cs": "Run the PHPCS script against the entire codebase.", + "fix-cs": "Run the PHPCBF script to fix all the autofixable violations on the codebase.", + "run-tests": "Run all the unit tests for the Gutenberg Coding Standards sniffs.", + "check-all": "Run all checks (lint, phpcs) and tests." + } +} diff --git a/test/php/gutenberg-coding-standards/phpunit.xml.dist b/test/php/gutenberg-coding-standards/phpunit.xml.dist new file mode 100644 index 00000000000000..0e9d9cb5b75071 --- /dev/null +++ b/test/php/gutenberg-coding-standards/phpunit.xml.dist @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.3/phpunit.xsd" + backupGlobals="true" + bootstrap="./Tests/bootstrap.php" + beStrictAboutTestsThatDoNotTestAnything="false" + colors="true"> + + <testsuites> + <testsuite name="Gutenberg"> + <directory suffix="UnitTest.php">./Gutenberg/Tests/</directory> + </testsuite> + </testsuites> + +</phpunit> diff --git a/test/storybook-playwright/playwright.config.ts b/test/storybook-playwright/playwright.config.ts index eabe9b17121f2d..091398371e902b 100644 --- a/test/storybook-playwright/playwright.config.ts +++ b/test/storybook-playwright/playwright.config.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { PlaywrightTestConfig } from '@playwright/test'; +import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { outputDir: 'test-results/output', diff --git a/test/storybook-playwright/specs/button.spec.ts b/test/storybook-playwright/specs/button.spec.ts new file mode 100644 index 00000000000000..d45fddfec0b5a1 --- /dev/null +++ b/test/storybook-playwright/specs/button.spec.ts @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { expect, test } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { + gotoStoryId, + getAllPropsPermutations, + testSnapshotForPropsConfig, +} from '../utils'; + +test.describe( 'Button', () => { + test.describe( 'variant states', () => { + test.beforeEach( async ( { page } ) => { + gotoStoryId( page, 'components-button--variant-states', { + decorators: { customE2EControls: 'show' }, + } ); + } ); + + getAllPropsPermutations( [ + { + propName: '__next40pxDefaultSize', + valuesToTest: [ true, false ], + }, + ] ).forEach( ( propsConfig ) => { + test( `should render with ${ JSON.stringify( + propsConfig + ) }`, async ( { page } ) => { + await testSnapshotForPropsConfig( page, propsConfig ); + } ); + } ); + } ); + + test.describe( 'icon', () => { + test.beforeEach( async ( { page } ) => { + gotoStoryId( page, 'components-button--icon', { + decorators: { customE2EControls: 'show' }, + } ); + } ); + + getAllPropsPermutations( [ + { + propName: '__next40pxDefaultSize', + valuesToTest: [ true, false ], + }, + ] ).forEach( ( propsConfig ) => { + test( `should render with ${ JSON.stringify( + propsConfig + ) }`, async ( { page } ) => { + await testSnapshotForPropsConfig( page, propsConfig ); + } ); + } ); + } ); + + test.describe( 'dashicon', () => { + test.beforeEach( async ( { page } ) => { + await gotoStoryId( page, 'components-button--dashicons', { + decorators: { css: 'wordpress' }, + } ); + // Wait for dashicons font to load + await page.waitForFunction( () => + document.fonts.check( '20px dashicons' ) + ); + } ); + + test( 'should render with correct spacing', async ( { page } ) => { + expect( await page.screenshot() ).toMatchSnapshot(); + } ); + } ); +} ); diff --git a/test/storybook-playwright/storybook/main.js b/test/storybook-playwright/storybook/main.js index ba05d2ee1c5693..e3023f844da2db 100644 --- a/test/storybook-playwright/storybook/main.js +++ b/test/storybook-playwright/storybook/main.js @@ -6,8 +6,9 @@ const baseConfig = require( '../../../storybook/main' ); const config = { ...baseConfig, addons: [ '@storybook/addon-toolbars' ], + docs: undefined, stories: [ - '../../../packages/components/src/**/stories/e2e/*.@(js|tsx|mdx)', + '../../../packages/components/src/**/stories/e2e/*.story.@(js|tsx|mdx)', ], }; diff --git a/test/storybook-playwright/storybook/webpack.config.js b/test/storybook-playwright/storybook/webpack.config.js deleted file mode 100644 index 1c6d2f700bc594..00000000000000 --- a/test/storybook-playwright/storybook/webpack.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Internal dependencies - */ -const baseConfig = require( '../../../storybook/webpack.config' ); - -module.exports = baseConfig; diff --git a/test/storybook-playwright/utils.ts b/test/storybook-playwright/utils.ts index d3f37aa7df26bc..61ab7b3552edd2 100644 --- a/test/storybook-playwright/utils.ts +++ b/test/storybook-playwright/utils.ts @@ -21,7 +21,7 @@ const buildDecoratorString = ( decorators: Decorators = {} ) => { return decoratorParamStrings.join( ';' ); }; -export const gotoStoryId = ( +export const gotoStoryId = async ( page: Page, storyId: string, { decorators }: Options = {} @@ -35,7 +35,7 @@ export const gotoStoryId = ( params.set( 'id', storyId ); - page.goto( + await page.goto( `http://localhost:${ STORYBOOK_PORT }/iframe.html?${ params.toString() }`, { waitUntil: 'load' } ); @@ -62,9 +62,10 @@ export const getAllPropsPermutations = ( // Test all values for the given prop. for ( const value of propObject.valuesToTest ) { + const valueAsString = value === undefined ? 'undefined' : value; const newAccProps = { ...accProps, - [ propObject.propName ]: value, + [ propObject.propName ]: valueAsString, }; if ( restProps.length === 0 ) { @@ -99,5 +100,7 @@ export const testSnapshotForPropsConfig = async ( await submitButton.click(); - expect( await page.screenshot() ).toMatchSnapshot(); + expect( + await page.screenshot( { animations: 'disabled' } ) + ).toMatchSnapshot(); }; diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 19ff4e90e214e7..88acc23da59413 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -29,6 +29,7 @@ const blockViewRegex = new RegExp( */ const prefixFunctions = [ 'build_query_vars_from_query_block', + 'wp_apply_colors_support', 'wp_enqueue_block_support_styles', 'wp_get_typography_font_size_value', 'wp_style_engine_get_styles', @@ -219,82 +220,4 @@ module.exports = [ } ), ].filter( Boolean ), }, - { - ...baseConfig, - watchOptions: { - aggregateTimeout: 200, - }, - name: 'interactivity', - entry: { - file: './packages/block-library/src/file/interactivity.js', - navigation: - './packages/block-library/src/navigation/interactivity.js', - image: './packages/block-library/src/image/interactivity.js', - }, - output: { - devtoolNamespace: 'wp', - filename: './blocks/[name]/interactivity.min.js', - path: join( __dirname, '..', '..', 'build', 'block-library' ), - }, - optimization: { - ...baseConfig.optimization, - runtimeChunk: { - name: 'vendors', - }, - splitChunks: { - cacheGroups: { - vendors: { - name: 'vendors', - test: /[\\/]node_modules[\\/]/, - filename: './interactivity/[name].min.js', - minSize: 0, - chunks: 'all', - }, - runtime: { - name: 'runtime', - test: /[\\/]utils[\\/]interactivity[\\/]/, - filename: './interactivity/[name].min.js', - chunks: 'all', - minSize: 0, - priority: -10, - }, - }, - }, - }, - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - exclude: /node_modules/, - use: [ - { - loader: require.resolve( 'babel-loader' ), - options: { - cacheDirectory: - process.env.BABEL_CACHE_DIRECTORY || true, - babelrc: false, - configFile: false, - presets: [ - [ - '@babel/preset-react', - { - runtime: 'automatic', - importSource: 'preact', - }, - ], - ], - }, - }, - ], - }, - ], - }, - plugins: [ - ...plugins, - new DependencyExtractionWebpackPlugin( { - __experimentalInjectInteractivityRuntime: true, - injectPolyfill: false, - } ), - ].filter( Boolean ), - }, ]; diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js new file mode 100644 index 00000000000000..26e49966ad40c3 --- /dev/null +++ b/tools/webpack/interactivity.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +const { join } = require( 'path' ); + +/** + * Internal dependencies + */ +const { baseConfig } = require( './shared' ); + +module.exports = { + ...baseConfig, + name: 'interactivity', + entry: { + index: { + import: `./packages/interactivity/src/index.js`, + library: { + name: [ 'wp', 'interactivity' ], + type: 'window', + }, + }, + }, + output: { + devtoolNamespace: 'wp', + filename: './build/interactivity/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve( 'babel-loader' ), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], + ], + }, + }, + ], + }, + ], + }, + watchOptions: { + ignored: [ '**/node_modules' ], + aggregateTimeout: 500, + }, +}; diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index 30f73a82fa0da2..e5bb74abdb0a1f 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -2,6 +2,7 @@ * External dependencies */ const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); +const MomentTimezoneDataPlugin = require( 'moment-timezone-data-webpack-plugin' ); const { join } = require( 'path' ); /** @@ -75,7 +76,8 @@ const gutenbergPackages = Object.keys( dependencies ) ( packageName ) => ! BUNDLED_PACKAGES.includes( packageName ) && packageName.startsWith( WORDPRESS_NAMESPACE ) && - ! packageName.startsWith( WORDPRESS_NAMESPACE + 'react-native' ) + ! packageName.startsWith( WORDPRESS_NAMESPACE + 'react-native' ) && + ! packageName.startsWith( WORDPRESS_NAMESPACE + 'interactivity' ) ) .map( ( packageName ) => packageName.replace( WORDPRESS_NAMESPACE, '' ) ); @@ -156,5 +158,9 @@ module.exports = { .concat( bundledPackagesPhpConfig ) .concat( vendorsCopyConfig ), } ), + new MomentTimezoneDataPlugin( { + startYear: 2000, + endYear: 2040, + } ), ].filter( Boolean ), }; diff --git a/tools/webpack/shared.js b/tools/webpack/shared.js index 9aaa35737400c9..debd3fc93f6f6d 100644 --- a/tools/webpack/shared.js +++ b/tools/webpack/shared.js @@ -69,6 +69,8 @@ const plugins = [ // Inject the `IS_WORDPRESS_CORE` global, used for feature flagging. 'process.env.IS_WORDPRESS_CORE': process.env.npm_package_config_IS_WORDPRESS_CORE, + // Inject the `SCRIPT_DEBUG` global, used for dev versions of JavaScript. + SCRIPT_DEBUG: mode === 'development', } ), mode === 'production' && new ReadableJsAssetsWebpackPlugin(), ]; diff --git a/tsconfig.base.json b/tsconfig.base.json index 201304f455a131..495796402bdf85 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,7 +21,6 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "importsNotUsedAsValues": "error", /* Module Resolution Options */ "moduleResolution": "node", diff --git a/tsconfig.json b/tsconfig.json index 7f4d4054bea88b..2c395450fb6a0e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,6 +42,7 @@ { "path": "packages/report-flaky-tests" }, { "path": "packages/rich-text" }, { "path": "packages/style-engine" }, + { "path": "packages/sync" }, { "path": "packages/token-list" }, { "path": "packages/url" }, { "path": "packages/warning" }, diff --git a/typings/gutenberg-env/index.d.ts b/typings/gutenberg-env/index.d.ts index e2876716bd8b7f..ecf60a7ca094f9 100644 --- a/typings/gutenberg-env/index.d.ts +++ b/typings/gutenberg-env/index.d.ts @@ -7,3 +7,5 @@ interface Process { env: Environment; } declare var process: Process; + +declare var SCRIPT_DEBUG: boolean; diff --git a/webpack.config.js b/webpack.config.js index f1c5ce803adc1b..8558707f4bc9fa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,6 +3,12 @@ */ const blocksConfig = require( './tools/webpack/blocks' ); const developmentConfigs = require( './tools/webpack/development' ); +const interactivity = require( './tools/webpack/interactivity' ); const packagesConfig = require( './tools/webpack/packages' ); -module.exports = [ ...blocksConfig, packagesConfig, ...developmentConfigs ]; +module.exports = [ + ...blocksConfig, + interactivity, + packagesConfig, + ...developmentConfigs, +];