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

This is the home screen.

+ + Navigate to child screen. + +
+ +

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the home screen.

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

This is the child screen.

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

This is the grand child screen.

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

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

Home screen

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

Child screen

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

{ SCREEN_TEXT.product }

Product ID is { params.productId }

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

{ SCREEN_TEXT.home }

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

{ SCREEN_TEXT.child }

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

{ SCREEN_TEXT.nested }

{ BUTTON_TEXT.back } -
+ - +

{ SCREEN_TEXT.invalidHtmlPath }

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

{ SCREEN_TEXT.home }

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

{ SCREEN_TEXT.child }

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

{ SCREEN_TEXT.nested }

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

{ SCREEN_TEXT.home }

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

{ SCREEN_TEXT.child }

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

{ SCREEN_TEXT.nested }

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

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

+

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

+

Try the following:

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

Selected tab: Tab 1

+

(Label with multiple words)

+
+ +

Selected tab: Tab 2

+

(Short)

+
+ +

Selected tab: Tab 3

+

(Hippopotomonstrosesquippedaliophobia)

+
+ +

Selected tab: Tab 4

+
+ +

Selected tab: Tab 5

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

Code is Poetry.

+

Code is Poetry.

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