diff --git a/bin/api-docs/gen-theme-reference.js b/bin/api-docs/gen-theme-reference.js index f638bb708890a8..0ea9e282e5463e 100644 --- a/bin/api-docs/gen-theme-reference.js +++ b/bin/api-docs/gen-theme-reference.js @@ -74,6 +74,42 @@ const keys = ( maybeObject ) => { return Object.keys( maybeObject ); }; +/** + * Get definition from ref. + * + * @param {string} ref + * @return {Object} definition + * @throws {Error} If the referenced definition is not found in 'themejson.definitions'. + * + * @example + * getDefinition( '#/definitions/typographyProperties/properties/fontFamily' ) + * // returns themejson.definitions.typographyProperties.properties.fontFamily + */ +const resolveDefinitionRef = ( ref ) => { + const refParts = ref.split( '/' ); + const definition = refParts[ refParts.length - 1 ]; + if ( ! themejson.definitions[ definition ] ) { + throw new Error( `Can't resolve '${ ref }'. Definition not found` ); + } + return themejson.definitions[ definition ]; +}; + +/** + * Get properties from an array. + * + * @param {Object} items + * @return {Object} properties + */ +const getPropertiesFromArray = ( items ) => { + // if its a $ref resolve it + if ( items.$ref ) { + return resolveDefinitionRef( items.$ref ).properties; + } + + // otherwise just return the properties + return items.properties; +}; + /** * Convert settings properties to markup. * @@ -96,7 +132,9 @@ const getSettingsPropertiesMarkup = ( struct ) => { const def = 'default' in props[ key ] ? props[ key ].default : ''; const ps = props[ key ].type === 'array' - ? keys( props[ key ].items.properties ).sort().join( ', ' ) + ? keys( getPropertiesFromArray( props[ key ].items ) ) + .sort() + .join( ', ' ) : ''; markup += `| ${ key } | ${ props[ key ].type } | ${ def } | ${ ps } |\n`; } ); diff --git a/bin/list-experimental-api-matches.sh b/bin/list-experimental-api-matches.sh index 156464c4e7375e..d9399e63e5cf64 100755 --- a/bin/list-experimental-api-matches.sh +++ b/bin/list-experimental-api-matches.sh @@ -31,7 +31,7 @@ namespace() { awk -F: ' { print module($1), $2 } function module(path) { - n = split(path, parts, "/") + split(path, parts, "/") if (parts[1] == "lib") return "lib" return parts[1] "/" parts[2] }' diff --git a/changelog.txt b/changelog.txt index ba43ddc731d01b..9268dc7edd1fba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,261 @@ == Changelog == += 17.5.0-rc.1 = + + + +## Changelog + +### Enhancements + +#### Editor Unification +- Editor: Add the show most used blocks preference to the site editor. ([57637](https://github.com/WordPress/gutenberg/pull/57637)) +- Editor: Migrate and unify the panel preferences. ([57529](https://github.com/WordPress/gutenberg/pull/57529)) +- Editor: Unify context text cursor preference. ([57479](https://github.com/WordPress/gutenberg/pull/57479)) +- Editor: Unify list view open preference. ([57504](https://github.com/WordPress/gutenberg/pull/57504)) +- Editor: Unify right click override preference. ([57468](https://github.com/WordPress/gutenberg/pull/57468)) +- Editor: Unify show icon labels preference. ([57480](https://github.com/WordPress/gutenberg/pull/57480)) +- Editor: Unify spotlight mode preference. ([57533](https://github.com/WordPress/gutenberg/pull/57533)) +- Editor: Unify the distraction free preference. ([57590](https://github.com/WordPress/gutenberg/pull/57590)) +- Editor: Unify the show block breadcrumbs preference. ([57506](https://github.com/WordPress/gutenberg/pull/57506)) +- Editor: Unify the top toolbar preference. ([57531](https://github.com/WordPress/gutenberg/pull/57531)) + +#### Components +- Components: Replace `TabPanel` with `Tabs` in inline color picker. ([57292](https://github.com/WordPress/gutenberg/pull/57292) +- Add `compact` size variant to InputControl-based components. ([57398](https://github.com/WordPress/gutenberg/pull/57398)) +- BaseControl: Connect to context system. ([57408](https://github.com/WordPress/gutenberg/pull/57408)) +- Replace `TabPanel` with `Tabs` in the Style Book. ([57287](https://github.com/WordPress/gutenberg/pull/57287)) +- Tooltip: Improve tests. ([57345](https://github.com/WordPress/gutenberg/pull/57345)) +- Update @ariakit/react to v0.3.12 and @ariakit/test to v0.3.7. ([57547](https://github.com/WordPress/gutenberg/pull/57547)) + +#### Font Library +- Font Library: Remove "has_font_mime_type" function. ([57364](https://github.com/WordPress/gutenberg/pull/57364)) +- Font Library: Update font uninstall modal text. ([57368](https://github.com/WordPress/gutenberg/pull/57368)) +- Font Library: Add progress-bar while uploading font assets. ([57463](https://github.com/WordPress/gutenberg/pull/57463)) +- Font Library: Singularize install font families endpoint. ([57569](https://github.com/WordPress/gutenberg/pull/57569)) +- Font Library: Unregister font collection. ([54701](https://github.com/WordPress/gutenberg/pull/54701)) + +#### Site Editor +- Add Template Modal: Update scroll related layout. ([57617](https://github.com/WordPress/gutenberg/pull/57617)) +- Components: Replace `TabPanel` with `Tabs` in the Font Library `Modal`. ([57181](https://github.com/WordPress/gutenberg/pull/57181)) + +#### Interactivity API +- Implement `wp_initial_state()`. ([57556](https://github.com/WordPress/gutenberg/pull/57556)) +- Server directive processing: Stop processing non-interactive blocks. ([56302](https://github.com/WordPress/gutenberg/pull/56302)) +- Interactive template: Use viewModule. ([57712](https://github.com/WordPress/gutenberg/pull/57712)) +- Navigation Block: Use dom.focus for focus control. ([57362](https://github.com/WordPress/gutenberg/pull/57362)) + + +#### Site Editor +- Site editor: Add padding to entity save panel header. ([57471](https://github.com/WordPress/gutenberg/pull/57471)) +- Site editor: Add margin to entity save panel header via a classname. ([57473](https://github.com/WordPress/gutenberg/pull/57473)) + + +#### Block Library +- Post Featured Image: Add a useFirstImageFromPost attribute. ([56573](https://github.com/WordPress/gutenberg/pull/56573)) +- Gallery Block: Add random order setting. ([57477](https://github.com/WordPress/gutenberg/pull/57477)) +- Image Block: Change upload icon label. ([57704](https://github.com/WordPress/gutenberg/pull/57704)) + + +### Bug Fixes + +- Avoid using a memoized selector without dependencies. ([57257](https://github.com/WordPress/gutenberg/pull/57257)) +- Core Data: Pass the 'options' argument to data action shortcuts. ([57383](https://github.com/WordPress/gutenberg/pull/57383)) +- Preferences: Update accessibility scope to "core". ([57563](https://github.com/WordPress/gutenberg/pull/57563)) + +#### Block Editor +- Fix Link UI displaying out of sync results. ([57522](https://github.com/WordPress/gutenberg/pull/57522)) +- Give iframe fallback background color. ([57330](https://github.com/WordPress/gutenberg/pull/57330)) +- Rich text: Add HTML string methods to RichTextData. ([57322](https://github.com/WordPress/gutenberg/pull/57322)) + +#### Block Library +- Footnotes: Fix wrong link when adding more than 9 footnotes. ([57599](https://github.com/WordPress/gutenberg/pull/57599)) +- Table: Remove unnecessary margin override in editor styles. ([57699](https://github.com/WordPress/gutenberg/pull/57699)) +- Template Part block: Fix template part path arg missing from actions. ([56790](https://github.com/WordPress/gutenberg/pull/56790)) + +#### Components +- DuotonePicker: Fix top margin when no duotone options. ([57489](https://github.com/WordPress/gutenberg/pull/57489)) +- NavigatorProvider: Exclude size value from contain CSS rule. ([57498](https://github.com/WordPress/gutenberg/pull/57498)) +- Snackbar: Fix icon positioning. ([57377](https://github.com/WordPress/gutenberg/pull/57377)) + +#### Patterns +- Pattern Overrides: Add `template-lock: All` to pattern inner blocks to prevent deletion/insertion. ([57661](https://github.com/WordPress/gutenberg/pull/57661)) +- Refactor the findOrCreate term method. ([57655](https://github.com/WordPress/gutenberg/pull/57655)) +- Edit source pattern in focus mode in post and site editors. ([57036](https://github.com/WordPress/gutenberg/pull/57036)) + + +#### Site Editor +- Make sure comamnd palette toggle does not disappear while being clicked. ([57420](https://github.com/WordPress/gutenberg/pull/57420)) +- Reinstate iframe CSS for editor canvas container. ([57503](https://github.com/WordPress/gutenberg/pull/57503)) + +#### Global Styles +- Use `is-layout` pattern on layout generated classname. ([57564](https://github.com/WordPress/gutenberg/pull/57564)) +- Global styles revisions: Add individual headings translations, update tests. ([57472](https://github.com/WordPress/gutenberg/pull/57472)) +- Global style revisions: Move change summary code and tests to block editor package. ([57411](https://github.com/WordPress/gutenberg/pull/57411)) +- Reduce specificity of block style variation selectors. ([57659](https://github.com/WordPress/gutenberg/pull/57659)) +- Background image block support: Add tests for size and repeat output. ([57474](https://github.com/WordPress/gutenberg/pull/57474)) + + +#### Post Editor +- Fix Template preview menu item accessibility. ([57456](https://github.com/WordPress/gutenberg/pull/57456)) +- Fullscreen mode description: Use full text instead of abbreviation. ([57518](https://github.com/WordPress/gutenberg/pull/57518)) +- Improve pre-publish checks naming consistency. ([57019](https://github.com/WordPress/gutenberg/pull/57019)) +- Make the Replace featured image button perceivable by assistive technologies. ([57453](https://github.com/WordPress/gutenberg/pull/57453)) + +#### Components +- Label the HeightControl. ([57683](https://github.com/WordPress/gutenberg/pull/57683)) +- NumberControl: Make increment and decrement buttons keyboard accessible. ([57402](https://github.com/WordPress/gutenberg/pull/57402)) + +#### Block Tools +- Update the position of the patterns tab in the inserter menu. ([55688](https://github.com/WordPress/gutenberg/pull/55688)) +- Use full text instead of abbreviation for min height setting. ([57680](https://github.com/WordPress/gutenberg/pull/57680)) +- ResizableEditor: Fix tab order for resize handles. ([57475](https://github.com/WordPress/gutenberg/pull/57475)) +- Keep Lock button it in the toolbar until unmounted. ([57229](https://github.com/WordPress/gutenberg/pull/57229)) +- Custom field connections: Better description on Experiments page. ([57501](https://github.com/WordPress/gutenberg/pull/57501)) + +### Performance + +#### Block Library +- File: Remove 'block-editor' store subscription. ([57511](https://github.com/WordPress/gutenberg/pull/57511)) +- Remove store subscriptions from Audio and Video blocks. ([57449](https://github.com/WordPress/gutenberg/pull/57449)) +- Site Logo: Remove unnecessary 'block-editor' store subscription. ([57513](https://github.com/WordPress/gutenberg/pull/57513)) +- Send numerical post id when uploading image. ([57388](https://github.com/WordPress/gutenberg/pull/57388)) +- PostFeaturedImage: Remove unnecessary 'block-editor' store subscription. ([57554](https://github.com/WordPress/gutenberg/pull/57554)) + +### Experiments + +#### Data Views +- DataViews: Use DropdownMenuRadioItem component when possible. ([57505](https://github.com/WordPress/gutenberg/pull/57505)) +- Align icon size + placement in Patterns data view. ([57548](https://github.com/WordPress/gutenberg/pull/57548)) +- DataViews: Add `duplicate pattern` action in patterns page. ([57592](https://github.com/WordPress/gutenberg/pull/57592)) +- DataViews: Add duplicate template pattern action. ([57638](https://github.com/WordPress/gutenberg/pull/57638)) +- DataViews: Add footer to Pages sidebar. ([57690](https://github.com/WordPress/gutenberg/pull/57690)) +- DataViews: Add new page button in `Pages`. ([57685](https://github.com/WordPress/gutenberg/pull/57685)) +- DataViews: Add sync filter in patterns page. ([57532](https://github.com/WordPress/gutenberg/pull/57532)) +- DataViews: Consolidate CSS selectors naming schema. ([57651](https://github.com/WordPress/gutenberg/pull/57651)) +- DataViews: Fallback to `(no title)` is there's no rendered title. ([57434](https://github.com/WordPress/gutenberg/pull/57434)) +- DataViews: Hide actions menu upon selecting a layout. ([57418](https://github.com/WordPress/gutenberg/pull/57418)) +- DataViews: Make `fields` dependant on `view.type`. ([57450](https://github.com/WordPress/gutenberg/pull/57450)) +- DataViews: Memoize `onSetSelection`. ([57458](https://github.com/WordPress/gutenberg/pull/57458)) +- DataViews: Prevent unnecessary re-renders of Pagination. ([57454](https://github.com/WordPress/gutenberg/pull/57454)) +- DataViews: Prevent unnecessary re-renders. ([57452](https://github.com/WordPress/gutenberg/pull/57452)) +- DataViews: Update names for `DropdownMenuRadioItemCustom`. ([57416](https://github.com/WordPress/gutenberg/pull/57416)) +- DataViews: Use i18n._x to clarify term "Duplicate". ([57686](https://github.com/WordPress/gutenberg/pull/57686)) +- DataViews: Use in patterns page. ([57333](https://github.com/WordPress/gutenberg/pull/57333)) +- Dataview: Change the stacking order of table header. ([57565](https://github.com/WordPress/gutenberg/pull/57565)) +- Dataviews: Add some client side data handling utils. ([57488](https://github.com/WordPress/gutenberg/pull/57488)) +- Make title display in grid views consistent. ([57553](https://github.com/WordPress/gutenberg/pull/57553)) +- Update Table layout design details. ([57644](https://github.com/WordPress/gutenberg/pull/57644)) +- Update pagination spacing in List layout. ([57670](https://github.com/WordPress/gutenberg/pull/57670)) +- Update table header gap. ([57671](https://github.com/WordPress/gutenberg/pull/57671)) +- [Dataviews] Table layout: Ensure focus is not lost on interaction. ([57340](https://github.com/WordPress/gutenberg/pull/57340)) + +#### Patterns +- [Pattern Overrides] Fix duplication of inner blocks. ([57538](https://github.com/WordPress/gutenberg/pull/57538)) +- [Pattern overrides] Allow multiple attributes overrides. ([57573](https://github.com/WordPress/gutenberg/pull/57573)) + + +### Documentation + +- Add links to additional local dev tools in Block Developement Environment readme. ([57682](https://github.com/WordPress/gutenberg/pull/57682)) +- Add new section to the Quick Start Guide about wp-env. ([57559](https://github.com/WordPress/gutenberg/pull/57559)) +- Block JSON schema: Add renaming key to supports definition. ([57373](https://github.com/WordPress/gutenberg/pull/57373)) +- Break out the Curating the Editor Experience doc into its own How-to Guides section. ([57289](https://github.com/WordPress/gutenberg/pull/57289)) +- Change the slug for the theme.json doc to avoid conflicts. ([57410](https://github.com/WordPress/gutenberg/pull/57410)) +- Docs/tutorial: Fix opposite condition for content generation in render.php. ([57445](https://github.com/WordPress/gutenberg/pull/57445)) +- Docs: Fundamentals of Block Development - Static or Dynamic rendering of a block. ([57250](https://github.com/WordPress/gutenberg/pull/57250)) +- Docs: Update sample code to fix React warning error on Tutorial page. ([57412](https://github.com/WordPress/gutenberg/pull/57412)) +- Fix formatting issue due to incorrect link parsing in the Quick Start Guide. ([57693](https://github.com/WordPress/gutenberg/pull/57693)) +- Fix incorrect heading level in Editor curation documentation. ([57409](https://github.com/WordPress/gutenberg/pull/57409)) +- Fix two typos in tutorial.md. ([57627](https://github.com/WordPress/gutenberg/pull/57627)) +- Fix: Create block getting started links. ([57551](https://github.com/WordPress/gutenberg/pull/57551)) +- Improve the static vs dynamic rendering comment in the block tutorial. ([57284](https://github.com/WordPress/gutenberg/pull/57284)) +- Update copyright year to 2024 in `license.md`. ([57481](https://github.com/WordPress/gutenberg/pull/57481)) +- Update the "Build your first block" tutorial based on user feedback. ([57403](https://github.com/WordPress/gutenberg/pull/57403)) +- Update: Material design icons link. ([57550](https://github.com/WordPress/gutenberg/pull/57550)) + + +### Code Quality +- Editor: Unify the DocumentTools component. ([57214](https://github.com/WordPress/gutenberg/pull/57214)) +- Make getLastFocus and setLastFocus private. ([57612](https://github.com/WordPress/gutenberg/pull/57612)) +- Remove deprecated `behaviors` syntax. ([57165](https://github.com/WordPress/gutenberg/pull/57165)) +- Avoid extra `useMarkPersistent` dispatch calls. ([57435](https://github.com/WordPress/gutenberg/pull/57435)) +- Clean up code editor CSS. ([57519](https://github.com/WordPress/gutenberg/pull/57519)) +- Combine selectors in 'useTransformCommands'. ([57424](https://github.com/WordPress/gutenberg/pull/57424)) + +#### Block Library +- Background image: Add has-background classname when background image is applied. ([57495](https://github.com/WordPress/gutenberg/pull/57495)) +- File: Remove unnecessary synchronization effect. ([57585](https://github.com/WordPress/gutenberg/pull/57585)) +- Navigation: Refactor mobile overlay breakpoints to JS. ([57520](https://github.com/WordPress/gutenberg/pull/57520)) +- Search Block: Remove unused `buttonBehavior` attribute. ([53467](https://github.com/WordPress/gutenberg/pull/53467)) + +#### Patterns +- Improve inserter pattern constants. ([57570](https://github.com/WordPress/gutenberg/pull/57570)) +- Remove duplicate setting for `getPostLinkProps` and prefer stable naming. ([57535](https://github.com/WordPress/gutenberg/pull/57535)) +- Rename `patternBlock` to `patternPost`. ([57568](https://github.com/WordPress/gutenberg/pull/57568)) + +#### Post Editor +- Editor: Use hooks instead of HoCs in 'PostVisibilityCheck'. ([57705](https://github.com/WordPress/gutenberg/pull/57705)) +- Quality: Avoid React warning when changing rendering mode. ([57413](https://github.com/WordPress/gutenberg/pull/57413)) + +#### Block Editor +- Editor: Unify the inserter sidebar. ([57466](https://github.com/WordPress/gutenberg/pull/57466)) +- Remove unused parameters from useOnBlockDrop. ([57527](https://github.com/WordPress/gutenberg/pull/57527)) + +#### List View +- Editor: Unify the list view sidebar between the post and site editors. ([57467](https://github.com/WordPress/gutenberg/pull/57467)) +- Add drag cursor to draggable list items. ([57493](https://github.com/WordPress/gutenberg/pull/57493)) + +### Tools + +- Dependency Extraction Webpack Plugin: Use `import` for module externals. ([57577](https://github.com/WordPress/gutenberg/pull/57577)) +- DependencyExtractionWebpackPlugin: Add true shorthand for requestToExternalModule. ([57593](https://github.com/WordPress/gutenberg/pull/57593)) +- DependencyExtractionWebpackPlugin: Use module for @wordpress/interactivity. ([57602](https://github.com/WordPress/gutenberg/pull/57602)) +- Fix webpack not setting environment.module true. ([57714](https://github.com/WordPress/gutenberg/pull/57714)) +- Modules: Load the import map polyfill when needed. ([57256](https://github.com/WordPress/gutenberg/pull/57256)) +- Blocks: Add handling for block.json viewModule. ([57437](https://github.com/WordPress/gutenberg/pull/57437)) + +#### Testing +- Allowed Patterns end-to-end test - move tests that run with a subset of allowed blocks into a group. ([57496](https://github.com/WordPress/gutenberg/pull/57496)) +- Clean up end-to-end tests package. ([57575](https://github.com/WordPress/gutenberg/pull/57575)) +- Fix flaky 'Post publish button' end-to-end test. ([57407](https://github.com/WordPress/gutenberg/pull/57407)) +- Migrate 'allowed patterns' end-to-end tests to Playwright. ([57399](https://github.com/WordPress/gutenberg/pull/57399)) +- Migrate 'block editor keyboard shortcuts' end-to-end tests to Playwright. ([57422](https://github.com/WordPress/gutenberg/pull/57422)) +- Migrate 'core settings' end-to-end tests to Playwright. ([57581](https://github.com/WordPress/gutenberg/pull/57581)) +- Migrate 'datepicker' end-to-end tests to Playwright. ([57545](https://github.com/WordPress/gutenberg/pull/57545)) +- Migrate 'dropdown menu' end-to-end tests to Playwright. ([57663](https://github.com/WordPress/gutenberg/pull/57663)) +- Migrate 'editor modes' end-to-end tests to Playwright. ([57574](https://github.com/WordPress/gutenberg/pull/57574)) +- Migrate 'invalid blocks' end-to-end tests to Playwright. ([57508](https://github.com/WordPress/gutenberg/pull/57508)) +- Migrate 'nux' end-to-end tests to Playwright. ([57542](https://github.com/WordPress/gutenberg/pull/57542)) +- Migrate 'preferences' end-to-end tests to Playwright. ([57446](https://github.com/WordPress/gutenberg/pull/57446)) +- Migrate 'publishing' end-to-end tests to Playwright. ([57521](https://github.com/WordPress/gutenberg/pull/57521)) +- Migrate 'scheduling' end-to-end tests to Playwright. ([57539](https://github.com/WordPress/gutenberg/pull/57539)) +- Migrate 'sidebar' end-to-end tests to Playwright. ([57448](https://github.com/WordPress/gutenberg/pull/57448)) +- Migrate 'taxonomies' end-to-end tests to Playwright. ([57662](https://github.com/WordPress/gutenberg/pull/57662)) +- Migrate `editing-widgets` to Playwright. ([57483](https://github.com/WordPress/gutenberg/pull/57483)) +- Migrate remaining 'publish panel' end-to-end tests to Playwright. ([57432](https://github.com/WordPress/gutenberg/pull/57432)) +- Update 'missing block' end-to-end tests to use the 'setContent' helper. ([57509](https://github.com/WordPress/gutenberg/pull/57509)) + +#### Build Tooling +- Group GitHub Action Dependabot updates. ([57591](https://github.com/WordPress/gutenberg/pull/57591)) +- WP Scripts: Build block.json viewModule. ([57461](https://github.com/WordPress/gutenberg/pull/57461)) +- Dependency Extraction Webpack Plugin: Add Module support. ([57199](https://github.com/WordPress/gutenberg/pull/57199)) + +## First time contributors + +The following PRs were merged by first time contributors: + +- @HrithikDalal: Font Library: Update font uninstall modal text. ([57368](https://github.com/WordPress/gutenberg/pull/57368)) +- @muhme: Fix two typos in tutorial.md. ([57627](https://github.com/WordPress/gutenberg/pull/57627)) + + +## Contributors + +The following contributors merged PRs in this release: + +@afercia @andrewhayward @andrewserong @atachibana @c4rl0sbr4v0 @carolinan @chad1008 @ciampo @DAreRodz @dcalhoun @derekblank @desrosj @ellatrix @fai-sal @fluiddot @geriux @getdave @glendaviesnz @gziolo @hbhalodia @HrithikDalal @jameskoster @jeryj @jorgefilipecosta @jsnajdr @juanmaguitar @kevin940726 @Mamaduka @matiasbenedetto @mcsf @michalczaplinski @mirka @muhme @ndiego @ntsekouras @oandregal @ockham @ramonjd @scruffian @sirreal @Soean @t-hamano @talldan @tellthemachines @youknowriad + + = 17.3.2 = ## Changelog diff --git a/docs/getting-started/devenv/README.md b/docs/getting-started/devenv/README.md index c891490437d431..47113c84d78dac 100644 --- a/docs/getting-started/devenv/README.md +++ b/docs/getting-started/devenv/README.md @@ -48,3 +48,10 @@ Refer to the [Get started with `wp-env`](/docs/getting-started/devenv/get-starte
Throughout the Handbook, you may also see references to wp-now. This is a lightweight tool powered by WordPress Playground that streamlines setting up a simple local WordPress environment. While still experimental, this tool is great for quickly testing WordPress releases, plugins, and themes.
+ +This list is not exhaustive, but here are several additional options to choose from if you prefer not to use `wp-env`: + +- [Local](https://localwp.com/) +- [XAMPP](https://www.apachefriends.org/) +- [MAMP](https://www.mamp.info/en/mamp/mac/) +- [Varying Vagrant Vagrants](https://varyingvagrantvagrants.org/) (VVV) diff --git a/docs/getting-started/quick-start-guide.md b/docs/getting-started/quick-start-guide.md index c6f22ce219136c..736a56c006c9e1 100644 --- a/docs/getting-started/quick-start-guide.md +++ b/docs/getting-started/quick-start-guide.md @@ -41,7 +41,7 @@ When you are finished making changes, run the `npm run build` command. This opti You can use any local WordPress development environment to test your new block, but the scaffolded plugin includes configuration for `wp-env`. You must have [Docker](https://www.docker.com/products/docker-desktop) already installed and running on your machine, but if you do, run the `npx wp-env start` command. -Once the script finishes running, you can access the local environment at: `http://localhost:8888`. Log into the WordPress dashboard using username `admin` and password `password`. The plugin will already be installed and activated. Open the Editor or Site Editor, and insert the Copyright Date Block as you would any other block. +Once the script finishes running, you can access the local environment at: http://localhost:8888. Log into the WordPress dashboard using username `admin` and password `password`. The plugin will already be installed and activated. Open the Editor or Site Editor, and insert the Copyright Date Block as you would any other block. Visit the [Getting started](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-env/) guide to learn more about `wp-env`. diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 38a93552bcbef2..7b0bd386daaf48 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -588,18 +588,6 @@ _Properties_ - _isDisabled_ `boolean`: Whether or not the user should be prevented from inserting this item. - _frecency_ `number`: Heuristic that combines frequency and recency. -### getLastFocus - -Returns the element of the last element that had focus when focus left the editor canvas. - -_Parameters_ - -- _state_ `Object`: Block editor state. - -_Returns_ - -- `Object`: Element. - ### getLastMultiSelectedBlockClientId Returns the client ID of the last block in the multi-selection set, or null if there is no multi-selection. @@ -1663,18 +1651,6 @@ _Parameters_ - _clientId_ `string`: The block's clientId. - _hasControlledInnerBlocks_ `boolean`: True if the block's inner blocks are controlled. -### setLastFocus - -Action that sets the element that had focus when focus leaves the editor canvas. - -_Parameters_ - -- _lastFocus_ `Object`: The last focused element. - -_Returns_ - -- `Object`: Action object. - ### setNavigationMode Action that enables or disables the navigation mode. diff --git a/docs/reference-guides/theme-json-reference/theme-json-living.md b/docs/reference-guides/theme-json-reference/theme-json-living.md index 4baa5a6009ded6..ee88f779ace1ce 100644 --- a/docs/reference-guides/theme-json-reference/theme-json-living.md +++ b/docs/reference-guides/theme-json-reference/theme-json-living.md @@ -176,6 +176,7 @@ Settings related to typography. | Property | Type | Default | Props | | --- | --- | --- |--- | +| defaultFontSizes | boolean | true | | | customFontSize | boolean | true | | | fontStyle | boolean | true | | | fontWeight | boolean | true | | diff --git a/gutenberg.php b/gutenberg.php index 35e416006bea50..9559f838608da9 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.3 * Requires PHP: 7.0 - * Version: 17.4.1 + * Version: 17.5.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/block-supports/pattern.php b/lib/block-supports/pattern.php index a783135c793e3f..2f7717091ff506 100644 --- a/lib/block-supports/pattern.php +++ b/lib/block-supports/pattern.php @@ -13,7 +13,7 @@ * @param WP_Block_Type $block_type Block Type. */ function gutenberg_register_pattern_support( $block_type ) { - $pattern_support = property_exists( $block_type, 'supports' ) ? _wp_array_get( $block_type->supports, array( '__experimentalConnections' ), false ) : false; + $pattern_support = 'core/paragraph' === $block_type->name ? true : false; if ( $pattern_support ) { if ( ! $block_type->uses_context ) { diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index af750fa0599795..a063ab34d9069c 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -155,7 +155,7 @@ class WP_Theme_JSON_Gutenberg { ), array( 'path' => array( 'typography', 'fontSizes' ), - 'prevent_override' => false, + 'prevent_override' => array( 'typography', 'defaultFontSizes' ), 'use_default_names' => true, 'value_func' => 'gutenberg_get_typography_font_size_value', 'css_vars' => '--wp--preset--font-size--$slug', @@ -411,19 +411,20 @@ class WP_Theme_JSON_Gutenberg { 'defaultPresets' => null, ), 'typography' => array( - 'fluid' => null, - 'customFontSize' => null, - 'dropCap' => null, - 'fontFamilies' => null, - 'fontSizes' => null, - 'fontStyle' => null, - 'fontWeight' => null, - 'letterSpacing' => null, - 'lineHeight' => null, - 'textColumns' => null, - 'textDecoration' => null, - 'textTransform' => null, - 'writingMode' => null, + 'fluid' => null, + 'customFontSize' => null, + 'defaultFontSizes' => null, + 'dropCap' => null, + 'fontFamilies' => null, + 'fontSizes' => null, + 'fontStyle' => null, + 'fontWeight' => null, + 'letterSpacing' => null, + 'lineHeight' => null, + 'textColumns' => null, + 'textDecoration' => null, + 'textTransform' => null, + 'writingMode' => null, ), ); @@ -1021,8 +1022,7 @@ protected static function get_blocks_metadata() { if ( ! empty( $block_type->styles ) ) { $style_selectors = array(); foreach ( $block_type->styles as $style ) { - // The style variation classname is duplicated in the selector to ensure that it overrides core block styles. - $style_selectors[ $style['name'] ] = static::append_to_selector( '.is-style-' . $style['name'] . '.is-style-' . $style['name'], static::$blocks_metadata[ $block_name ]['selector'] ); + $style_selectors[ $style['name'] ] = static::append_to_selector( '.is-style-' . $style['name'], static::$blocks_metadata[ $block_name ]['selector'] ); } static::$blocks_metadata[ $block_name ]['styleVariations'] = $style_selectors; } diff --git a/lib/experimental/block-bindings/html-processing.php b/lib/experimental/block-bindings/html-processing.php new file mode 100644 index 00000000000000..515749d0a8e753 --- /dev/null +++ b/lib/experimental/block-bindings/html-processing.php @@ -0,0 +1,110 @@ +get_registered( $block_name ); + if ( null === $block_type ) { + return; + } + + // Depending on the attribute source, the processing will be different. + switch ( $block_type->attributes[ $block_attr ]['source'] ) { + case 'html': + case 'rich-text': + $block_reader = new WP_HTML_Tag_Processor( $block_content ); + + // TODO: Support for CSS selectors whenever they are ready in the HTML API. + // In the meantime, support comma-separated selectors by exploding them into an array. + $selectors = explode( ',', $block_type->attributes[ $block_attr ]['selector'] ); + // Add a bookmark to the first tag to be able to iterate over the selectors. + $block_reader->next_tag(); + $block_reader->set_bookmark( 'iterate-selectors' ); + + // TODO: This shouldn't be needed when the `set_inner_html` function is ready. + // Store the parent tag and its attributes to be able to restore them later in the button. + // The button block has a wrapper while the paragraph and heading blocks don't. + if ( 'core/button' === $block_name ) { + $button_wrapper = $block_reader->get_tag(); + $button_wrapper_attribute_names = $block_reader->get_attribute_names_with_prefix( '' ); + $button_wrapper_attrs = array(); + foreach ( $button_wrapper_attribute_names as $name ) { + $button_wrapper_attrs[ $name ] = $block_reader->get_attribute( $name ); + } + } + + foreach ( $selectors as $selector ) { + // If the parent tag, or any of its children, matches the selector, replace the HTML. + if ( strcasecmp( $block_reader->get_tag( $selector ), $selector ) === 0 || $block_reader->next_tag( + array( + 'tag_name' => $selector, + ) + ) ) { + $block_reader->release_bookmark( 'iterate-selectors' ); + + // TODO: Use `set_inner_html` method whenever it's ready in the HTML API. + // Until then, it is hardcoded for the paragraph, heading, and button blocks. + // Store the tag and its attributes to be able to restore them later. + $selector_attribute_names = $block_reader->get_attribute_names_with_prefix( '' ); + $selector_attrs = array(); + foreach ( $selector_attribute_names as $name ) { + $selector_attrs[ $name ] = $block_reader->get_attribute( $name ); + } + $selector_markup = "<$selector>" . esc_html( $source_value ) . ""; + $amended_content = new WP_HTML_Tag_Processor( $selector_markup ); + $amended_content->next_tag(); + foreach ( $selector_attrs as $attribute_key => $attribute_value ) { + $amended_content->set_attribute( $attribute_key, $attribute_value ); + } + if ( 'core/paragraph' === $block_name || 'core/heading' === $block_name ) { + return $amended_content->get_updated_html(); + } + if ( 'core/button' === $block_name ) { + $button_markup = "<$button_wrapper>{$amended_content->get_updated_html()}"; + $amended_button = new WP_HTML_Tag_Processor( $button_markup ); + $amended_button->next_tag(); + foreach ( $button_wrapper_attrs as $attribute_key => $attribute_value ) { + $amended_button->set_attribute( $attribute_key, $attribute_value ); + } + return $amended_button->get_updated_html(); + } + } else { + $block_reader->seek( 'iterate-selectors' ); + } + } + $block_reader->release_bookmark( 'iterate-selectors' ); + return $block_content; + + case 'attribute': + $amended_content = new WP_HTML_Tag_Processor( $block_content ); + if ( ! $amended_content->next_tag( + array( + // TODO: build the query from CSS selector. + 'tag_name' => $block_type->attributes[ $block_attr ]['selector'], + ) + ) ) { + return $block_content; + } + $amended_content->set_attribute( $block_type->attributes[ $block_attr ]['attribute'], esc_attr( $source_value ) ); + return $amended_content->get_updated_html(); + break; + + default: + return $block_content; + break; + } + return; + } +} diff --git a/lib/experimental/block-bindings/index.php b/lib/experimental/block-bindings/index.php new file mode 100644 index 00000000000000..cca857e93702f3 --- /dev/null +++ b/lib/experimental/block-bindings/index.php @@ -0,0 +1,20 @@ + $label, + 'apply' => $apply, + ); + } +} diff --git a/lib/experimental/block-bindings/sources/pattern.php b/lib/experimental/block-bindings/sources/pattern.php new file mode 100644 index 00000000000000..e3456aa468d3eb --- /dev/null +++ b/lib/experimental/block-bindings/sources/pattern.php @@ -0,0 +1,21 @@ +attributes, array( 'metadata', 'id' ), false ) ) { + return null; + } + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, $attribute_name ), null ); + }; + register_block_bindings_source( + 'pattern_attributes', + __( 'Pattern Attributes', 'gutenberg' ), + $pattern_source_callback + ); +} diff --git a/lib/experimental/block-bindings/sources/post-meta.php b/lib/experimental/block-bindings/sources/post-meta.php new file mode 100644 index 00000000000000..99b6afc03c0d42 --- /dev/null +++ b/lib/experimental/block-bindings/sources/post-meta.php @@ -0,0 +1,25 @@ +context['postId'] but it wasn't available in the image block. + $post_id = get_the_ID(); + } + + return get_post_meta( $post_id, $source_attrs['value'], true ); + }; + register_block_bindings_source( + 'post_meta', + __( 'Post Meta', 'gutenberg' ), + $post_meta_source_callback + ); +} diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 88e46b478389d2..42663e127870c9 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -83,118 +83,90 @@ function wp_enqueue_block_view_script( $block_name, $args ) { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); if ( $gutenberg_experiments && ( - array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) || + array_key_exists( 'gutenberg-block-bindings', $gutenberg_experiments ) || array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) ) { - /** - * Renders the block meta attributes. - * - * @param string $block_content Block Content. - * @param array $block Block attributes. - * @param WP_Block $block_instance The block instance. - */ - function gutenberg_render_block_connections( $block_content, $block, $block_instance ) { - $connection_sources = require __DIR__ . '/connection-sources/index.php'; - $block_type = $block_instance->block_type; - - // Allowlist of blocks that support block connections. - // Currently, we only allow the following blocks and attributes: - // - Paragraph: content. - // - Image: url. - $blocks_attributes_allowlist = array( - 'core/paragraph' => array( 'content' ), - 'core/image' => array( 'url' ), - ); - - // Whitelist of the block types that support block connections. - // Currently, we only allow the Paragraph and Image blocks to use block connections. - if ( ! in_array( $block['blockName'], array_keys( $blocks_attributes_allowlist ), true ) ) { - return $block_content; - } - - // If for some reason, the block type is not found, skip it. - if ( null === $block_type ) { - return $block_content; - } - - // If the block does not have support for block connections, skip it. - if ( ! block_has_support( $block_type, array( '__experimentalConnections' ), false ) ) { - return $block_content; - } - - // Get all the attributes that have a connection. - $connected_attributes = $block['attrs']['connections']['attributes'] ?? false; - if ( ! $connected_attributes ) { - return $block_content; - } - - foreach ( $connected_attributes as $attribute_name => $attribute_value ) { - - // If the attribute is not in the allowlist, skip it. - if ( ! in_array( $attribute_name, $blocks_attributes_allowlist[ $block['blockName'] ], true ) ) { - continue; - } - - // Skip if the source value is not "meta_fields" or "pattern_attributes". - if ( 'meta_fields' !== $attribute_value['source'] && 'pattern_attributes' !== $attribute_value['source'] ) { - continue; - } - // If the attribute does not have a source, skip it. - if ( ! isset( $block_type->attributes[ $attribute_name ]['source'] ) ) { - continue; + require_once __DIR__ . '/block-bindings/index.php'; + // Allowed blocks that support block bindings. + // TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes? + global $block_bindings_allowed_blocks; + $block_bindings_allowed_blocks = array( + 'core/paragraph' => array( 'content' ), + 'core/heading' => array( 'content' ), + 'core/image' => array( 'url', 'title', 'alt' ), + 'core/button' => array( 'url', 'text' ), + ); + if ( ! function_exists( 'process_block_bindings' ) ) { + /** + * Process the block bindings attribute. + * + * @param string $block_content Block Content. + * @param array $block Block attributes. + * @param WP_Block $block_instance The block instance. + */ + function process_block_bindings( $block_content, $block, $block_instance ) { + // If the block doesn't have the bindings property, return. + if ( ! isset( $block['attrs']['metadata']['bindings'] ) ) { + return $block_content; } - if ( 'pattern_attributes' === $attribute_value['source'] ) { - if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { + // Assuming the following format for the bindings property of the "metadata" attribute: + // + // "bindings": { + // "title": { + // "source": { + // "name": "post_meta", + // "attributes": { "value": "text_custom_field" } + // } + // }, + // "url": { + // "source": { + // "name": "post_meta", + // "attributes": { "value": "text_custom_field" } + // } + // } + // } + // + global $block_bindings_allowed_blocks; + global $block_bindings_sources; + $modified_block_content = $block_content; + foreach ( $block['attrs']['metadata']['bindings'] as $binding_attribute => $binding_source ) { + // If the block is not in the list, stop processing. + if ( ! isset( $block_bindings_allowed_blocks[ $block['blockName'] ] ) ) { + return $block_content; + } + // If the attribute is not in the list, process next attribute. + if ( ! in_array( $binding_attribute, $block_bindings_allowed_blocks[ $block['blockName'] ], true ) ) { continue; } - - $custom_value = $connection_sources[ $attribute_value['source'] ]( $block_instance ); - } else { - // If the attribute does not specify the name of the custom field, skip it. - if ( ! isset( $attribute_value['value'] ) ) { + // If no source is provided, or that source is not registered, process next attribute. + if ( ! isset( $binding_source['source'] ) || ! isset( $binding_source['source']['name'] ) || ! isset( $block_bindings_sources[ $binding_source['source']['name'] ] ) ) { continue; } - // Get the content from the connection source. - $custom_value = $connection_sources[ $attribute_value['source'] ]( - $block_instance, - $attribute_value['value'] - ); - } - - if ( false === $custom_value ) { - continue; - } + $source_callback = $block_bindings_sources[ $binding_source['source']['name'] ]['apply']; + // Get the value based on the source. + if ( ! isset( $binding_source['source']['attributes'] ) ) { + $source_args = array(); + } else { + $source_args = $binding_source['source']['attributes']; + } + $source_value = $source_callback( $source_args, $block_instance, $binding_attribute ); + // If the value is null, process next attribute. + if ( is_null( $source_value ) ) { + continue; + } - $tags = new WP_HTML_Tag_Processor( $block_content ); - $found = $tags->next_tag( - array( - // TODO: In the future, when blocks other than Paragraph and Image are - // supported, we should build the full query from CSS selector. - 'tag_name' => $block_type->attributes[ $attribute_name ]['selector'], - ) - ); - if ( ! $found ) { - return $block_content; + // Process the HTML based on the block and the attribute. + $modified_block_content = block_bindings_replace_html( $modified_block_content, $block['blockName'], $binding_attribute, $source_value ); } - $tag_name = $tags->get_tag(); - $markup = "<$tag_name>$custom_value"; - $updated_tags = new WP_HTML_Tag_Processor( $markup ); - $updated_tags->next_tag(); - - // Get all the attributes from the original block and add them to the new markup. - $names = $tags->get_attribute_names_with_prefix( '' ); - foreach ( $names as $name ) { - $updated_tags->set_attribute( $name, $tags->get_attribute( $name ) ); - } - - return $updated_tags->get_updated_html(); + return $modified_block_content; } - return $block_content; + // Add filter only to the blocks in the list. + foreach ( $block_bindings_allowed_blocks as $block_name => $attributes ) { + add_filter( 'render_block_' . $block_name, 'process_block_bindings', 20, 3 ); + } } - - add_filter( 'render_block', 'gutenberg_render_block_connections', 10, 3 ); } diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php deleted file mode 100644 index bf89ba177b6e94..00000000000000 --- a/lib/experimental/connection-sources/index.php +++ /dev/null @@ -1,19 +0,0 @@ - 'meta', - 'meta_fields' => function ( $block_instance, $meta_field ) { - // We should probably also check if the meta field exists but for now it's okay because - // if it doesn't, `get_post_meta()` will just return an empty string. - return get_post_meta( $block_instance->context['postId'], $meta_field, true ); - }, - 'pattern_attributes' => function ( $block_instance ) { - $block_id = $block_instance->attributes['metadata']['id']; - return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id ), false ); - }, -); diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 5f61684e8b1342..729376cf030dd9 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -26,8 +26,8 @@ function gutenberg_enable_experiments() { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGroupGridVariation = true', 'before' ); } - if ( $gutenberg_experiments && array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalConnections = true', 'before' ); + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-block-bindings', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalBlockBindings = true', 'before' ); } if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { diff --git a/lib/experimental/fonts/font-library/class-wp-font-collection.php b/lib/experimental/fonts/font-library/class-wp-font-collection.php index e8cc7c98fe730f..6189da5fa984b1 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-collection.php +++ b/lib/experimental/fonts/font-library/class-wp-font-collection.php @@ -35,7 +35,7 @@ class WP_Font_Collection { * @since 6.5.0 * * @param array $config Font collection config options. - * See {@see wp_register_font_collection()} for the supported fields. + * See {@see wp_register_font_collection()} for the supported fields. * @throws Exception If the required parameters are missing. */ public function __construct( $config ) { @@ -43,16 +43,16 @@ public function __construct( $config ) { throw new Exception( 'Font Collection config options is required as a non-empty array.' ); } - if ( empty( $config['id'] ) || ! is_string( $config['id'] ) ) { - throw new Exception( 'Font Collection config ID is required as a non-empty string.' ); + if ( empty( $config['slug'] ) || ! is_string( $config['slug'] ) ) { + throw new Exception( 'Font Collection config slug is required as a non-empty string.' ); } if ( empty( $config['name'] ) || ! is_string( $config['name'] ) ) { throw new Exception( 'Font Collection config name is required as a non-empty string.' ); } - if ( empty( $config['src'] ) || ! is_string( $config['src'] ) ) { - throw new Exception( 'Font Collection config "src" option is required as a non-empty string.' ); + if ( ( empty( $config['src'] ) || ! is_string( $config['src'] ) ) && ( empty( $config['data'] ) ) ) { + throw new Exception( 'Font Collection config "src" option OR "data" option is required.' ); } $this->config = $config; @@ -63,21 +63,59 @@ public function __construct( $config ) { * * @since 6.5.0 * - * @return array An array containing the font collection config. + * @return array { + * An array of font collection config. + * + * @type string $slug The font collection's unique slug. + * @type string $name The font collection's name. + * @type string $description The font collection's description. + * } */ public function get_config() { - return $this->config; + return array( + 'slug' => $this->config['slug'], + 'name' => $this->config['name'], + 'description' => $this->config['description'] ?? '', + ); } /** - * Gets the font collection data. + * Gets the font collection config and data. + * + * This function returns an array containing the font collection's unique ID, + * name, and its data as a PHP array. * * @since 6.5.0 * - * @return array|WP_Error An array containing the list of font families in theme.json format on success, + * @return array { + * An array of font collection config and data. + * + * @type string $slug The font collection's unique ID. + * @type string $name The font collection's name. + * @type string $description The font collection's description. + * @type array $data The font collection's data as a PHP array. + * } + */ + public function get_config_and_data() { + $config_and_data = $this->get_config(); + $config_and_data['data'] = $this->load_data(); + return $config_and_data; + } + + /** + * Loads the font collection data. + * + * @since 6.5.0 + * + * @return array|WP_Error An array containing the list of font families in font-collection.json format on success, * else an instance of WP_Error on failure. */ - public function get_data() { + public function load_data() { + + if ( ! empty( $this->config['data'] ) ) { + return $this->config['data']; + } + // If the src is a URL, fetch the data from the URL. if ( str_contains( $this->config['src'], 'http' ) && str_contains( $this->config['src'], '://' ) ) { if ( ! wp_http_validate_url( $this->config['src'] ) ) { @@ -104,9 +142,6 @@ public function get_data() { } } - $collection_data = $this->get_config(); - $collection_data['data'] = $data; - unset( $collection_data['src'] ); - return $collection_data; + return $data; } } diff --git a/lib/experimental/fonts/font-library/class-wp-font-family.php b/lib/experimental/fonts/font-library/class-wp-font-family.php index a4204dfe1fa2c7..f64aebc0c8efa7 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-family.php +++ b/lib/experimental/fonts/font-library/class-wp-font-family.php @@ -135,7 +135,7 @@ public function uninstall() { */ private static function delete_asset( $src ) { $filename = basename( $src ); - $file_path = path_join( WP_Font_Library::get_fonts_dir(), $filename ); + $file_path = path_join( wp_get_font_dir()['path'], $filename ); wp_delete_file( $file_path ); @@ -163,7 +163,6 @@ private static function delete_font_face_assets( $font_face ) { return true; } - /** * Gets the overrides for the 'wp_handle_upload' function. * @@ -394,7 +393,7 @@ private function download_or_move_font_faces( $files ) { // If the font face requires the use of the filesystem, create the fonts dir if it doesn't exist. if ( ! empty( $font_face['downloadFromUrl'] ) && ! empty( $font_face['uploadedFile'] ) ) { - wp_mkdir_p( WP_Font_Library::get_fonts_dir() ); + wp_mkdir_p( wp_get_font_dir()['path'] ); } // If installing google fonts, download the font face assets. @@ -599,9 +598,9 @@ private function create_or_update_font_post() { */ public function install( $files = null ) { add_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); - add_filter( 'upload_dir', array( 'WP_Font_Library', 'set_upload_dir' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); $were_assets_written = $this->download_or_move_font_faces( $files ); - remove_filter( 'upload_dir', array( 'WP_Font_Library', 'set_upload_dir' ) ); + remove_filter( 'upload_dir', 'wp_get_font_dir' ); remove_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); if ( ! $were_assets_written ) { diff --git a/lib/experimental/fonts/font-library/class-wp-font-library.php b/lib/experimental/fonts/font-library/class-wp-font-library.php index 59ec5e93fa787e..fd36f6ba073c4f 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-library.php +++ b/lib/experimental/fonts/font-library/class-wp-font-library.php @@ -63,11 +63,11 @@ public static function get_expected_font_mime_types_per_php_version( $php_versio */ public static function register_font_collection( $config ) { $new_collection = new WP_Font_Collection( $config ); - if ( self::is_collection_registered( $config['id'] ) ) { + if ( self::is_collection_registered( $config['slug'] ) ) { $error_message = sprintf( - /* translators: %s: Font collection id. */ - __( 'Font collection with id: "%s" is already registered.', 'default' ), - $config['id'] + /* translators: %s: Font collection slug. */ + __( 'Font collection with slug: "%s" is already registered.', 'default' ), + $config['slug'] ); _doing_it_wrong( __METHOD__, @@ -76,7 +76,7 @@ public static function register_font_collection( $config ) { ); return new WP_Error( 'font_collection_registration_error', $error_message ); } - self::$collections[ $config['id'] ] = $new_collection; + self::$collections[ $config['slug'] ] = $new_collection; return $new_collection; } @@ -85,20 +85,20 @@ public static function register_font_collection( $config ) { * * @since 6.5.0 * - * @param string $collection_id Font collection ID. + * @param string $collection_slug Font collection slug. * @return bool True if the font collection was unregistered successfully and false otherwise. */ - public static function unregister_font_collection( $collection_id ) { - if ( ! self::is_collection_registered( $collection_id ) ) { + public static function unregister_font_collection( $slug ) { + if ( ! self::is_collection_registered( $slug ) ) { _doing_it_wrong( __METHOD__, - /* translators: %s: Font collection id. */ - sprintf( __( 'Font collection "%s" not found.', 'default' ), $collection_id ), + /* translators: %s: Font collection slug. */ + sprintf( __( 'Font collection "%s" not found.', 'default' ), $slug ), '6.5.0' ); return false; } - unset( self::$collections[ $collection_id ] ); + unset( self::$collections[ $slug ] ); return true; } @@ -107,11 +107,11 @@ public static function unregister_font_collection( $collection_id ) { * * @since 6.5.0 * - * @param string $collection_id Font collection ID. + * @param string $slug Font collection slug. * @return bool True if the font collection is registered and false otherwise. */ - private static function is_collection_registered( $collection_id ) { - return array_key_exists( $collection_id, self::$collections ); + private static function is_collection_registered( $slug ) { + return array_key_exists( $slug, self::$collections ); } /** @@ -130,52 +130,17 @@ public static function get_font_collections() { * * @since 6.5.0 * - * @param string $id Font collection id. + * @param string $slug Font collection slug. * @return array List of font collections. */ - public static function get_font_collection( $id ) { - if ( array_key_exists( $id, self::$collections ) ) { - return self::$collections[ $id ]; + public static function get_font_collection( $slug ) { + if ( array_key_exists( $slug, self::$collections ) ) { + return self::$collections[ $slug ]; } return new WP_Error( 'font_collection_not_found', 'Font collection not found.' ); } - /** - * Gets the upload directory for fonts. - * - * @since 6.5.0 - * - * @return string Path of the upload directory for fonts. - */ - public static function get_fonts_dir() { - return path_join( WP_CONTENT_DIR, 'fonts' ); - } - /** - * Sets the upload directory for fonts. - * - * @since 6.5.0 - * - * @param array $defaults { - * Default upload directory. - * - * @type string $path Path to the directory. - * @type string $url URL for the directory. - * @type string $subdir Sub-directory of the directory. - * @type string $basedir Base directory. - * @type string $baseurl Base URL. - * } - * @return array Modified upload directory. - */ - public static function set_upload_dir( $defaults ) { - $defaults['basedir'] = WP_CONTENT_DIR; - $defaults['baseurl'] = content_url(); - $defaults['subdir'] = '/fonts'; - $defaults['path'] = self::get_fonts_dir(); - $defaults['url'] = $defaults['baseurl'] . '/fonts'; - - return $defaults; - } /** * Sets the allowed mime types for fonts. diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-collections-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-collections-controller.php index 2367cba0b870a7..c7595a56413b9b 100644 --- a/lib/experimental/fonts/font-library/class-wp-rest-font-collections-controller.php +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-collections-controller.php @@ -50,7 +50,7 @@ public function register_routes() { register_rest_route( $this->namespace, - '/' . $this->rest_base . '/(?P[\/\w-]+)', + '/' . $this->rest_base . '/(?P[\/\w-]+)', array( array( 'methods' => WP_REST_Server::READABLE, @@ -70,20 +70,23 @@ public function register_routes() { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_font_collection( $request ) { - $id = $request->get_param( 'id' ); - $collection = WP_Font_Library::get_font_collection( $id ); + $slug = $request->get_param( 'slug' ); + $collection = WP_Font_Library::get_font_collection( $slug ); // If the collection doesn't exist returns a 404. if ( is_wp_error( $collection ) ) { $collection->add_data( array( 'status' => 404 ) ); return $collection; } - $collection_with_data = $collection->get_data(); + $config_and_data = $collection->get_config_and_data(); + $collection_data = $config_and_data['data']; + // If there was an error getting the collection data, return the error. - if ( is_wp_error( $collection_with_data ) ) { - $collection_with_data->add_data( array( 'status' => 500 ) ); - return $collection_with_data; + if ( is_wp_error( $collection_data ) ) { + $collection_data->add_data( array( 'status' => 500 ) ); + return $collection_data; } - return new WP_REST_Response( $collection_with_data ); + + return new WP_REST_Response( $config_and_data ); } /** @@ -96,7 +99,7 @@ public function get_font_collection( $request ) { public function get_font_collections() { $collections = array(); foreach ( WP_Font_Library::get_font_collections() as $collection ) { - $collections[] = $collection->get_config(); + $collections[] = $collection->get_config_and_data(); } return new WP_REST_Response( $collections, 200 ); diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php index 0147d80b7bde94..ede8762c88c6dc 100644 --- a/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php @@ -276,7 +276,7 @@ public function update_font_library_permissions_check() { * @return bool Whether the font directory exists. */ private function has_upload_directory() { - $upload_dir = WP_Font_Library::get_fonts_dir(); + $upload_dir = wp_get_font_dir()['path']; return is_dir( $upload_dir ); } @@ -290,7 +290,7 @@ private function has_upload_directory() { private function has_write_permission() { // The update endpoints requires write access to the temp and the fonts directories. $temp_dir = get_temp_dir(); - $upload_dir = WP_Font_Library::get_fonts_dir(); + $upload_dir = wp_get_font_dir()['path']; if ( ! is_writable( $temp_dir ) || ! wp_is_writable( $upload_dir ) ) { return false; } @@ -353,7 +353,7 @@ public function install_fonts( $request ) { } if ( $this->needs_write_permission( $font_family_settings ) ) { - $upload_dir = WP_Font_Library::get_fonts_dir(); + $upload_dir = wp_get_font_dir()['path']; if ( ! $this->has_upload_directory() ) { if ( ! wp_mkdir_p( $upload_dir ) ) { $errors[] = new WP_Error( diff --git a/lib/experimental/fonts/font-library/font-library.php b/lib/experimental/fonts/font-library/font-library.php index 711a6bb40c282b..d1ad8e1447ad9c 100644 --- a/lib/experimental/fonts/font-library/font-library.php +++ b/lib/experimental/fonts/font-library/font-library.php @@ -50,7 +50,8 @@ function gutenberg_init_font_library_routes() { * Font collection associative array of configuration options. * * @type string $id The font collection's unique ID. - * @type string $src The font collection's data JSON file. + * @type string $src The font collection's data as a JSON file path. + * @type array $data The font collection's data as a PHP array. * } * @return WP_Font_Collection|WP_Error A font collection is it was registered * successfully, else WP_Error. @@ -75,10 +76,59 @@ function wp_unregister_font_collection( $collection_id ) { } $default_font_collection = array( - 'id' => 'default-font-collection', + 'slug' => 'default-font-collection', 'name' => 'Google Fonts', 'description' => __( 'Add from Google Fonts. Fonts are copied to and served from your site.', 'gutenberg' ), 'src' => 'https://s.w.org/images/fonts/16.7/collections/google-fonts-with-preview.json', ); wp_register_font_collection( $default_font_collection ); + +// @core-merge: This code should probably go into Core's src/wp-includes/functions.php. +if ( ! function_exists( 'wp_get_font_dir' ) ) { + /** + * Returns an array containing the current fonts upload directory's path and URL. + * + * @since 6.5.0 + * + * @param array $defaults { + * Array of information about the upload directory. + * + * @type string $path Base directory and subdirectory or full path to the fonts upload directory. + * @type string $url Base URL and subdirectory or absolute URL to the fonts upload directory. + * @type string $subdir Subdirectory + * @type string $basedir Path without subdir. + * @type string $baseurl URL path without subdir. + * @type string|false $error False or error message. + * } + * + * @return array $defaults { + * Array of information about the upload directory. + * + * @type string $path Base directory and subdirectory or full path to the fonts upload directory. + * @type string $url Base URL and subdirectory or absolute URL to the fonts upload directory. + * @type string $subdir Subdirectory + * @type string $basedir Path without subdir. + * @type string $baseurl URL path without subdir. + * @type string|false $error False or error message. + * } + */ + function wp_get_font_dir( $defaults = array() ) { + // Multi site path + $site_path = ''; + if ( is_multisite() && ! ( is_main_network() && is_main_site() ) ) { + $site_path = '/sites/' . get_current_blog_id(); + } + + // Sets the defaults. + $defaults['path'] = path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path; + $defaults['url'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; + $defaults['subdir'] = ''; + $defaults['basedir'] = path_join( WP_CONTENT_DIR, 'fonts' ) . $site_path; + $defaults['baseurl'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; + $defaults['error'] = false; + + // Filters the fonts directory data. + return apply_filters( 'font_dir', $defaults ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php index bb70068aa9482b..3b8a38f973815d 100644 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -35,7 +35,11 @@ class WP_Directive_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { * @param array $block The block to add. */ public static function mark_root_block( $block ) { - self::$root_block = md5( serialize( $block ) ); + if ( null !== $block['blockName'] ) { + self::$root_block = $block['blockName'] . md5( serialize( $block ) ); + } else { + self::$root_block = md5( serialize( $block ) ); + } } /** @@ -52,6 +56,14 @@ public static function unmark_root_block() { * @return bool True if block is a root block, false otherwise. */ public static function is_marked_as_root_block( $block ) { + // If self::$root_block is null, is impossible that any block has been marked as root. + if ( is_null( self::$root_block ) ) { + return false; + } + // Blocks whose blockName is null are specifically intended to convey - "this is a freeform HTML block." + if ( null !== $block['blockName'] ) { + return str_contains( self::$root_block, $block['blockName'] ) && $block['blockName'] . md5( serialize( $block ) ) === self::$root_block; + } return md5( serialize( $block ) ) === self::$root_block; } @@ -256,4 +268,43 @@ public static function is_html_void_element( $tag_name ) { public static function parse_attribute_name( $name ) { return explode( '--', $name, 2 ); } + + /** + * Parse and extract the namespace and path from the given value. + * + * If the value contains a JSON instead of a path, the function parses it + * and returns the resulting array. + * + * @param string $value Passed value. + * @param string $ns Namespace fallback. + * @return array The resulting array + */ + public static function parse_attribute_value( $value, $ns = null ) { + $matches = array(); + $has_ns = preg_match( '/^([\w\-_\/]+)::(.+)$/', $value, $matches ); + + /* + * Overwrite both `$ns` and `$value` variables if `$value` explicitly + * contains a namespace. + */ + if ( $has_ns ) { + list( , $ns, $value ) = $matches; + } + + /* + * Try to decode `$value` as a JSON object. If it works, `$value` is + * replaced with the resulting array. The original string is preserved + * otherwise. + * + * Note that `json_decode` returns `null` both for an invalid JSON or + * the `'null'` string (a valid JSON). In the latter case, `$value` is + * replaced with `null`. + */ + $data = json_decode( $value, true ); + if ( null !== $data || 'null' === trim( $value ) ) { + $value = $data; + } + + return array( $ns, $value ); + } } diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php new file mode 100644 index 00000000000000..15e57edfa4a6a2 --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php @@ -0,0 +1,82 @@ +%s', + wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP ) + ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php deleted file mode 100644 index c53701b14e8aff..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-interactivity-store.php +++ /dev/null @@ -1,69 +0,0 @@ -%s', - wp_json_encode( self::$store, JSON_HEX_TAG | JSON_HEX_AMP ) - ); - } -} diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php index 075d31d577634c..b49ee538390ff1 100644 --- a/lib/experimental/interactivity-api/directive-processing.php +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -43,12 +43,13 @@ function gutenberg_process_directives_in_root_blocks( $block_content, $block ) { $parsed_blocks = parse_blocks( $block_content ); $context = new WP_Directive_Context(); $processed_content = ''; + $namespace_stack = array(); foreach ( $parsed_blocks as $parsed_block ) { if ( 'core/interactivity-wrapper' === $parsed_block['blockName'] ) { - $processed_content .= gutenberg_process_interactive_block( $parsed_block, $context ); + $processed_content .= gutenberg_process_interactive_block( $parsed_block, $context, $namespace_stack ); } elseif ( 'core/non-interactivity-wrapper' === $parsed_block['blockName'] ) { - $processed_content .= gutenberg_process_non_interactive_block( $parsed_block, $context ); + $processed_content .= gutenberg_process_non_interactive_block( $parsed_block, $context, $namespace_stack ); } else { $processed_content .= $parsed_block['innerHTML']; } @@ -118,10 +119,11 @@ function gutenberg_mark_block_interactivity( $block_content, $block, $block_inst * * @param array $interactive_block The interactive block to process. * @param WP_Directive_Context $context The context to use when processing. + * @param array $namespace_stack Stack of namespackes passed by reference. * * @return string The processed HTML. */ -function gutenberg_process_interactive_block( $interactive_block, $context ) { +function gutenberg_process_interactive_block( $interactive_block, $context, &$namespace_stack ) { $block_index = 0; $content = ''; $interactive_inner_blocks = array(); @@ -137,7 +139,7 @@ function gutenberg_process_interactive_block( $interactive_block, $context ) { } } - return gutenberg_process_interactive_html( $content, $context, $interactive_inner_blocks ); + return gutenberg_process_interactive_html( $content, $context, $interactive_inner_blocks, $namespace_stack ); } /** @@ -147,10 +149,11 @@ function gutenberg_process_interactive_block( $interactive_block, $context ) { * * @param array $non_interactive_block The non-interactive block to process. * @param WP_Directive_Context $context The context to use when processing. + * @param array $namespace_stack Stack of namespackes passed by reference. * * @return string The processed HTML. */ -function gutenberg_process_non_interactive_block( $non_interactive_block, $context ) { +function gutenberg_process_non_interactive_block( $non_interactive_block, $context, &$namespace_stack ) { $block_index = 0; $content = ''; foreach ( $non_interactive_block['innerContent'] as $inner_content ) { @@ -164,9 +167,9 @@ function gutenberg_process_non_interactive_block( $non_interactive_block, $conte $inner_block = $non_interactive_block['innerBlocks'][ $block_index++ ]; if ( 'core/interactivity-wrapper' === $inner_block['blockName'] ) { - $content .= gutenberg_process_interactive_block( $inner_block, $context ); + $content .= gutenberg_process_interactive_block( $inner_block, $context, $namespace_stack ); } elseif ( 'core/non-interactivity-wrapper' === $inner_block['blockName'] ) { - $content .= gutenberg_process_non_interactive_block( $inner_block, $context ); + $content .= gutenberg_process_non_interactive_block( $inner_block, $context, $namespace_stack ); } } } @@ -184,16 +187,18 @@ function gutenberg_process_non_interactive_block( $non_interactive_block, $conte * @param string $html The HTML to process. * @param mixed $context The context to use when processing. * @param array $inner_blocks The inner blocks to process. + * @param array $namespace_stack Stack of namespackes passed by reference. * * @return string The processed HTML. */ -function gutenberg_process_interactive_html( $html, $context, $inner_blocks = array() ) { +function gutenberg_process_interactive_html( $html, $context, $inner_blocks = array(), &$namespace_stack = array() ) { static $directives = array( - 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', - 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', - 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', - 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', - 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', + 'data-wp-interactive' => 'gutenberg_interactivity_process_wp_interactive', + 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', + 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', + 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', + 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', + 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', ); $tags = new WP_Directive_Processor( $html ); @@ -207,9 +212,9 @@ function gutenberg_process_interactive_html( $html, $context, $inner_blocks = ar // Processes the inner blocks. if ( str_contains( $tag_name, 'WP-INNER-BLOCKS' ) && ! empty( $inner_blocks ) && ! $tags->is_tag_closer() ) { if ( 'core/interactivity-wrapper' === $inner_blocks[ $inner_blocks_index ]['blockName'] ) { - $inner_processed_blocks[ strtolower( $tag_name ) ] = gutenberg_process_interactive_block( $inner_blocks[ $inner_blocks_index++ ], $context ); + $inner_processed_blocks[ strtolower( $tag_name ) ] = gutenberg_process_interactive_block( $inner_blocks[ $inner_blocks_index++ ], $context, $namespace_stack ); } elseif ( 'core/non-interactivity-wrapper' === $inner_blocks[ $inner_blocks_index ]['blockName'] ) { - $inner_processed_blocks[ strtolower( $tag_name ) ] = gutenberg_process_non_interactive_block( $inner_blocks[ $inner_blocks_index++ ], $context ); + $inner_processed_blocks[ strtolower( $tag_name ) ] = gutenberg_process_non_interactive_block( $inner_blocks[ $inner_blocks_index++ ], $context, $namespace_stack ); } } if ( $tags->is_tag_closer() ) { @@ -270,7 +275,15 @@ function gutenberg_process_interactive_html( $html, $context, $inner_blocks = ar ); foreach ( $sorted_attrs as $attribute ) { - call_user_func( $directives[ $attribute ], $tags, $context ); + call_user_func_array( + $directives[ $attribute ], + array( + $tags, + $context, + end( $namespace_stack ), + &$namespace_stack, + ) + ); } } @@ -290,17 +303,25 @@ function gutenberg_process_interactive_html( $html, $context, $inner_blocks = ar } /** - * Resolves the reference using the store and the context from the provided - * path. + * Resolves the passed reference from the store and the context under the given + * namespace. * - * @param string $path Path. + * A reference could be either a single path or a namespace followed by a path, + * separated by two colons, i.e, `namespace::path.to.prop`. If the reference + * contains a namespace, that namespace overrides the one passed as argument. + * + * @param string $reference Reference value. + * @param string $ns Inherited namespace. * @param array $context Context data. - * @return mixed + * @return mixed Resolved value. */ -function gutenberg_interactivity_evaluate_reference( $path, array $context = array() ) { - $store = array_merge( - WP_Interactivity_Store::get_data(), - array( 'context' => $context ) +function gutenberg_interactivity_evaluate_reference( $reference, $ns, array $context = array() ) { + // Extract the namespace from the reference (if present). + list( $ns, $path ) = WP_Directive_Processor::parse_attribute_value( $reference, $ns ); + + $store = array( + 'state' => WP_Interactivity_Initial_State::get_state( $ns ), + 'context' => $context[ $ns ] ?? array(), ); /* @@ -329,7 +350,12 @@ function gutenberg_interactivity_evaluate_reference( $path, array $context = arr * E.g., "file" is an string and a "callable" (the "file" function exists). */ if ( $current instanceof Closure ) { - $current = call_user_func( $current, $store ); + /* + * TODO: Figure out a way to implement derived state without having to + * pass the store as argument: + * + * $current = call_user_func( $current ); + */ } // Returns the opposite if it has a negator operator (!). diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php index 54be4a9faeb7d2..57d2e5deb23ab4 100644 --- a/lib/experimental/interactivity-api/directives/wp-bind.php +++ b/lib/experimental/interactivity-api/directives/wp-bind.php @@ -11,8 +11,9 @@ * * @param WP_Directive_Processor $tags Tags. * @param WP_Directive_Context $context Directive context. + * @param string $ns Namespace. */ -function gutenberg_interactivity_process_wp_bind( $tags, $context ) { +function gutenberg_interactivity_process_wp_bind( $tags, $context, $ns ) { if ( $tags->is_tag_closer() ) { return; } @@ -25,8 +26,8 @@ function gutenberg_interactivity_process_wp_bind( $tags, $context ) { continue; } - $expr = $tags->get_attribute( $attr ); - $value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $reference = $tags->get_attribute( $attr ); + $value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); $tags->set_attribute( $bound_attr, $value ); } } diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php index 741cc75b42c60e..ef91835be86fc1 100644 --- a/lib/experimental/interactivity-api/directives/wp-class.php +++ b/lib/experimental/interactivity-api/directives/wp-class.php @@ -11,8 +11,9 @@ * * @param WP_Directive_Processor $tags Tags. * @param WP_Directive_Context $context Directive context. + * @param string $ns Namespace. */ -function gutenberg_interactivity_process_wp_class( $tags, $context ) { +function gutenberg_interactivity_process_wp_class( $tags, $context, $ns ) { if ( $tags->is_tag_closer() ) { return; } @@ -25,8 +26,8 @@ function gutenberg_interactivity_process_wp_class( $tags, $context ) { continue; } - $expr = $tags->get_attribute( $attr ); - $add_class = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $reference = $tags->get_attribute( $attr ); + $add_class = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); if ( $add_class ) { $tags->add_class( $class_name ); } else { diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php index 7d92b0ac7b0c67..b41b47c86c78c3 100644 --- a/lib/experimental/interactivity-api/directives/wp-context.php +++ b/lib/experimental/interactivity-api/directives/wp-context.php @@ -10,19 +10,21 @@ * * @param WP_Directive_Processor $tags Tags. * @param WP_Directive_Context $context Directive context. + * @param string $ns Namespace. */ -function gutenberg_interactivity_process_wp_context( $tags, $context ) { +function gutenberg_interactivity_process_wp_context( $tags, $context, $ns ) { if ( $tags->is_tag_closer() ) { $context->rewind_context(); return; } - $value = $tags->get_attribute( 'data-wp-context' ); + $attr_value = $tags->get_attribute( 'data-wp-context' ); - $new_context = json_decode( - is_string( $value ) && ! empty( $value ) ? $value : '{}', - true - ); + //Separate namespace and value from the context directive attribute. + list( $ns, $data ) = is_string( $attr_value ) && ! empty( $attr_value ) + ? WP_Directive_Processor::parse_attribute_value( $attr_value, $ns ) + : array( $ns, null ); - $context->set_context( $new_context ?? array() ); + // Add parsed data to the context under the corresponding namespace. + $context->set_context( array( $ns => is_array( $data ) ? $data : array() ) ); } diff --git a/lib/experimental/interactivity-api/directives/wp-interactive.php b/lib/experimental/interactivity-api/directives/wp-interactive.php new file mode 100644 index 00000000000000..9f3471a8b4e6a9 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-interactive.php @@ -0,0 +1,44 @@ +is_tag_closer() ) { + array_pop( $ns_stack ); + return; + } + + /* + * Decode the data-wp-interactive attribute. In the case it is not a valid + * JSON string, NULL is stored in `$island_data`. + */ + $island = $tags->get_attribute( 'data-wp-interactive' ); + $island_data = is_string( $island ) && ! empty( $island ) + ? json_decode( $island, true ) + : null; + + /* + * Push the newly defined namespace, or the current one if the island + * definition was invalid or does not contain a namespace. + * + * This is done because the function pops out the current namespace from the + * stack whenever it finds an island's closing tag, independently of whether + * the island definition was correct or it contained a valid namespace. + */ + $ns_stack[] = isset( $island_data ) && $island_data['namespace'] + ? $island_data['namespace'] + : $ns; +} diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php index e5d7b269ace7cf..16432e57282606 100644 --- a/lib/experimental/interactivity-api/directives/wp-style.php +++ b/lib/experimental/interactivity-api/directives/wp-style.php @@ -11,8 +11,9 @@ * * @param WP_Directive_Processor $tags Tags. * @param WP_Directive_Context $context Directive context. + * @param string $ns Namespace. */ -function gutenberg_interactivity_process_wp_style( $tags, $context ) { +function gutenberg_interactivity_process_wp_style( $tags, $context, $ns ) { if ( $tags->is_tag_closer() ) { return; } @@ -25,8 +26,8 @@ function gutenberg_interactivity_process_wp_style( $tags, $context ) { continue; } - $expr = $tags->get_attribute( $attr ); - $style_value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $reference = $tags->get_attribute( $attr ); + $style_value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); if ( $style_value ) { $style_attr = $tags->get_attribute( 'style' ) ?? ''; $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value ); diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php index b0cfc98a74e702..c4c5bb27a31e10 100644 --- a/lib/experimental/interactivity-api/directives/wp-text.php +++ b/lib/experimental/interactivity-api/directives/wp-text.php @@ -11,8 +11,9 @@ * * @param WP_Directive_Processor $tags Tags. * @param WP_Directive_Context $context Directive context. + * @param string $ns Namespace. */ -function gutenberg_interactivity_process_wp_text( $tags, $context ) { +function gutenberg_interactivity_process_wp_text( $tags, $context, $ns ) { if ( $tags->is_tag_closer() ) { return; } @@ -22,6 +23,6 @@ function gutenberg_interactivity_process_wp_text( $tags, $context ) { return; } - $text = gutenberg_interactivity_evaluate_reference( $value, $context->get_context() ); + $text = gutenberg_interactivity_evaluate_reference( $value, $ns, $context->get_context() ); $tags->set_inner_html( esc_html( $text ) ); } diff --git a/lib/experimental/interactivity-api/initial-state.php b/lib/experimental/interactivity-api/initial-state.php new file mode 100644 index 00000000000000..a38d0da631f3c4 --- /dev/null +++ b/lib/experimental/interactivity-api/initial-state.php @@ -0,0 +1,29 @@ + 'view_module_ids', + ); + foreach ( $module_fields as $metadata_field_name => $settings_field_name ) { + if ( ! empty( $settings[ $metadata_field_name ] ) ) { + $metadata[ $metadata_field_name ] = $settings[ $metadata_field_name ]; + } + if ( ! empty( $metadata[ $metadata_field_name ] ) ) { + $modules = $metadata[ $metadata_field_name ]; + $processed_modules = array(); + if ( is_array( $modules ) ) { + for ( $index = 0; $index < count( $modules ); $index++ ) { + $processed_modules[] = gutenberg_register_block_module_id( + $metadata, + $metadata_field_name, + $index + ); + } + } else { + $processed_modules[] = gutenberg_register_block_module_id( + $metadata, + $metadata_field_name + ); + } + $settings[ $settings_field_name ] = $processed_modules; + } + } + + return $settings; +} + +add_filter( 'block_type_metadata_settings', 'gutenberg_filter_block_type_metadata_settings_register_modules', 10, 2 ); + +/** + * Enqueue modules associated with the block. + * + * @param string $block_content The block content. + * @param array $block The full block, including name and attributes. + * @param WP_Block $instance The block instance. + */ +function gutenberg_filter_render_block_enqueue_view_modules( $block_content, $parsed_block, $block_instance ) { + $block_type = $block_instance->block_type; + + if ( ! empty( $block_type->view_module_ids ) ) { + foreach ( $block_type->view_module_ids as $module_id ) { + gutenberg_enqueue_module( $module_id ); + } + } + + return $block_content; +} + +add_filter( 'render_block', 'gutenberg_filter_render_block_enqueue_view_modules', 10, 3 ); + +/** + * Finds a module ID for the selected block metadata field. It detects + * when a path to file was provided and finds a corresponding asset file + * with details necessary to register the module under an automatically + * generated module ID. + * + * This is analogous to the `register_block_script_handle` in WordPress Core. + * + * @param array $metadata Block metadata. + * @param string $field_name Field name to pick from metadata. + * @param int $index Optional. Index of the script to register when multiple items passed. + * Default 0. + * @return string Module ID. + */ +function gutenberg_register_block_module_id( $metadata, $field_name, $index = 0 ) { + if ( empty( $metadata[ $field_name ] ) ) { + return false; + } + + $module_id = $metadata[ $field_name ]; + if ( is_array( $module_id ) ) { + if ( empty( $module_id[ $index ] ) ) { + return false; + } + $module_id = $module_id[ $index ]; + } + + $module_path = remove_block_asset_path_prefix( $module_id ); + if ( $module_id === $module_path ) { + return $module_id; + } + + $path = dirname( $metadata['file'] ); + $module_asset_raw_path = $path . '/' . substr_replace( $module_path, '.asset.php', - strlen( '.js' ) ); + $module_id = gutenberg_generate_block_asset_module_id( $metadata['name'], $field_name, $index ); + $module_asset_path = wp_normalize_path( realpath( $module_asset_raw_path ) ); + + if ( empty( $module_asset_path ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + // This string is from WordPress Core. See `register_block_script_handle`. + // Translators: This is a translation from WordPress Core (default). No need to translate. + __( 'The asset file (%1$s) for the "%2$s" defined in "%3$s" block definition is missing.', 'default' ), + $module_asset_raw_path, + $field_name, + $metadata['name'] + ), + '6.5.0' + ); + return false; + } + + $module_path_norm = wp_normalize_path( realpath( $path . '/' . $module_path ) ); + $module_uri = get_block_asset_url( $module_path_norm ); + $module_asset = require $module_asset_path; + $module_dependencies = isset( $module_asset['dependencies'] ) ? $module_asset['dependencies'] : array(); + + gutenberg_register_module( + $module_id, + $module_uri, + $module_dependencies, + isset( $module_asset['version'] ) ? $module_asset['version'] : false + ); + + return $module_id; +} + +/** + * Generates the module ID for an asset based on the name of the block + * and the field name provided. + * + * This is analogous to the `generate_block_asset_handle` in WordPress Core. + * + * @param string $block_name Name of the block. + * @param string $field_name Name of the metadata field. + * @param int $index Optional. Index of the asset when multiple items passed. + * Default 0. + * @return string Generated module ID for the block's field. + */ +function gutenberg_generate_block_asset_module_id( $block_name, $field_name, $index = 0 ) { + if ( str_starts_with( $block_name, 'core/' ) ) { + $asset_handle = str_replace( 'core/', 'wp-block-', $block_name ); + if ( str_starts_with( $field_name, 'editor' ) ) { + $asset_handle .= '-editor'; + } + if ( str_starts_with( $field_name, 'view' ) ) { + $asset_handle .= '-view'; + } + if ( $index > 0 ) { + $asset_handle .= '-' . ( $index + 1 ); + } + return $asset_handle; + } + + $field_mappings = array( + 'viewModule' => 'view-module', + ); + $asset_handle = str_replace( '/', '-', $block_name ) . + '-' . $field_mappings[ $field_name ]; + if ( $index > 0 ) { + $asset_handle .= '-' . ( $index + 1 ); + } + return $asset_handle; +} + +function gutenberg_register_view_module_ids_rest_field() { + register_rest_field( + 'block-type', + 'view_module_ids', + array( + 'get_callback' => function ( $item ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $item['name'] ); + if ( isset( $block_type->view_module_ids ) ) { + return $block_type->view_module_ids; + } + return array(); + }, + ) + ); +} + +add_action( 'rest_api_init', 'gutenberg_register_view_module_ids_rest_field' ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 2d2e76273d2d59..8af1eb82c6bed0 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -128,13 +128,13 @@ function gutenberg_initialize_experiments_settings() { add_settings_field( 'gutenberg-custom-fields', - __( 'Connections', 'gutenberg' ), + __( 'Block Bindings & Custom Fields', 'gutenberg' ), 'gutenberg_display_experiment_field', 'gutenberg-experiments', 'gutenberg_experiments_section', array( - 'label' => __( 'Test connecting block attribute values to a custom field value', 'gutenberg' ), - 'id' => 'gutenberg-connections', + 'label' => __( 'Test connecting block attributes to different sources like custom fields', 'gutenberg' ), + 'id' => 'gutenberg-block-bindings', ) ); diff --git a/lib/load.php b/lib/load.php index 1aa55f7581c272..d413334227ee73 100644 --- a/lib/load.php +++ b/lib/load.php @@ -117,8 +117,8 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/disable-tinymce.php'; } -require __DIR__ . '/experimental/interactivity-api/class-wp-interactivity-store.php'; -require __DIR__ . '/experimental/interactivity-api/store.php'; +require __DIR__ . '/experimental/interactivity-api/class-wp-interactivity-initial-state.php'; +require __DIR__ . '/experimental/interactivity-api/initial-state.php'; require __DIR__ . '/experimental/interactivity-api/modules.php'; require __DIR__ . '/experimental/interactivity-api/class-wp-directive-processor.php'; require __DIR__ . '/experimental/interactivity-api/class-wp-directive-context.php'; @@ -128,6 +128,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/interactivity-api/directives/wp-class.php'; require __DIR__ . '/experimental/interactivity-api/directives/wp-style.php'; require __DIR__ . '/experimental/interactivity-api/directives/wp-text.php'; +require __DIR__ . '/experimental/interactivity-api/directives/wp-interactive.php'; require __DIR__ . '/experimental/modules/class-gutenberg-modules.php'; diff --git a/lib/theme.json b/lib/theme.json index c2ed7fdca39ed5..b7bc3cb89e60f2 100644 --- a/lib/theme.json +++ b/lib/theme.json @@ -236,6 +236,7 @@ }, "typography": { "customFontSize": true, + "defaultFontSizes": true, "dropCap": true, "fontSizes": [ { diff --git a/package-lock.json b/package-lock.json index 25144ad2de9782..dc84ae2fba494e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "17.4.1", + "version": "17.5.0-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "17.4.1", + "version": "17.5.0-rc.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -86,7 +86,7 @@ "devDependencies": { "@actions/core": "1.9.1", "@actions/github": "5.0.0", - "@ariakit/test": "^0.3.5", + "@ariakit/test": "^0.3.7", "@babel/core": "7.16.0", "@babel/plugin-proposal-export-namespace-from": "7.18.9", "@babel/plugin-syntax-jsx": "7.16.0", @@ -212,7 +212,7 @@ "node-fetch": "2.6.1", "node-watch": "0.7.0", "npm-run-all": "4.1.5", - "patch-package": "6.2.2", + "patch-package": "8.0.0", "postcss": "8.4.16", "postcss-loader": "6.2.1", "prettier": "npm:wp-prettier@3.0.3", @@ -1628,13 +1628,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ariakit/core": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.3.10.tgz", + "integrity": "sha512-AcN+GSoVXuUOzKx5d3xPL3YsEHevh4PIO6QIt/mg/nRX1XQ6cvxQEiAjO/BJQm+/MVl7/VbuGBoTFjr0tPU6NQ==" + }, + "node_modules/@ariakit/react": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.3.12.tgz", + "integrity": "sha512-HxKMZZhWSkwwS/Sh9OdWyuNKQ2tjDAIQIy2KVI7IRa8ZQ6ze/4g3YLUHbfCxO7oDupXHfXaeZ4hWx8lP7l1U/g==", + "dependencies": { + "@ariakit/react-core": "0.3.12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ariakit" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@ariakit/react-core": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.3.12.tgz", + "integrity": "sha512-w6P1A7TYb1fKUe9QbwaoTOWofl13g7TEuXdV4JyefJCQL1e9HQdEw9UL67I8aXRo8/cFHH94/z0N37t8hw5Ogg==", + "dependencies": { + "@ariakit/core": "0.3.10", + "@floating-ui/dom": "^1.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@ariakit/test": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.3.5.tgz", - "integrity": "sha512-7UCQBnJZ88JptkEnAXT7iSgtxEZiFwqdkKtxLCXDssTOJNatbFsnq0Jow324y41jGfAE2n4Lf5qY2FsZUPf9XQ==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.3.7.tgz", + "integrity": "sha512-rOa9pJA0ZfPPSI4SkDX41CsBcvxs6BmxgzFEElZWZo/uBBqtnr8ZL4oe5HySeZKEAHRH86XDqfxFISkhV76m5g==", "dev": true, "dependencies": { - "@ariakit/core": "0.3.8", + "@ariakit/core": "0.3.10", "@testing-library/dom": "^8.0.0 || ^9.0.0" }, "peerDependencies": { @@ -1650,12 +1685,6 @@ } } }, - "node_modules/@ariakit/test/node_modules/@ariakit/core": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.3.8.tgz", - "integrity": "sha512-LlSCwbyyozMX4ZEobpYGcv1LFqOdBTdTYPZw3lAVgLcFSNivsazi3NkKM9qNWNIu00MS+xTa0+RuIcuWAjlB2Q==", - "dev": true - }, "node_modules/@aw-web-design/x-default-browser": { "version": "1.4.126", "resolved": "https://registry.npmjs.org/@aw-web-design/x-default-browser/-/x-default-browser-1.4.126.tgz", @@ -19711,6 +19740,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -21203,13 +21241,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dev": true, "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -24631,6 +24670,20 @@ "node": ">=10" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -28068,108 +28121,12 @@ } }, "node_modules/find-yarn-workspace-root": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-1.2.1.tgz", - "integrity": "sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q==", - "dev": true, - "dependencies": { - "fs-extra": "^4.0.3", - "micromatch": "^3.1.4" - } - }, - "node_modules/find-yarn-workspace-root/node_modules/braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "dependencies": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/find-yarn-workspace-root/node_modules/braces/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/find-yarn-workspace-root/node_modules/fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", - "dev": true, - "dependencies": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/find-yarn-workspace-root/node_modules/fill-range/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/find-yarn-workspace-root/node_modules/fs-extra": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", - "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "node_modules/find-yarn-workspace-root/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true, "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" + "micromatch": "^4.0.2" } }, "node_modules/flat": { @@ -28603,9 +28560,12 @@ "dev": true }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { "version": "1.1.5", @@ -33323,12 +33283,36 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-zfA+5SuwYN2VWqN1/5HZaDzQKLJHaBVMZIIM+wuYjdptkaQsqzDdqjqf+lZZJUuJq1aanHiY8LhH8LmH+qBYJA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -33366,6 +33350,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -42337,85 +42330,78 @@ } }, "node_modules/patch-package": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-6.2.2.tgz", - "integrity": "sha512-YqScVYkVcClUY0v8fF0kWOjDYopzIM8e3bj/RU1DPeEF14+dCGm6UeOYm4jvCyxqIEQ5/eJzmbWfDWnUleFNMg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", "dev": true, "dependencies": { "@yarnpkg/lockfile": "^1.1.0", - "chalk": "^2.4.2", - "cross-spawn": "^6.0.5", - "find-yarn-workspace-root": "^1.2.1", - "fs-extra": "^7.0.1", - "is-ci": "^2.0.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", - "minimist": "^1.2.0", + "minimist": "^1.2.6", + "open": "^7.4.2", "rimraf": "^2.6.3", - "semver": "^5.6.0", + "semver": "^7.5.3", "slash": "^2.0.0", - "tmp": "^0.0.33" + "tmp": "^0.0.33", + "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" }, "engines": { + "node": ">=14", "npm": ">5" } }, - "node_modules/patch-package/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/patch-package/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/patch-package/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=4.8" + "node": ">= 8" } }, "node_modules/patch-package/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=10" } }, "node_modules/patch-package/node_modules/glob": { @@ -42438,6 +42424,64 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/patch-package/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/patch-package/node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -42450,13 +42494,25 @@ "rimraf": "bin.js" } }, - "node_modules/patch-package/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/patch-package/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "bin": { - "semver": "bin/semver" + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" } }, "node_modules/patch-package/node_modules/slash": { @@ -42468,6 +42524,51 @@ "node": ">=6" } }, + "node_modules/patch-package/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/patch-package/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/patch-package/node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/path-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", @@ -47215,6 +47316,21 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -54157,7 +54273,7 @@ "version": "25.14.0", "license": "GPL-2.0-or-later", "dependencies": { - "@ariakit/react": "^0.3.10", + "@ariakit/react": "^0.3.12", "@babel/runtime": "^7.16.0", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", @@ -54216,41 +54332,6 @@ "react-dom": "^18.0.0" } }, - "packages/components/node_modules/@ariakit/core": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.3.8.tgz", - "integrity": "sha512-LlSCwbyyozMX4ZEobpYGcv1LFqOdBTdTYPZw3lAVgLcFSNivsazi3NkKM9qNWNIu00MS+xTa0+RuIcuWAjlB2Q==" - }, - "packages/components/node_modules/@ariakit/react": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.3.10.tgz", - "integrity": "sha512-XRY69IOm8Oy+HSPoaspcVLAhLo3ToLhhJKSLK1voTAZtSzu5kUeUf4nUPxTzYFsvirKORZgOLAeNwuo1gPr61g==", - "dependencies": { - "@ariakit/react-core": "0.3.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ariakit" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - } - }, - "packages/components/node_modules/@ariakit/react-core": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.3.10.tgz", - "integrity": "sha512-CzSffcNlOyS2xuy21UB6fgJXi5LriJ9JrTSJzcgJmE+P9/WfQlplJC3L75d8O2yKgaGPeFnQ0hhDA6ItsI98eQ==", - "dependencies": { - "@ariakit/core": "0.3.8", - "@floating-ui/dom": "^1.0.0", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - } - }, "packages/components/node_modules/@floating-ui/react-dom": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.1.tgz", @@ -55216,6 +55297,7 @@ "@wordpress/icons": "file:../icons", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", + "@wordpress/private-apis": "file:../private-apis", "@wordpress/viewport": "file:../viewport", "classnames": "^2.3.1" }, @@ -57305,22 +57387,37 @@ } } }, + "@ariakit/core": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.3.10.tgz", + "integrity": "sha512-AcN+GSoVXuUOzKx5d3xPL3YsEHevh4PIO6QIt/mg/nRX1XQ6cvxQEiAjO/BJQm+/MVl7/VbuGBoTFjr0tPU6NQ==" + }, + "@ariakit/react": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.3.12.tgz", + "integrity": "sha512-HxKMZZhWSkwwS/Sh9OdWyuNKQ2tjDAIQIy2KVI7IRa8ZQ6ze/4g3YLUHbfCxO7oDupXHfXaeZ4hWx8lP7l1U/g==", + "requires": { + "@ariakit/react-core": "0.3.12" + } + }, + "@ariakit/react-core": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.3.12.tgz", + "integrity": "sha512-w6P1A7TYb1fKUe9QbwaoTOWofl13g7TEuXdV4JyefJCQL1e9HQdEw9UL67I8aXRo8/cFHH94/z0N37t8hw5Ogg==", + "requires": { + "@ariakit/core": "0.3.10", + "@floating-ui/dom": "^1.0.0", + "use-sync-external-store": "^1.2.0" + } + }, "@ariakit/test": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.3.5.tgz", - "integrity": "sha512-7UCQBnJZ88JptkEnAXT7iSgtxEZiFwqdkKtxLCXDssTOJNatbFsnq0Jow324y41jGfAE2n4Lf5qY2FsZUPf9XQ==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.3.7.tgz", + "integrity": "sha512-rOa9pJA0ZfPPSI4SkDX41CsBcvxs6BmxgzFEElZWZo/uBBqtnr8ZL4oe5HySeZKEAHRH86XDqfxFISkhV76m5g==", "dev": true, "requires": { - "@ariakit/core": "0.3.8", + "@ariakit/core": "0.3.10", "@testing-library/dom": "^8.0.0 || ^9.0.0" - }, - "dependencies": { - "@ariakit/core": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.3.8.tgz", - "integrity": "sha512-LlSCwbyyozMX4ZEobpYGcv1LFqOdBTdTYPZw3lAVgLcFSNivsazi3NkKM9qNWNIu00MS+xTa0+RuIcuWAjlB2Q==", - "dev": true - } } }, "@aw-web-design/x-default-browser": { @@ -69276,7 +69373,7 @@ "@wordpress/components": { "version": "file:packages/components", "requires": { - "@ariakit/react": "^0.3.10", + "@ariakit/react": "^0.3.12", "@babel/runtime": "^7.16.0", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", @@ -69328,29 +69425,6 @@ "valtio": "1.7.0" }, "dependencies": { - "@ariakit/core": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.3.8.tgz", - "integrity": "sha512-LlSCwbyyozMX4ZEobpYGcv1LFqOdBTdTYPZw3lAVgLcFSNivsazi3NkKM9qNWNIu00MS+xTa0+RuIcuWAjlB2Q==" - }, - "@ariakit/react": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.3.10.tgz", - "integrity": "sha512-XRY69IOm8Oy+HSPoaspcVLAhLo3ToLhhJKSLK1voTAZtSzu5kUeUf4nUPxTzYFsvirKORZgOLAeNwuo1gPr61g==", - "requires": { - "@ariakit/react-core": "0.3.10" - } - }, - "@ariakit/react-core": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.3.10.tgz", - "integrity": "sha512-CzSffcNlOyS2xuy21UB6fgJXi5LriJ9JrTSJzcgJmE+P9/WfQlplJC3L75d8O2yKgaGPeFnQ0hhDA6ItsI98eQ==", - "requires": { - "@ariakit/core": "0.3.8", - "@floating-ui/dom": "^1.0.0", - "use-sync-external-store": "^1.2.0" - } - }, "@floating-ui/react-dom": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.1.tgz", @@ -70031,6 +70105,7 @@ "@wordpress/icons": "file:../icons", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", + "@wordpress/private-apis": "file:../private-apis", "@wordpress/viewport": "file:../viewport", "classnames": "^2.3.1" } @@ -71918,6 +71993,12 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true + }, "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -73082,13 +73163,14 @@ } }, "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" } }, "caller-callsite": { @@ -75687,6 +75769,17 @@ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "dev": true }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, "define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -78350,99 +78443,12 @@ } }, "find-yarn-workspace-root": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-1.2.1.tgz", - "integrity": "sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true, "requires": { - "fs-extra": "^4.0.3", - "micromatch": "^3.1.4" - }, - "dependencies": { - "braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "requires": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "fs-extra": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", - "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - } - } + "micromatch": "^4.0.2" } }, "flat": { @@ -78773,9 +78779,9 @@ } }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "function.prototype.name": { "version": "1.1.5", @@ -82299,6 +82305,26 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-zfA+5SuwYN2VWqN1/5HZaDzQKLJHaBVMZIIM+wuYjdptkaQsqzDdqjqf+lZZJUuJq1aanHiY8LhH8LmH+qBYJA==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -82336,6 +82362,12 @@ "graceful-fs": "^4.1.6" } }, + "jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true + }, "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -89309,67 +89341,59 @@ "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==" }, "patch-package": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-6.2.2.tgz", - "integrity": "sha512-YqScVYkVcClUY0v8fF0kWOjDYopzIM8e3bj/RU1DPeEF14+dCGm6UeOYm4jvCyxqIEQ5/eJzmbWfDWnUleFNMg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", "dev": true, "requires": { "@yarnpkg/lockfile": "^1.1.0", - "chalk": "^2.4.2", - "cross-spawn": "^6.0.5", - "find-yarn-workspace-root": "^1.2.1", - "fs-extra": "^7.0.1", - "is-ci": "^2.0.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", - "minimist": "^1.2.0", + "minimist": "^1.2.6", + "open": "^7.4.2", "rimraf": "^2.6.3", - "semver": "^5.6.0", + "semver": "^7.5.3", "slash": "^2.0.0", - "tmp": "^0.0.33" + "tmp": "^0.0.33", + "yaml": "^2.2.2" }, "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" } }, "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" } }, "fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "requires": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" } }, "glob": { @@ -89386,6 +89410,47 @@ "path-is-absolute": "^1.0.0" } }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -89395,10 +89460,19 @@ "glob": "^7.1.3" } }, - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, "slash": { @@ -89406,6 +89480,36 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true } } }, @@ -92999,6 +93103,18 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", diff --git a/package.json b/package.json index 5f99726146658e..684f35d408d3c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "17.4.1", + "version": "17.5.0-rc.1", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", @@ -98,7 +98,7 @@ "devDependencies": { "@actions/core": "1.9.1", "@actions/github": "5.0.0", - "@ariakit/test": "^0.3.5", + "@ariakit/test": "^0.3.7", "@babel/core": "7.16.0", "@babel/plugin-proposal-export-namespace-from": "7.18.9", "@babel/plugin-syntax-jsx": "7.16.0", @@ -224,7 +224,7 @@ "node-fetch": "2.6.1", "node-watch": "0.7.0", "npm-run-all": "4.1.5", - "patch-package": "6.2.2", + "patch-package": "8.0.0", "postcss": "8.4.16", "postcss-loader": "6.2.1", "prettier": "npm:wp-prettier@3.0.3", diff --git a/packages/api-fetch/src/middlewares/theme-preview.js b/packages/api-fetch/src/middlewares/theme-preview.js index 8ab21fc3284763..56098c88cb351f 100644 --- a/packages/api-fetch/src/middlewares/theme-preview.js +++ b/packages/api-fetch/src/middlewares/theme-preview.js @@ -1,32 +1,39 @@ /** * WordPress dependencies */ -import { addQueryArgs, hasQueryArg } from '@wordpress/url'; +import { addQueryArgs, getQueryArg, removeQueryArgs } from '@wordpress/url'; /** * This appends a `wp_theme_preview` parameter to the REST API request URL if * the admin URL contains a `theme` GET parameter. * + * If the REST API request URL has contained the `wp_theme_preview` parameter as `''`, + * then bypass this middleware. + * * @param {Record} themePath * @return {import('../types').APIFetchMiddleware} Preloading middleware. */ const createThemePreviewMiddleware = ( themePath ) => ( options, next ) => { - if ( - typeof options.url === 'string' && - ! hasQueryArg( options.url, 'wp_theme_preview' ) - ) { - options.url = addQueryArgs( options.url, { - wp_theme_preview: themePath, - } ); + if ( typeof options.url === 'string' ) { + const wpThemePreview = getQueryArg( options.url, 'wp_theme_preview' ); + if ( wpThemePreview === undefined ) { + options.url = addQueryArgs( options.url, { + wp_theme_preview: themePath, + } ); + } else if ( wpThemePreview === '' ) { + options.url = removeQueryArgs( options.url, 'wp_theme_preview' ); + } } - if ( - typeof options.path === 'string' && - ! hasQueryArg( options.path, 'wp_theme_preview' ) - ) { - options.path = addQueryArgs( options.path, { - wp_theme_preview: themePath, - } ); + if ( typeof options.path === 'string' ) { + const wpThemePreview = getQueryArg( options.path, 'wp_theme_preview' ); + if ( wpThemePreview === undefined ) { + options.path = addQueryArgs( options.path, { + wp_theme_preview: themePath, + } ); + } else if ( wpThemePreview === '' ) { + options.path = removeQueryArgs( options.path, 'wp_theme_preview' ); + } } return next( options ); diff --git a/packages/block-editor/src/components/block-canvas/style.scss b/packages/block-editor/src/components/block-canvas/style.scss index 54ccd407d74a21..631024b7c3aec1 100644 --- a/packages/block-editor/src/components/block-canvas/style.scss +++ b/packages/block-editor/src/components/block-canvas/style.scss @@ -1,6 +1,13 @@ iframe[name="editor-canvas"] { width: 100%; height: 100%; - background-color: $white; display: block; } + +iframe[name="editor-canvas"]:not(.has-history) { + background-color: $white; +} + +iframe[name="editor-canvas"].has-history { + padding: $grid-unit-60 $grid-unit-60 0; +} diff --git a/packages/block-editor/src/components/block-lock/toolbar.js b/packages/block-editor/src/components/block-lock/toolbar.js index ccf04c5e5262d7..69f0825ed4be33 100644 --- a/packages/block-editor/src/components/block-lock/toolbar.js +++ b/packages/block-editor/src/components/block-lock/toolbar.js @@ -33,16 +33,24 @@ export default function BlockLockToolbar( { clientId } ) { } }, [ isLocked ] ); - if ( ! canLock || ( ! isLocked && ! hasLockButtonShown.current ) ) { + if ( ! isLocked && ! hasLockButtonShown.current ) { return null; } + let label = isLocked ? __( 'Unlock' ) : __( 'Lock' ); + + if ( ! canLock && isLocked ) { + label = __( 'Locked' ); + } + return ( <> { const { name } = item; if ( priorityTextTranformsNames.includes( name ) ) { @@ -49,6 +49,23 @@ function useGroupedTransforms( possibleBlockTransformations ) { }, { priorityTextTransformations: [], restTransformations: [] } ); + /** + * If there is only one priority text transformation and it's a Quote, + * is should move to the rest transformations. This is because Quote can + * be a container for any block type, so in multi-block selection it will + * always be suggested, even for non-text blocks. + */ + if ( + groupedPossibleTransforms.priorityTextTransformations.length === + 1 && + groupedPossibleTransforms.priorityTextTransformations[ 0 ].name === + 'core/quote' + ) { + const singleQuote = + groupedPossibleTransforms.priorityTextTransformations.pop(); + groupedPossibleTransforms.restTransformations.push( singleQuote ); + } + return groupedPossibleTransforms; }, [ possibleBlockTransformations ] ); // Order the priority text transformations. diff --git a/packages/block-editor/src/components/block-toolbar/style.scss b/packages/block-editor/src/components/block-toolbar/style.scss index 85020cea2aa23f..fc18dba7cc2cf8 100644 --- a/packages/block-editor/src/components/block-toolbar/style.scss +++ b/packages/block-editor/src/components/block-toolbar/style.scss @@ -203,6 +203,24 @@ .block-editor-block-mover .block-editor-block-mover__move-button-container { width: auto; + + @include break-small() { + position: relative; + + &::before { + content: ""; + height: $border-width; + width: 100%; + background: $gray-900; + position: absolute; + top: 50%; + left: 50%; + // With Top toolbar enabled, this separator has a smaller width. Translating the + // X axis allows to make the separator always centered regardless of its width. + transform: translate(-50%, 0); + margin-top: -$border-width * 0.5; + } + } } .block-editor-block-mover.is-horizontal { @@ -231,19 +249,6 @@ padding-right: $grid-unit-15; } - @include break-small() { - // Specificity override for https://github.com/WordPress/gutenberg/blob/try/block-toolbar-labels/packages/block-editor/src/components/block-mover/style.scss#L69 - .is-up-button.is-up-button.is-up-button { - margin-right: 0; - border-radius: 0; - order: 1; - } - - .is-down-button.is-down-button.is-down-button { - order: 2; - } - } - .block-editor-block-contextual-toolbar .block-editor-block-mover.is-horizontal .block-editor-block-mover-button.block-editor-block-mover-button { width: auto; } diff --git a/packages/block-editor/src/components/global-styles/dimensions-panel.js b/packages/block-editor/src/components/global-styles/dimensions-panel.js index 47b5bd329725a7..47e50aa515e3c6 100644 --- a/packages/block-editor/src/components/global-styles/dimensions-panel.js +++ b/packages/block-editor/src/components/global-styles/dimensions-panel.js @@ -603,7 +603,7 @@ export default function DimensionsPanel( { { showMinHeightControl && ( diff --git a/packages/block-editor/src/components/global-styles/hooks.js b/packages/block-editor/src/components/global-styles/hooks.js index 1fed98cfd229b8..2652732807cfd7 100644 --- a/packages/block-editor/src/components/global-styles/hooks.js +++ b/packages/block-editor/src/components/global-styles/hooks.js @@ -66,6 +66,7 @@ const VALID_SETTINGS = [ 'spacing.units', 'typography.fluid', 'typography.customFontSize', + 'typography.defaultFontSizes', 'typography.dropCap', 'typography.fontFamilies', 'typography.fontSizes', @@ -238,6 +239,7 @@ export function useSettingsForBlockElement( ...updatedSettings.typography, fontSizes: {}, customFontSize: false, + defaultFontSizes: false, }; } diff --git a/packages/block-editor/src/components/global-styles/typography-panel.js b/packages/block-editor/src/components/global-styles/typography-panel.js index 8e6755a6e4c2c4..668e8b101be926 100644 --- a/packages/block-editor/src/components/global-styles/typography-panel.js +++ b/packages/block-editor/src/components/global-styles/typography-panel.js @@ -22,7 +22,7 @@ import TextTransformControl from '../text-transform-control'; import TextDecorationControl from '../text-decoration-control'; import WritingModeControl from '../writing-mode-control'; import { getValueFromVariable, TOOLSPANEL_DROPDOWNMENU_PROPS } from './utils'; -import { setImmutably } from '../../utils/object'; +import { setImmutably, uniqByProperty } from '../../utils/object'; const MIN_TEXT_COLUMNS = 1; const MAX_TEXT_COLUMNS = 6; @@ -53,7 +53,10 @@ export function useHasTypographyPanel( settings ) { function useHasFontSizeControl( settings ) { return ( - hasMergedOrigins( settings?.typography?.fontSizes ) || + ( settings?.typography?.defaultFontSizes !== false && + settings?.typography?.fontSizes?.default?.length ) || + settings?.typography?.fontSizes?.theme?.length || + settings?.typography?.fontSizes?.custom?.length || settings?.typography?.customFontSize ); } @@ -100,16 +103,45 @@ function useHasTextColumnsControl( settings ) { return settings?.typography?.textColumns; } -function getUniqueFontSizesBySlug( settings ) { - const fontSizes = settings?.typography?.fontSizes; - const mergedFontSizes = fontSizes ? mergeOrigins( fontSizes ) : []; - const uniqueSizes = []; - for ( const currentSize of mergedFontSizes ) { - if ( ! uniqueSizes.some( ( { slug } ) => slug === currentSize.slug ) ) { - uniqueSizes.push( currentSize ); - } - } - return uniqueSizes; +/** + * TODO: The reversing and filtering of default font sizes is a hack so the + * dropdown UI matches what is generated in the global styles CSS stylesheet. + * + * This is a temporary solution until #57733 is resolved. At which point, + * the mergedFontSizes would just need to be the concatenated array of all + * presets or a custom dropdown with sections for each. + * + * @see {@link https://github.com/WordPress/gutenberg/issues/57733} + * + * @param {Object} settings The global styles settings. + * + * @return {Array} The merged font sizes. + */ +function getMergedFontSizes( settings ) { + // The font size presets are merged in reverse order so that the duplicates + // that may defined later in the array have higher priority to match the CSS. + const mergedFontSizesAll = uniqByProperty( + [ + settings?.typography?.fontSizes?.custom, + settings?.typography?.fontSizes?.theme, + settings?.typography?.fontSizes?.default, + ].flatMap( ( presets ) => presets?.toReversed() ?? [] ), + 'slug' + ).reverse(); + + // Default presets exist in the global styles CSS no matter the setting, so + // filtering them out in the UI has to be done after merging. + const mergedFontSizes = + settings?.typography?.defaultFontSizes === false + ? mergedFontSizesAll.filter( + ( { slug } ) => + ! [ 'small', 'medium', 'large', 'x-large' ].includes( + slug + ) + ) + : mergedFontSizesAll; + + return mergedFontSizes; } function TypographyToolsPanel( { @@ -185,7 +217,7 @@ export default function TypographyPanel( { // Font Size const hasFontSizeEnabled = useHasFontSizeControl( settings ); const disableCustomFontSizes = ! settings?.typography?.customFontSize; - const mergedFontSizes = getUniqueFontSizesBySlug( settings ); + const mergedFontSizes = getMergedFontSizes( settings ); const fontSize = decodeValue( inheritedValue?.typography?.fontSize ); const setFontSize = ( newValue, metadata ) => { diff --git a/packages/block-editor/src/components/height-control/README.md b/packages/block-editor/src/components/height-control/README.md index 67b52f1d56f9b2..9be1741e8cdd8e 100644 --- a/packages/block-editor/src/components/height-control/README.md +++ b/packages/block-editor/src/components/height-control/README.md @@ -43,7 +43,7 @@ A callback function that handles the application of the height value. - **Type:** `String` - **Default:** `'Height'` -A label for the height control. This is useful when using the height control for a feature that is controlled in the same way as height, but requires a different label. For example, "Min. height". +A label for the height control. This is useful when using the height control for a feature that is controlled in the same way as height, but requires a different label. For example, "Minimum height". ## Related components diff --git a/packages/block-editor/src/components/height-control/index.js b/packages/block-editor/src/components/height-control/index.js index e1797522497447..23738378b69983 100644 --- a/packages/block-editor/src/components/height-control/index.js +++ b/packages/block-editor/src/components/height-control/index.js @@ -157,6 +157,8 @@ export default function HeightControl( { onUnitChange={ handleUnitChange } min={ 0 } size={ '__unstable-large' } + label={ label } + hideLabelFromVision /> @@ -175,6 +177,8 @@ export default function HeightControl( { withInputField={ false } onChange={ handleSliderChange } __nextHasNoMarginBottom + label={ label } + hideLabelFromVision /> diff --git a/packages/block-editor/src/components/navigable-toolbar/index.js b/packages/block-editor/src/components/navigable-toolbar/index.js index e97efb2a4b3910..b5212497b287e3 100644 --- a/packages/block-editor/src/components/navigable-toolbar/index.js +++ b/packages/block-editor/src/components/navigable-toolbar/index.js @@ -19,6 +19,7 @@ import { ESCAPE } from '@wordpress/keycodes'; * Internal dependencies */ import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; function hasOnlyToolbarItem( elements ) { const dataProp = 'toolbarItem'; @@ -169,7 +170,7 @@ function useToolbarFocus( { }; }, [ initialIndex, initialFocusOnMount, onIndexChange, toolbarRef ] ); - const { getLastFocus } = useSelect( blockEditorStore ); + const { getLastFocus } = unlock( useSelect( blockEditorStore ) ); /** * Handles returning focus to the block editor canvas when pressing escape. */ diff --git a/packages/block-editor/src/components/rich-text/native/index.native.js b/packages/block-editor/src/components/rich-text/native/index.native.js index f83d03ece47983..1f536011b35b6f 100644 --- a/packages/block-editor/src/components/rich-text/native/index.native.js +++ b/packages/block-editor/src/components/rich-text/native/index.native.js @@ -52,10 +52,6 @@ import { getFormatColors } from './get-format-colors'; import styles from './style.scss'; import ToolbarButtonWithOptions from './toolbar-button-with-options'; -const unescapeSpaces = ( text ) => { - return text.replace( / | /gi, ' ' ); -}; - // The flattened color palettes array is memoized to ensure that the same array instance is // returned for the colors palettes. This value might be used as a prop, so having the same // instance will prevent unnecessary re-renders of the RichText component. @@ -318,7 +314,7 @@ export class RichText extends Component { } const contentWithoutRootTag = this.removeRootTagsProducedByAztec( - unescapeSpaces( event.nativeEvent.text ) + event.nativeEvent.text ); // On iOS, onChange can be triggered after selection changes, even though there are no content changes. if ( contentWithoutRootTag === this.value.toString() ) { @@ -333,7 +329,7 @@ export class RichText extends Component { onTextUpdate( event ) { const contentWithoutRootTag = this.removeRootTagsProducedByAztec( - unescapeSpaces( event.nativeEvent.text ) + event.nativeEvent.text ); this.debounceCreateUndoLevel(); @@ -660,7 +656,7 @@ export class RichText extends Component { // Check and dicsard stray event, where the text and selection is equal to the ones already cached. const contentWithoutRootTag = this.removeRootTagsProducedByAztec( - unescapeSpaces( event.nativeEvent.text ) + event.nativeEvent.text ); if ( contentWithoutRootTag === this.value.toString() && diff --git a/packages/block-editor/src/components/rich-text/native/test/__snapshots__/index.native.js.snap b/packages/block-editor/src/components/rich-text/native/test/__snapshots__/index.native.js.snap index c9d3d62e40ce9d..84e9b467132714 100644 --- a/packages/block-editor/src/components/rich-text/native/test/__snapshots__/index.native.js.snap +++ b/packages/block-editor/src/components/rich-text/native/test/__snapshots__/index.native.js.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` Font Size renders component with style and font size 1`] = ` +exports[` when applying the font size renders component with style and font size 1`] = ` "

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed imperdiet ut nibh vitae ornare. Sed auctor nec augue at blandit.

" `; -exports[` Font Size should update the font size when style prop with font size property is provided 1`] = ` +exports[` when applying the font size should update the font size when style prop with font size property is provided 1`] = ` Font Size should update the font size when style prop with `; -exports[` Font Size should update the font size with decimals when style prop with font size property is provided 1`] = ` +exports[` when applying the font size should update the font size with decimals when style prop with font size property is provided 1`] = ` ', () => { } ); } ); - describe( 'when changes arrive from Aztec', () => { + describe( 'when the value changes', () => { it( 'should avoid updating attributes when values are equal', async () => { const handleChange = jest.fn(); - const defaultEmptyValue = new RichTextData(); - const screen = render( + const defaultEmptyValue = RichTextData.empty(); + render( ', () => { expect( handleChange ).not.toHaveBeenCalled(); } ); + + it( 'should preserve non-breaking space HTML entity', () => { + const onChange = jest.fn(); + const onSelectionChange = jest.fn(); + // The initial value is created using an HTML element to preserve + // the HTML entity. + const initialValue = RichTextData.fromHTMLElement( + __unstableCreateElement( document, ' ' ) + ); + render( + + ); + + // Trigger selection event with same text value as initial. + fireEvent( + screen.getByLabelText( /Text input/ ), + 'onSelectionChange', + 0, + 0, + initialValue.toString(), + { + nativeEvent: { + eventCount: 0, + target: undefined, + text: initialValue.toString(), + }, + } + ); + + expect( onChange ).not.toHaveBeenCalled(); + expect( onSelectionChange ).toHaveBeenCalled(); + } ); } ); - describe( 'Font Size', () => { + describe( 'when applying the font size', () => { it( 'should display rich text at the DEFAULT font size.', () => { // Arrange. const expectedFontSize = 16; @@ -259,7 +301,7 @@ describe( '', () => { const fontSize = '10'; const style = { fontSize: '12' }; // Act. - const screen = render( ); + render( ); screen.update( ); // Assert. expect( screen.toJSON() ).toMatchSnapshot(); @@ -281,7 +323,7 @@ describe( '', () => { const fontSize = '10'; const style = { fontSize: '12.56px' }; // Act. - const screen = render( ); + render( ); screen.update( ); // Assert. expect( screen.toJSON() ).toMatchSnapshot(); diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index b1fb1800a53ea2..bfc64dde071533 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -12,6 +12,7 @@ import { useRef } from '@wordpress/element'; */ import { store as blockEditorStore } from '../../store'; import { isInSameBlock, isInsideRootBlock } from '../../utils/dom'; +import { unlock } from '../../lock-unlock'; export default function useTabNav() { const container = useRef(); @@ -20,16 +21,15 @@ export default function useTabNav() { const { hasMultiSelection, getSelectedBlockClientId, getBlockCount } = useSelect( blockEditorStore ); - const { setNavigationMode, setLastFocus } = useDispatch( blockEditorStore ); + const { setNavigationMode, setLastFocus } = unlock( + useDispatch( blockEditorStore ) + ); const isNavigationMode = useSelect( ( select ) => select( blockEditorStore ).isNavigationMode(), [] ); - const lastFocus = useSelect( - ( select ) => select( blockEditorStore ).getLastFocus(), - [] - ); + const { getLastFocus } = unlock( useSelect( blockEditorStore ) ); // Don't allow tabbing to this element in Navigation mode. const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined; @@ -45,7 +45,7 @@ export default function useTabNav() { } else if ( hasMultiSelection() ) { container.current.focus(); } else if ( getSelectedBlockClientId() ) { - lastFocus.current.focus(); + getLastFocus()?.current.focus(); } else { setNavigationMode( true ); @@ -163,7 +163,7 @@ export default function useTabNav() { } function onFocusOut( event ) { - setLastFocus( { ...lastFocus, current: event.target } ); + setLastFocus( { ...getLastFocus(), current: event.target } ); const { ownerDocument } = node; diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js deleted file mode 100644 index 9b677933adc138..00000000000000 --- a/packages/block-editor/src/hooks/custom-fields.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * WordPress dependencies - */ -import { addFilter } from '@wordpress/hooks'; -import { PanelBody, TextControl } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; -import { hasBlockSupport } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { InspectorControls } from '../components'; -import { useBlockEditingMode } from '../components/block-editing-mode'; - -/** - * Filters registered block settings, extending attributes to include `connections`. - * - * @param {Object} settings Original block settings. - * - * @return {Object} Filtered block settings. - */ -function addAttribute( settings ) { - if ( hasBlockSupport( settings, '__experimentalConnections', true ) ) { - // Gracefully handle if settings.attributes.connections is undefined. - settings.attributes = { - ...settings.attributes, - connections: { - type: 'object', - }, - }; - } - - return settings; -} - -function CustomFieldsControlPure( { name, connections, setAttributes } ) { - const blockEditingMode = useBlockEditingMode(); - if ( blockEditingMode !== 'default' ) { - return null; - } - - // If the block is a paragraph or image block, we need to know which - // attribute to use for the connection. Only the `content` attribute - // of the paragraph block and the `url` attribute of the image block are supported. - let attributeName; - if ( name === 'core/paragraph' ) attributeName = 'content'; - if ( name === 'core/image' ) attributeName = 'url'; - - return ( - - - { - if ( nextValue === '' ) { - setAttributes( { - connections: undefined, - [ attributeName ]: undefined, - placeholder: undefined, - } ); - } else { - setAttributes( { - connections: { - attributes: { - // The attributeName will be either `content` or `url`. - [ attributeName ]: { - // Source will be variable, could be post_meta, user_meta, term_meta, etc. - // Could even be a custom source like a social media attribute. - source: 'meta_fields', - value: nextValue, - }, - }, - }, - [ attributeName ]: undefined, - placeholder: sprintf( - 'This content will be replaced on the frontend by the value of "%s" custom field.', - nextValue - ), - } ); - } - } } - /> - - - ); -} - -export default { - edit: CustomFieldsControlPure, - attributeKeys: [ 'connections' ], - hasSupport( name ) { - return ( - hasBlockSupport( name, '__experimentalConnections', false ) && - // Check if the current block is a paragraph or image block. - // Currently, only these two blocks are supported. - [ 'core/paragraph', 'core/image' ].includes( name ) - ); - }, -}; - -if ( - window.__experimentalConnections || - window.__experimentalPatternPartialSyncing -) { - addFilter( - 'blocks.registerBlockType', - 'core/editor/connections/attribute', - addAttribute - ); -} diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 385b9fe6b1511e..f17c0a22166e4e 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -25,7 +25,6 @@ import layout from './layout'; import childLayout from './layout-child'; import contentLockUI from './content-lock-ui'; import './metadata'; -import customFields from './custom-fields'; import blockHooks from './block-hooks'; import blockRenaming from './block-renaming'; @@ -39,7 +38,6 @@ createBlockEditFilter( position, layout, contentLockUI, - window.__experimentalConnections ? customFields : null, blockHooks, blockRenaming, ].filter( Boolean ) diff --git a/packages/block-editor/src/hooks/utils.js b/packages/block-editor/src/hooks/utils.js index 1c597836e9ec57..cd660c85826c28 100644 --- a/packages/block-editor/src/hooks/utils.js +++ b/packages/block-editor/src/hooks/utils.js @@ -177,7 +177,10 @@ export function useBlockSettings( name, parentLayout ) { backgroundImage, backgroundSize, fontFamilies, - fontSizes, + userFontSizes, + themeFontSizes, + defaultFontSizes, + defaultFontSizesEnabled, customFontSize, fontStyle, fontWeight, @@ -222,7 +225,10 @@ export function useBlockSettings( name, parentLayout ) { 'background.backgroundImage', 'background.backgroundSize', 'typography.fontFamilies', - 'typography.fontSizes', + 'typography.fontSizes.custom', + 'typography.fontSizes.theme', + 'typography.fontSizes.default', + 'typography.defaultFontSizes', 'typography.customFontSize', 'typography.fontStyle', 'typography.fontWeight', @@ -304,9 +310,12 @@ export function useBlockSettings( name, parentLayout ) { custom: fontFamilies, }, fontSizes: { - custom: fontSizes, + custom: userFontSizes, + theme: themeFontSizes, + default: defaultFontSizes, }, customFontSize, + defaultFontSizes: defaultFontSizesEnabled, fontStyle, fontWeight, lineHeight, @@ -341,7 +350,10 @@ export function useBlockSettings( name, parentLayout ) { backgroundImage, backgroundSize, fontFamilies, - fontSizes, + userFontSizes, + themeFontSizes, + defaultFontSizes, + defaultFontSizesEnabled, customFontSize, fontStyle, fontWeight, diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index b21436161cb8c3..4b396045a73c2a 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1679,13 +1679,13 @@ export function setBlockVisibility( updates ) { } /** - * Action that sets whether a block is being temporaritly edited as blocks. + * Action that sets whether a block is being temporarily edited as blocks. * * DO-NOT-USE in production. * This action is created for internal/experimental only usage and may be * removed anytime without any warning, causing breakage on any plugin or theme invoking it. * - * @param {?string} temporarilyEditingAsBlocks The block's clientId being temporaritly edited as blocks. + * @param {?string} temporarilyEditingAsBlocks The block's clientId being temporarily edited as blocks. */ export function __unstableSetTemporarilyEditingAsBlocks( temporarilyEditingAsBlocks @@ -1919,18 +1919,3 @@ export function unsetBlockEditingMode( clientId = '' ) { clientId, }; } - -/** - * Action that sets the element that had focus when focus leaves the editor canvas. - * - * @param {Object} lastFocus The last focused element. - * - * - * @return {Object} Action object. - */ -export function setLastFocus( lastFocus = null ) { - return { - type: 'LAST_FOCUS', - lastFocus, - }; -} diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index 48c5d15d469be4..43c392bc7ce8cf 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -323,3 +323,18 @@ export function syncDerivedUpdates( callback ) { } ); }; } + +/** + * Action that sets the element that had focus when focus leaves the editor canvas. + * + * @param {Object} lastFocus The last focused element. + * + * + * @return {Object} Action object. + */ +export function setLastFocus( lastFocus = null ) { + return { + type: 'LAST_FOCUS', + lastFocus, + }; +} diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 98a75122f47245..d31a710fd94fe3 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -281,3 +281,14 @@ export const hasAllowedPatterns = createSelector( ), ] ); + +/** + * Returns the element of the last element that had focus when focus left the editor canvas. + * + * @param {Object} state Block editor state. + * + * @return {Object} Element. + */ +export function getLastFocus( state ) { + return state.lastFocus; +} diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 94eebd32837a53..55d157c6927a2d 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2946,14 +2946,3 @@ export const isGroupable = createRegistrySelector( ); } ); - -/** - * Returns the element of the last element that had focus when focus left the editor canvas. - * - * @param {Object} state Block editor state. - * - * @return {Object} Element. - */ -export function getLastFocus( state ) { - return state.lastFocus; -} diff --git a/packages/block-editor/src/utils/object.js b/packages/block-editor/src/utils/object.js index 8f6c82a9c3991e..c78fe0e656dfef 100644 --- a/packages/block-editor/src/utils/object.js +++ b/packages/block-editor/src/utils/object.js @@ -49,3 +49,19 @@ export const getValueFromObjectPath = ( object, path, defaultValue ) => { } ); return value ?? defaultValue; }; + +/** + * Helper util to filter out objects with duplicate values for a given property. + * + * @param {Object[]} array Array of objects to filter. + * @param {string} property Property to filter unique values by. + * + * @return {Object[]} Array of objects with unique values for the specified property. + */ +export function uniqByProperty( array, property ) { + const seen = new Set(); + return array.filter( ( item ) => { + const value = item[ property ]; + return seen.has( value ) ? false : seen.add( value ); + } ); +} diff --git a/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap index dca3f782efc676..4cf28f7063ad31 100644 --- a/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap @@ -532,3 +532,15 @@ exports[`Audio block renders placeholder without crashing 1`] = ` `; + +exports[`Audio block should enable autoplay setting 1`] = ` +" +
+" +`; + +exports[`Audio block should enable loop setting 1`] = ` +" +
+" +`; diff --git a/packages/block-library/src/audio/test/edit.native.js b/packages/block-library/src/audio/test/edit.native.js index c191fd2fff7989..7296d595d7aaab 100644 --- a/packages/block-library/src/audio/test/edit.native.js +++ b/packages/block-library/src/audio/test/edit.native.js @@ -5,7 +5,10 @@ import { addBlock, dismissModal, fireEvent, + getBlock, + getEditorHtml, initializeEditor, + openBlockSettings, render, screen, setupCoreBlocks, @@ -31,6 +34,10 @@ jest.unmock( '@wordpress/react-native-aztec' ); const MEDIA_UPLOAD_STATE_FAILED = 3; +const AUDIO_BLOCK = ` +
+`; + let uploadCallBack; subscribeMediaUpload.mockImplementation( ( callback ) => { uploadCallBack = callback; @@ -100,4 +107,26 @@ describe( 'Audio block', () => { screen.getByText( 'Invalid URL. Audio file not found.' ) ).toBeVisible(); } ); + + it( 'should enable autoplay setting', async () => { + await initializeEditor( { initialHtml: AUDIO_BLOCK } ); + + const audioBlock = getBlock( screen, 'Audio' ); + fireEvent.press( audioBlock ); + await openBlockSettings( screen ); + + fireEvent.press( screen.getByText( 'Autoplay' ) ); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'should enable loop setting', async () => { + await initializeEditor( { initialHtml: AUDIO_BLOCK } ); + + const audioBlock = getBlock( screen, 'Audio' ); + fireEvent.press( audioBlock ); + await openBlockSettings( screen ); + + fireEvent.press( screen.getByText( 'Loop' ) ); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 607f073323d996..6331d33c27a7bb 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -27,7 +27,7 @@ import { store as blockEditorStore, BlockControls, } from '@wordpress/block-editor'; -import { getBlockSupport, parse, cloneBlock } from '@wordpress/blocks'; +import { parse, cloneBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -38,17 +38,17 @@ const { useLayoutClasses } = unlock( blockEditorPrivateApis ); function isPartiallySynced( block ) { return ( - !! getBlockSupport( block.name, '__experimentalConnections', false ) && - !! block.attributes.connections?.attributes && - Object.values( block.attributes.connections.attributes ).some( - ( connection ) => connection.source === 'pattern_attributes' + 'core/paragraph' === block.name && + !! block.attributes.metadata?.bindings && + Object.values( block.attributes.metadata.bindings ).some( + ( binding ) => binding.source.name === 'pattern_attributes' ) ); } function getPartiallySyncedAttributes( block ) { - return Object.entries( block.attributes.connections.attributes ) + return Object.entries( block.attributes.metadata.bindings ) .filter( - ( [ , connection ] ) => connection.source === 'pattern_attributes' + ( [ , binding ] ) => binding.source.name === 'pattern_attributes' ) .map( ( [ attributeKey ] ) => attributeKey ); } @@ -97,9 +97,12 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { const attributes = getPartiallySyncedAttributes( block ); const newAttributes = { ...block.attributes }; for ( const attributeKey of attributes ) { - defaultValues[ blockId ] = block.attributes[ attributeKey ]; + defaultValues[ blockId ] ??= {}; + defaultValues[ blockId ][ attributeKey ] = + block.attributes[ attributeKey ]; if ( overrides[ blockId ] ) { - newAttributes[ attributeKey ] = overrides[ blockId ]; + newAttributes[ attributeKey ] = + overrides[ blockId ][ attributeKey ]; } } return { @@ -111,7 +114,7 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { } function getOverridesFromBlocks( blocks, defaultValues ) { - /** @type {Record} */ + /** @type {Record>} */ const overrides = {}; for ( const block of blocks ) { Object.assign( @@ -123,9 +126,12 @@ function getOverridesFromBlocks( blocks, defaultValues ) { const attributes = getPartiallySyncedAttributes( block ); for ( const attributeKey of attributes ) { if ( - block.attributes[ attributeKey ] !== defaultValues[ blockId ] + block.attributes[ attributeKey ] !== + defaultValues[ blockId ][ attributeKey ] ) { - overrides[ blockId ] = block.attributes[ attributeKey ]; + overrides[ blockId ] ??= {}; + overrides[ blockId ][ attributeKey ] = + block.attributes[ attributeKey ]; } } } @@ -254,6 +260,7 @@ export default function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { + templateLock: 'all', layout, renderAppender: innerBlocks?.length ? undefined diff --git a/packages/block-library/src/button/style.scss b/packages/block-library/src/button/style.scss index d9efc928c5b1c7..f441152107973f 100644 --- a/packages/block-library/src/button/style.scss +++ b/packages/block-library/src/button/style.scss @@ -98,19 +98,19 @@ $blocks-block__margin: 0.5em; border-radius: 0 !important; } -.wp-block-button.is-style-outline > .wp-block-button__link, -.wp-block-button .wp-block-button__link.is-style-outline { +.wp-block-button:where(.is-style-outline) > .wp-block-button__link, +.wp-block-button .wp-block-button__link:where(.is-style-outline) { border: 2px solid currentColor; padding: 0.667em 1.333em; } -.wp-block-button.is-style-outline > .wp-block-button__link:not(.has-text-color), -.wp-block-button .wp-block-button__link.is-style-outline:not(.has-text-color) { +.wp-block-button:where(.is-style-outline) > .wp-block-button__link:not(.has-text-color), +.wp-block-button .wp-block-button__link:where(.is-style-outline):not(.has-text-color) { color: currentColor; } -.wp-block-button.is-style-outline > .wp-block-button__link:not(.has-background), -.wp-block-button .wp-block-button__link.is-style-outline:not(.has-background) { +.wp-block-button:where(.is-style-outline) > .wp-block-button__link:not(.has-background), +.wp-block-button .wp-block-button__link:where(.is-style-outline):not(.has-background) { background-color: transparent; // background-image is required to overwrite a gradient background background-image: none; diff --git a/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap index 1a55c807225d9d..f04eacee4b91c1 100644 --- a/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap @@ -6,6 +6,12 @@ exports[`Buttons block color customization sets a background color 1`] = ` " `; +exports[`Buttons block color customization sets a custom gradient background color 1`] = ` +" +
+" +`; + exports[`Buttons block color customization sets a gradient background color 1`] = ` "
diff --git a/packages/block-library/src/buttons/test/edit.native.js b/packages/block-library/src/buttons/test/edit.native.js index f393a31c7330ad..af2ffe762e6a36 100644 --- a/packages/block-library/src/buttons/test/edit.native.js +++ b/packages/block-library/src/buttons/test/edit.native.js @@ -10,6 +10,7 @@ import { initializeEditor, triggerBlockListLayout, typeInRichText, + openBlockSettings, waitFor, } from 'test/helpers'; @@ -391,5 +392,53 @@ describe( 'Buttons block', () => { // Assert expect( getEditorHtml() ).toMatchSnapshot(); } ); + + it( 'sets a custom gradient background color', async () => { + // Arrange + const screen = await initializeEditor(); + await addBlock( screen, 'Buttons' ); + + // Act + const buttonsBlock = getBlock( screen, 'Buttons' ); + fireEvent.press( buttonsBlock ); + + // Trigger onLayout for the list + await triggerBlockListLayout( buttonsBlock ); + + const buttonBlock = await getBlock( screen, 'Button' ); + fireEvent.press( buttonBlock ); + + // Open Block Settings. + await openBlockSettings( screen ); + + // Open Text color settings + fireEvent.press( screen.getByLabelText( 'Background, Default' ) ); + + // Tap on the gradient segment + fireEvent.press( screen.getByLabelText( 'Gradient' ) ); + + // Tap one gradient color + fireEvent.press( + screen.getByLabelText( 'Light green cyan to vivid green cyan' ) + ); + + // Tap on Customize Gradient + fireEvent.press( screen.getByLabelText( /Customize Gradient/ ) ); + + // Change the current angle + fireEvent.press( screen.getByText( '135', { hidden: true } ) ); + const angleTextInput = screen.getByDisplayValue( '135', { + hidden: true, + } ); + fireEvent.changeText( angleTextInput, '200' ); + + // Go back to the settings list. + fireEvent.press( await screen.findByLabelText( 'Go back' ) ); + + // Assert + const customButton = await screen.findByText( 'CUSTOM' ); + expect( customButton ).toBeVisible(); + expect( getEditorHtml() ).toMatchSnapshot(); + } ); } ); } ); diff --git a/packages/block-library/src/cover/edit.native.js b/packages/block-library/src/cover/edit.native.js index 81ff43128b1a35..989c5ec3a0d332 100644 --- a/packages/block-library/src/cover/edit.native.js +++ b/packages/block-library/src/cover/edit.native.js @@ -538,6 +538,7 @@ const Cover = ( { { ( { shouldEnableBottomSheetScroll } ) => (
diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 3fe4fbb34e1029..a81d754d8ca1be 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -41,7 +41,6 @@ "text": true } }, - "__experimentalConnections": true, "spacing": { "margin": true, "padding": true, diff --git a/packages/block-library/src/table/editor.scss b/packages/block-library/src/table/editor.scss index 0367ed0a9c5d95..55652742a5ae9d 100644 --- a/packages/block-library/src/table/editor.scss +++ b/packages/block-library/src/table/editor.scss @@ -1,7 +1,4 @@ .wp-block-table { - // Remove default
style. - margin: 0; - .wp-block[data-align="left"] > &, .wp-block[data-align="right"] > &, .wp-block[data-align="center"] > & { diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 8805736c2e4409..022248e3706cbf 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -23,7 +23,9 @@ ### Enhancements +- `ColorPicker`: improve the UX around HSL sliders ([#57555](https://github.com/WordPress/gutenberg/pull/57555)). - Update `ariakit` to version `0.3.10` ([#57325](https://github.com/WordPress/gutenberg/pull/57325)). +- Update `@ariakit/react` to version `0.3.12` and @ariakit/test to version `0.3.7` ([#57547](https://github.com/WordPress/gutenberg/pull/57547)). - `DropdownMenuV2`: do not collapse suffix width ([#57238](https://github.com/WordPress/gutenberg/pull/57238)). - `DateTimePicker`: Adjustment of the dot position on DayButton and expansion of the button area. ([#55502](https://github.com/WordPress/gutenberg/pull/55502)). - `Modal`: Improve application of body class names ([#55430](https://github.com/WordPress/gutenberg/pull/55430)). @@ -31,6 +33,8 @@ - `InputControl`, `NumberControl`, `UnitControl`, `SelectControl`, `TreeSelect`: Add `compact` size variant ([#57398](https://github.com/WordPress/gutenberg/pull/57398)). - `ToggleGroupControl`: Update button size in large variant to be 32px ([#57338](https://github.com/WordPress/gutenberg/pull/57338)). - `Tooltip`: improve unit tests ([#57345](https://github.com/WordPress/gutenberg/pull/57345)). +- `Tooltip`: no-op when nested inside other `Tooltip` components ([#57202](https://github.com/WordPress/gutenberg/pull/57202)). +- `PaletteEdit`: improve unit tests ([#57645](https://github.com/WordPress/gutenberg/pull/57645)). ### Experimental @@ -38,6 +42,7 @@ - `Tabs`: do not render hidden content ([#57046](https://github.com/WordPress/gutenberg/pull/57046)). - `Tabs`: improve hover and text alignment styles ([#57275](https://github.com/WordPress/gutenberg/pull/57275)). - `Tabs`: make sure `Tab`s are associated to the right `TabPanel`s, regardless of the order they're rendered in ([#57033](https://github.com/WordPress/gutenberg/pull/57033)). +- `BoxControl`: Update design ([#56665](https://github.com/WordPress/gutenberg/pull/56665)). ## 25.14.0 (2023-12-13) diff --git a/packages/components/package.json b/packages/components/package.json index 885c1e455fea40..cd440998b93230 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -30,7 +30,7 @@ ], "types": "build-types", "dependencies": { - "@ariakit/react": "^0.3.10", + "@ariakit/react": "^0.3.12", "@babel/runtime": "^7.16.0", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", diff --git a/packages/components/src/alignment-matrix-control/test/index.tsx b/packages/components/src/alignment-matrix-control/test/index.tsx index 6836bc7e45f95c..a820b69b26c8ff 100644 --- a/packages/components/src/alignment-matrix-control/test/index.tsx +++ b/packages/components/src/alignment-matrix-control/test/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { press, click } from '@ariakit/test'; /** * Internal dependencies @@ -37,11 +37,9 @@ describe( 'AlignmentMatrixControl', () => { } ); it( 'should be centered by default', async () => { - const user = userEvent.setup(); - await renderAndInitCompositeStore( ); - await user.tab(); + await press.Tab(); expect( getCell( 'center center' ) ).toHaveFocus(); } ); @@ -60,7 +58,6 @@ describe( 'AlignmentMatrixControl', () => { 'bottom center', 'bottom right', ] )( '%s', async ( alignment ) => { - const user = userEvent.setup(); const spy = jest.fn(); await renderAndInitCompositeStore( @@ -72,14 +69,13 @@ describe( 'AlignmentMatrixControl', () => { const cell = getCell( alignment ); - await user.click( cell ); + await click( cell ); expect( cell ).toHaveFocus(); expect( spy ).toHaveBeenCalledWith( alignment ); } ); it( 'unless already focused', async () => { - const user = userEvent.setup(); const spy = jest.fn(); await renderAndInitCompositeStore( @@ -91,7 +87,7 @@ describe( 'AlignmentMatrixControl', () => { const cell = getCell( 'center center' ); - await user.click( cell ); + await click( cell ); expect( cell ).toHaveFocus(); expect( spy ).not.toHaveBeenCalled(); @@ -106,16 +102,15 @@ describe( 'AlignmentMatrixControl', () => { [ 'ArrowLeft', 'center left' ], [ 'ArrowDown', 'bottom center' ], [ 'ArrowRight', 'center right' ], - ] )( '%s', async ( keyRef, cellRef ) => { - const user = userEvent.setup(); + ] as const )( '%s', async ( keyRef, cellRef ) => { const spy = jest.fn(); await renderAndInitCompositeStore( ); - await user.tab(); - await user.keyboard( `[${ keyRef }]` ); + await press.Tab(); + await press[ keyRef ](); expect( getCell( cellRef ) ).toHaveFocus(); expect( spy ).toHaveBeenCalledWith( cellRef ); @@ -128,8 +123,7 @@ describe( 'AlignmentMatrixControl', () => { [ 'ArrowLeft', 'top left' ], [ 'ArrowDown', 'bottom right' ], [ 'ArrowRight', 'bottom right' ], - ] )( '%s', async ( keyRef, cellRef ) => { - const user = userEvent.setup(); + ] as const )( '%s', async ( keyRef, cellRef ) => { const spy = jest.fn(); await renderAndInitCompositeStore( @@ -137,8 +131,8 @@ describe( 'AlignmentMatrixControl', () => { ); const cell = getCell( cellRef ); - await user.click( cell ); - await user.keyboard( `[${ keyRef }]` ); + await click( cell ); + await press[ keyRef ](); expect( cell ).toHaveFocus(); expect( spy ).toHaveBeenCalledWith( cellRef ); diff --git a/packages/components/src/box-control/all-input-control.tsx b/packages/components/src/box-control/all-input-control.tsx index b66e10fdb4ce3f..9c18694bbd0b60 100644 --- a/packages/components/src/box-control/all-input-control.tsx +++ b/packages/components/src/box-control/all-input-control.tsx @@ -1,15 +1,25 @@ +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ import type { UnitControlProps } from '../unit-control/types'; +import { + FlexedRangeControl, + StyledUnitControl, +} from './styles/box-control-styles'; +import { HStack } from '../h-stack'; import type { BoxControlInputControlProps } from './types'; -import UnitControl from './unit-control'; +import { parseQuantityAndUnitFromRawValue } from '../unit-control'; import { LABELS, applyValueToSides, getAllValue, isValuesMixed, isValuesDefined, + CUSTOM_VALUE_SETTINGS, } from './utils'; const noop = () => {}; @@ -17,26 +27,29 @@ const noop = () => {}; export default function AllInputControl( { onChange = noop, onFocus = noop, - onHoverOn = noop, - onHoverOff = noop, values, sides, selectedUnits, setSelectedUnits, ...props }: BoxControlInputControlProps ) { + const inputId = useInstanceId( AllInputControl, 'box-control-input-all' ); + const allValue = getAllValue( values, selectedUnits, sides ); const hasValues = isValuesDefined( values ); const isMixed = hasValues && isValuesMixed( values, selectedUnits, sides ); const allPlaceholder = isMixed ? LABELS.mixed : undefined; + const [ parsedQuantity, parsedUnit ] = + parseQuantityAndUnitFromRawValue( allValue ); + const handleOnFocus: React.FocusEventHandler< HTMLInputElement > = ( event ) => { onFocus( event, { side: 'all' } ); }; - const handleOnChange: UnitControlProps[ 'onChange' ] = ( next ) => { + const onValueChange = ( next?: string ) => { const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) ); const nextValue = isNumeric ? next : undefined; const nextValues = applyValueToSides( values, nextValue, sides ); @@ -44,6 +57,12 @@ export default function AllInputControl( { onChange( nextValues ); }; + const sliderOnChange = ( next?: number ) => { + onValueChange( + next !== undefined ? [ next, parsedUnit ].join( '' ) : undefined + ); + }; + // Set selected unit so it can be used as fallback by unlinked controls // when individual sides do not have a value containing a unit. const handleOnUnitChange: UnitControlProps[ 'onUnitChange' ] = ( unit ) => { @@ -51,36 +70,37 @@ export default function AllInputControl( { setSelectedUnits( newUnits ); }; - const handleOnHoverOn = () => { - onHoverOn( { - top: true, - bottom: true, - left: true, - right: true, - } ); - }; - - const handleOnHoverOff = () => { - onHoverOff( { - top: false, - bottom: false, - left: false, - right: false, - } ); - }; - return ( - + + + + + ); } diff --git a/packages/components/src/box-control/axial-input-controls.tsx b/packages/components/src/box-control/axial-input-controls.tsx index bc8a4bd420bbd2..173605f68a8721 100644 --- a/packages/components/src/box-control/axial-input-controls.tsx +++ b/packages/components/src/box-control/axial-input-controls.tsx @@ -1,10 +1,19 @@ +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; -import UnitControl from './unit-control'; -import { LABELS } from './utils'; -import { Layout } from './styles/box-control-styles'; +import Tooltip from '../tooltip'; +import { CUSTOM_VALUE_SETTINGS, LABELS } from './utils'; +import { + FlexedBoxControlIcon, + FlexedRangeControl, + InputWrapper, + StyledUnitControl, +} from './styles/box-control-styles'; import type { BoxControlInputControlProps } from './types'; const groupedSides = [ 'vertical', 'horizontal' ] as const; @@ -13,14 +22,17 @@ type GroupedSide = ( typeof groupedSides )[ number ]; export default function AxialInputControls( { onChange, onFocus, - onHoverOn, - onHoverOff, values, selectedUnits, setSelectedUnits, sides, ...props }: BoxControlInputControlProps ) { + const generatedId = useInstanceId( + AxialInputControls, + `box-control-input` + ); + const createHandleOnFocus = ( side: GroupedSide ) => ( event: React.FocusEvent< HTMLInputElement > ) => { @@ -30,43 +42,7 @@ export default function AxialInputControls( { onFocus( event, { side } ); }; - const createHandleOnHoverOn = ( side: GroupedSide ) => () => { - if ( ! onHoverOn ) { - return; - } - if ( side === 'vertical' ) { - onHoverOn( { - top: true, - bottom: true, - } ); - } - if ( side === 'horizontal' ) { - onHoverOn( { - left: true, - right: true, - } ); - } - }; - - const createHandleOnHoverOff = ( side: GroupedSide ) => () => { - if ( ! onHoverOff ) { - return; - } - if ( side === 'vertical' ) { - onHoverOff( { - top: false, - bottom: false, - } ); - } - if ( side === 'horizontal' ) { - onHoverOff( { - left: false, - right: false, - } ); - } - }; - - const createHandleOnChange = ( side: GroupedSide ) => ( next?: string ) => { + const handleOnValueChange = ( side: GroupedSide, next?: string ) => { if ( ! onChange ) { return; } @@ -109,16 +85,8 @@ export default function AxialInputControls( { ? groupedSides.filter( ( side ) => sides.includes( side ) ) : groupedSides; - const first = filteredSides[ 0 ]; - const last = filteredSides[ filteredSides.length - 1 ]; - const only = first === last && first; - return ( - + <> { filteredSides.map( ( side ) => { const [ parsedQuantity, parsedUnit ] = parseQuantityAndUnitFromRawValue( @@ -128,26 +96,65 @@ export default function AxialInputControls( { side === 'vertical' ? selectedUnits.top : selectedUnits.left; + + const inputId = [ generatedId, side ].join( '-' ); + return ( - + + + + + handleOnValueChange( side, newValue ) + } + onUnitChange={ createHandleOnUnitChange( + side + ) } + onFocus={ createHandleOnFocus( side ) } + label={ LABELS[ side ] } + hideLabelFromVision + key={ side } + /> + + + handleOnValueChange( + side, + newValue !== undefined + ? [ + newValue, + selectedUnit ?? parsedUnit, + ].join( '' ) + : undefined + ) + } + min={ 0 } + max={ + CUSTOM_VALUE_SETTINGS[ selectedUnit ?? 'px' ] + ?.max ?? 10 + } + step={ + CUSTOM_VALUE_SETTINGS[ selectedUnit ?? 'px' ] + ?.step ?? 0.1 + } + value={ parsedQuantity ?? 0 } + withInputField={ false } + /> + ); } ) } - + ); } diff --git a/packages/components/src/box-control/index.tsx b/packages/components/src/box-control/index.tsx index c7fcf066c545ce..dcc890e8e3c510 100644 --- a/packages/components/src/box-control/index.tsx +++ b/packages/components/src/box-control/index.tsx @@ -9,17 +9,16 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { BaseControl } from '../base-control'; -import Button from '../button'; -import { FlexItem, FlexBlock } from '../flex'; import AllInputControl from './all-input-control'; import InputControls from './input-controls'; import AxialInputControls from './axial-input-controls'; -import BoxControlIcon from './icon'; import LinkedButton from './linked-button'; +import { Grid } from '../grid'; import { - Root, - Header, - HeaderControlWrapper, + FlexedBoxControlIcon, + InputWrapper, + ResetButton, + LinkedButtonWrapper, } from './styles/box-control-styles'; import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; import { @@ -155,57 +154,49 @@ function BoxControl( { }; return ( - -
- - - { label } - - - { allowReset && ( - - - - ) } -
- - - - - { isLinked && ( - - - - ) } - { ! isLinked && splitOnAxis && ( - - - - ) } - { ! hasOneSide && ( - - - - ) } - + + + { label } + + { isLinked && ( + + + + + ) } + { ! hasOneSide && ( + + + + ) } + + { ! isLinked && splitOnAxis && ( + + ) } { ! isLinked && ! splitOnAxis && ( ) } -
+ { allowReset && ( + + { __( 'Reset' ) } + + ) } + ); } diff --git a/packages/components/src/box-control/input-controls.tsx b/packages/components/src/box-control/input-controls.tsx index f72179f0d18c10..c8aaeae222749c 100644 --- a/packages/components/src/box-control/input-controls.tsx +++ b/packages/components/src/box-control/input-controls.tsx @@ -1,82 +1,81 @@ +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ -import UnitControl from './unit-control'; +import Tooltip from '../tooltip'; import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; -import { ALL_SIDES, LABELS } from './utils'; -import { LayoutContainer, Layout } from './styles/box-control-styles'; +import { ALL_SIDES, CUSTOM_VALUE_SETTINGS, LABELS } from './utils'; +import { + FlexedBoxControlIcon, + FlexedRangeControl, + InputWrapper, + StyledUnitControl, +} from './styles/box-control-styles'; import type { BoxControlInputControlProps, BoxControlValue } from './types'; -import type { UnitControlProps } from '../unit-control/types'; const noop = () => {}; export default function BoxInputControls( { onChange = noop, onFocus = noop, - onHoverOn = noop, - onHoverOff = noop, values, selectedUnits, setSelectedUnits, sides, ...props }: BoxControlInputControlProps ) { + const generatedId = useInstanceId( BoxInputControls, 'box-control-input' ); + const createHandleOnFocus = ( side: keyof BoxControlValue ) => ( event: React.FocusEvent< HTMLInputElement > ) => { onFocus( event, { side } ); }; - const createHandleOnHoverOn = ( side: keyof BoxControlValue ) => () => { - onHoverOn( { [ side ]: true } ); - }; - - const createHandleOnHoverOff = ( side: keyof BoxControlValue ) => () => { - onHoverOff( { [ side ]: false } ); - }; - const handleOnChange = ( nextValues: BoxControlValue ) => { onChange( nextValues ); }; - const createHandleOnChange: ( - side: keyof BoxControlValue - ) => UnitControlProps[ 'onChange' ] = - ( side ) => - ( next, { event } ) => { - const nextValues = { ...values }; - const isNumeric = - next !== undefined && ! isNaN( parseFloat( next ) ); - const nextValue = isNumeric ? next : undefined; + const handleOnValueChange = ( + side: keyof BoxControlValue, + next?: string, + extra?: { event: React.SyntheticEvent< Element, Event > } + ) => { + const nextValues = { ...values }; + const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) ); + const nextValue = isNumeric ? next : undefined; - nextValues[ side ] = nextValue; + nextValues[ side ] = nextValue; - /** - * Supports changing pair sides. For example, holding the ALT key - * when changing the TOP will also update BOTTOM. - */ - // @ts-expect-error - TODO: event.altKey is only present when the change event was - // triggered by a keyboard event. Should this feature be implemented differently so - // it also works with drag events? - if ( event.altKey ) { - switch ( side ) { - case 'top': - nextValues.bottom = nextValue; - break; - case 'bottom': - nextValues.top = nextValue; - break; - case 'left': - nextValues.right = nextValue; - break; - case 'right': - nextValues.left = nextValue; - break; - } + /** + * Supports changing pair sides. For example, holding the ALT key + * when changing the TOP will also update BOTTOM. + */ + // @ts-expect-error - TODO: event.altKey is only present when the change event was + // triggered by a keyboard event. Should this feature be implemented differently so + // it also works with drag events? + if ( extra?.event.altKey ) { + switch ( side ) { + case 'top': + nextValues.bottom = nextValue; + break; + case 'bottom': + nextValues.top = nextValue; + break; + case 'left': + nextValues.right = nextValue; + break; + case 'right': + nextValues.left = nextValue; + break; } + } - handleOnChange( nextValues ); - }; + handleOnChange( nextValues ); + }; const createHandleOnUnitChange = ( side: keyof BoxControlValue ) => ( next?: string ) => { @@ -90,45 +89,74 @@ export default function BoxInputControls( { ? ALL_SIDES.filter( ( side ) => sides.includes( side ) ) : ALL_SIDES; - const first = filteredSides[ 0 ]; - const last = filteredSides[ filteredSides.length - 1 ]; - const only = first === last && first; - return ( - - - { filteredSides.map( ( side ) => { - const [ parsedQuantity, parsedUnit ] = - parseQuantityAndUnitFromRawValue( values[ side ] ); + <> + { filteredSides.map( ( side ) => { + const [ parsedQuantity, parsedUnit ] = + parseQuantityAndUnitFromRawValue( values[ side ] ); + + const computedUnit = values[ side ] + ? parsedUnit + : selectedUnits[ side ]; + + const inputId = [ generatedId, side ].join( '-' ); - const computedUnit = values[ side ] - ? parsedUnit - : selectedUnits[ side ]; + return ( + + + + + handleOnValueChange( + side, + nextValue, + extra + ) + } + onUnitChange={ createHandleOnUnitChange( + side + ) } + onFocus={ createHandleOnFocus( side ) } + label={ LABELS[ side ] } + hideLabelFromVision + /> + - return ( - { + handleOnValueChange( + side, + newValue !== undefined + ? [ newValue, computedUnit ].join( '' ) + : undefined + ); + } } + min={ 0 } + max={ + CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ] + ?.max ?? 10 + } + step={ + CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ] + ?.step ?? 0.1 + } + value={ parsedQuantity ?? 0 } + withInputField={ false } /> - ); - } ) } - - + + ); + } ) } + ); } diff --git a/packages/components/src/box-control/styles/box-control-styles.ts b/packages/components/src/box-control/styles/box-control-styles.ts index d961d4322ba5ac..ce2c8aa227e586 100644 --- a/packages/components/src/box-control/styles/box-control-styles.ts +++ b/packages/components/src/box-control/styles/box-control-styles.ts @@ -1,80 +1,40 @@ /** * External dependencies */ -import { css } from '@emotion/react'; import styled from '@emotion/styled'; /** * Internal dependencies */ -import { Flex } from '../../flex'; -import BaseUnitControl from '../../unit-control'; -import { rtl } from '../../utils'; -import type { BoxUnitControlProps } from '../types'; - -export const Root = styled.div` - box-sizing: border-box; - max-width: 235px; - padding-bottom: 12px; - width: 100%; +import BoxControlIcon from '../icon'; +import Button from '../../button'; +import { HStack } from '../../h-stack'; +import RangeControl from '../../range-control'; +import UnitControl from '../../unit-control'; +import { space } from '../../utils/space'; + +export const StyledUnitControl = styled( UnitControl )` + max-width: 90px; `; -export const Header = styled( Flex )` - margin-bottom: 8px; +export const InputWrapper = styled( HStack )` + grid-column: 1 / span 3; `; -export const HeaderControlWrapper = styled( Flex )` - min-height: 30px; - gap: 0; +export const ResetButton = styled( Button )` + grid-area: 1 / 2; + justify-self: end; `; -export const UnitControlWrapper = styled.div` - box-sizing: border-box; - max-width: 80px; +export const LinkedButtonWrapper = styled.div` + grid-area: 1 / 3; + justify-self: end; `; -export const LayoutContainer = styled( Flex )` - justify-content: center; - padding-top: 8px; +export const FlexedBoxControlIcon = styled( BoxControlIcon )` + flex: 0 0 auto; `; -export const Layout = styled( Flex )` - position: relative; - height: 100%; +export const FlexedRangeControl = styled( RangeControl )` width: 100%; - justify-content: flex-start; -`; - -const unitControlBorderRadiusStyles = ( { - isFirst, - isLast, - isOnly, -}: Pick< BoxUnitControlProps, 'isFirst' | 'isLast' | 'isOnly' > ) => { - if ( isFirst ) { - return rtl( { borderTopRightRadius: 0, borderBottomRightRadius: 0 } )(); - } - if ( isLast ) { - return rtl( { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 } )(); - } - if ( isOnly ) { - return css( { borderRadius: 2 } ); - } - - return css( { - borderRadius: 0, - } ); -}; - -const unitControlMarginStyles = ( { - isFirst, - isOnly, -}: Pick< BoxUnitControlProps, 'isFirst' | 'isOnly' > ) => { - const marginLeft = isFirst || isOnly ? 0 : -1; - - return rtl( { marginLeft } )(); -}; - -export const UnitControl = styled( BaseUnitControl )` - max-width: 60px; - ${ unitControlBorderRadiusStyles }; - ${ unitControlMarginStyles }; + margin-inline-end: ${ space( 2 ) }; `; diff --git a/packages/components/src/box-control/styles/box-control-visualizer-styles.ts b/packages/components/src/box-control/styles/box-control-visualizer-styles.ts deleted file mode 100644 index bbfe66c9a71e98..00000000000000 --- a/packages/components/src/box-control/styles/box-control-visualizer-styles.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * External dependencies - */ -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; - -/** - * Internal dependencies - */ -import { COLORS, rtl } from '../../utils'; - -const containerPositionStyles = ( { - isPositionAbsolute, -}: { - isPositionAbsolute: boolean; -} ) => { - if ( ! isPositionAbsolute ) return ''; - - return css` - bottom: 0; - left: 0; - pointer-events: none; - position: absolute; - right: 0; - top: 0; - z-index: 1; - `; -}; - -export const Container = styled.div` - box-sizing: border-box; - position: relative; - ${ containerPositionStyles }; -`; - -export const Side = styled.div` - box-sizing: border-box; - background: ${ COLORS.theme.accent }; - filter: brightness( 1 ); - opacity: 0; - position: absolute; - pointer-events: none; - transition: opacity 120ms linear; - z-index: 1; - - ${ ( { isActive }: { isActive: boolean } ) => - isActive && - ` - opacity: 0.3; - ` } -`; - -export const TopView = styled( Side )` - top: 0; - left: 0; - right: 0; -`; - -export const RightView = styled( Side )` - top: 0; - bottom: 0; - ${ rtl( { right: 0 } ) }; -`; - -export const BottomView = styled( Side )` - bottom: 0; - left: 0; - right: 0; -`; - -export const LeftView = styled( Side )` - top: 0; - bottom: 0; - ${ rtl( { left: 0 } ) }; -`; diff --git a/packages/components/src/box-control/test/index.tsx b/packages/components/src/box-control/test/index.tsx index 8a861eff37e1b2..1ea3c84aae9225 100644 --- a/packages/components/src/box-control/test/index.tsx +++ b/packages/components/src/box-control/test/index.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; /** @@ -33,7 +33,10 @@ describe( 'BoxControl', () => { render( {} } /> ); expect( - screen.getByRole( 'textbox', { name: 'Box Control' } ) + screen.getByRole( 'group', { name: 'Box Control' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'textbox', { name: 'All sides' } ) ).toBeVisible(); } ); @@ -42,15 +45,41 @@ describe( 'BoxControl', () => { render( {} } /> ); - const input = screen.getByRole( 'textbox', { - name: 'Box Control', - } ); + const input = screen.getByRole( 'textbox', { name: 'All sides' } ); await user.type( input, '100' ); await user.keyboard( '{Enter}' ); expect( input ).toHaveValue( '100' ); } ); + + it( 'should update input values when interacting with slider', () => { + render( {} } /> ); + + const slider = screen.getByRole( 'slider' ); + + fireEvent.change( slider, { target: { value: 50 } } ); + + expect( slider ).toHaveValue( '50' ); + expect( + screen.getByRole( 'textbox', { name: 'All sides' } ) + ).toHaveValue( '50' ); + } ); + + it( 'should update slider values when interacting with input', async () => { + const user = userEvent.setup(); + render( {} } /> ); + + const input = screen.getByRole( 'textbox', { + name: 'All sides', + } ); + + await user.type( input, '50' ); + await user.keyboard( '{Enter}' ); + + expect( input ).toHaveValue( '50' ); + expect( screen.getByRole( 'slider' ) ).toHaveValue( '50' ); + } ); } ); describe( 'Reset', () => { @@ -60,7 +89,7 @@ describe( 'BoxControl', () => { render( {} } /> ); const input = screen.getByRole( 'textbox', { - name: 'Box Control', + name: 'All sides', } ); await user.type( input, '100' ); @@ -79,7 +108,7 @@ describe( 'BoxControl', () => { render( ); const input = screen.getByRole( 'textbox', { - name: 'Box Control', + name: 'All sides', } ); await user.type( input, '100' ); @@ -98,7 +127,7 @@ describe( 'BoxControl', () => { render( ); const input = screen.getByRole( 'textbox', { - name: 'Box Control', + name: 'All sides', } ); await user.type( input, '100' ); @@ -118,7 +147,7 @@ describe( 'BoxControl', () => { render( spyChange( v ) } /> ); const input = screen.getByRole( 'textbox', { - name: 'Box Control', + name: 'All sides', } ); await user.type( input, '100' ); @@ -152,21 +181,49 @@ describe( 'BoxControl', () => { ); await user.type( - screen.getByRole( 'textbox', { name: 'Top' } ), + screen.getByRole( 'textbox', { name: 'Top side' } ), '100' ); expect( - screen.getByRole( 'textbox', { name: 'Top' } ) + screen.getByRole( 'textbox', { name: 'Top side' } ) ).toHaveValue( '100' ); expect( - screen.getByRole( 'textbox', { name: 'Right' } ) + screen.getByRole( 'textbox', { name: 'Right side' } ) ).not.toHaveValue(); expect( - screen.getByRole( 'textbox', { name: 'Bottom' } ) + screen.getByRole( 'textbox', { name: 'Bottom side' } ) ).not.toHaveValue(); expect( - screen.getByRole( 'textbox', { name: 'Left' } ) + screen.getByRole( 'textbox', { name: 'Left side' } ) + ).not.toHaveValue(); + } ); + + it( 'should update a single side value when using slider unlinked', async () => { + const user = userEvent.setup(); + + render( ); + + await user.click( + screen.getByRole( 'button', { name: 'Unlink sides' } ) + ); + + const slider = screen.getByRole( 'slider', { name: 'Right side' } ); + + fireEvent.change( slider, { target: { value: 50 } } ); + + expect( slider ).toHaveValue( '50' ); + expect( + screen.getByRole( 'textbox', { name: 'Top side' } ) + ).not.toHaveValue(); + expect( + screen.getByRole( 'textbox', { name: 'Right side' } ) + ).toHaveValue( '50' ); + expect( + screen.getByRole( 'textbox', { name: 'Bottom side' } ) + ).not.toHaveValue(); + expect( + screen.getByRole( 'textbox', { name: 'Left side' } ) ).not.toHaveValue(); } ); @@ -181,17 +238,68 @@ describe( 'BoxControl', () => { await user.type( screen.getByRole( 'textbox', { - name: 'Vertical', + name: 'Top and bottom sides', } ), '100' ); expect( - screen.getByRole( 'textbox', { name: 'Vertical' } ) + screen.getByRole( 'textbox', { name: 'Top and bottom sides' } ) ).toHaveValue( '100' ); expect( - screen.getByRole( 'textbox', { name: 'Horizontal' } ) + screen.getByRole( 'textbox', { name: 'Left and right sides' } ) + ).not.toHaveValue(); + } ); + + it( 'should update a whole axis using a slider when value is changed when unlinked', async () => { + const user = userEvent.setup(); + + render( ); + + await user.click( + screen.getByRole( 'button', { name: 'Unlink sides' } ) + ); + + const slider = screen.getByRole( 'slider', { + name: 'Left and right sides', + } ); + + fireEvent.change( slider, { target: { value: 50 } } ); + + expect( slider ).toHaveValue( '50' ); + expect( + screen.getByRole( 'textbox', { name: 'Top and bottom sides' } ) ).not.toHaveValue(); + expect( + screen.getByRole( 'textbox', { name: 'Left and right sides' } ) + ).toHaveValue( '50' ); + } ); + + it( 'should show "Mixed" label when sides have different values but are linked', async () => { + const user = userEvent.setup(); + + render( ); + + const unlinkButton = screen.getByRole( 'button', { + name: 'Unlink sides', + } ); + + await user.click( unlinkButton ); + + await user.type( + screen.getByRole( 'textbox', { + name: 'Right side', + } ), + '13' + ); + + expect( + screen.getByRole( 'textbox', { name: 'Right side' } ) + ).toHaveValue( '13' ); + + await user.click( unlinkButton ); + + expect( screen.getByPlaceholderText( 'Mixed' ) ).toHaveValue( '' ); } ); } ); @@ -287,7 +395,7 @@ describe( 'BoxControl', () => { render( ); const valueInput = screen.getByRole( 'textbox', { - name: 'Box Control', + name: 'All sides', } ); const unitSelect = screen.getByRole( 'combobox', { name: 'Select unit', diff --git a/packages/components/src/box-control/types.ts b/packages/components/src/box-control/types.ts index 0eba0e58fd33cc..12524559564ab7 100644 --- a/packages/components/src/box-control/types.ts +++ b/packages/components/src/box-control/types.ts @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import type { useHover } from '@use-gesture/react'; - /** * Internal dependencies */ @@ -16,6 +11,10 @@ export type BoxControlValue = { left?: string; }; +export type CustomValueUnits = { + [ key: string ]: { max: number; step: number }; +}; + type UnitControlPassthroughProps = Omit< UnitControlProps, 'label' | 'onChange' | 'onFocus' | 'onMouseOver' | 'onMouseOut' | 'units' @@ -92,22 +91,6 @@ export type BoxControlInputControlProps = UnitControlPassthroughProps & { values: BoxControlValue; }; -export type BoxUnitControlProps = UnitControlPassthroughProps & - Pick< UnitControlProps, 'onChange' | 'onFocus' > & { - isFirst?: boolean; - isLast?: boolean; - isOnly?: boolean; - label?: string; - onHoverOff?: ( - event: ReturnType< typeof useHover >[ 'event' ], - state: Omit< ReturnType< typeof useHover >, 'event' > - ) => void; - onHoverOn?: ( - event: ReturnType< typeof useHover >[ 'event' ], - state: Omit< ReturnType< typeof useHover >, 'event' > - ) => void; - }; - export type BoxControlIconProps = { /** * @default 24 diff --git a/packages/components/src/box-control/unit-control.tsx b/packages/components/src/box-control/unit-control.tsx deleted file mode 100644 index 24d71cf0d6cd34..00000000000000 --- a/packages/components/src/box-control/unit-control.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * External dependencies - */ -import { useHover } from '@use-gesture/react'; - -/** - * Internal dependencies - */ -import BaseTooltip from '../tooltip'; -import { UnitControlWrapper, UnitControl } from './styles/box-control-styles'; -import type { BoxUnitControlProps } from './types'; - -const noop = () => {}; - -export default function BoxUnitControl( { - isFirst, - isLast, - isOnly, - onHoverOn = noop, - onHoverOff = noop, - label, - value, - ...props -}: BoxUnitControlProps ) { - const bindHoverGesture = useHover( ( { event, ...state } ) => { - if ( state.hovering ) { - onHoverOn( event, state ); - } else { - onHoverOff( event, state ); - } - } ); - - return ( - - - - - - ); -} - -function Tooltip( { - children, - text, -}: { - children: JSX.Element; - text?: string; -} ) { - if ( ! text ) return children; - - /** - * Wrapping the children in a `
` as Tooltip as it attempts - * to render the . Using a plain `
` appears to - * resolve this issue. - * - * Originally discovered and referenced here: - * https://github.com/WordPress/gutenberg/pull/24966#issuecomment-685875026 - */ - return ( - -
{ children }
-
- ); -} diff --git a/packages/components/src/box-control/utils.ts b/packages/components/src/box-control/utils.ts index 6614342d3da6d3..e480c9a9f4674a 100644 --- a/packages/components/src/box-control/utils.ts +++ b/packages/components/src/box-control/utils.ts @@ -7,17 +7,52 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; -import type { BoxControlProps, BoxControlValue } from './types'; +import type { + BoxControlProps, + BoxControlValue, + CustomValueUnits, +} from './types'; + +export const CUSTOM_VALUE_SETTINGS: CustomValueUnits = { + px: { max: 300, step: 1 }, + '%': { max: 100, step: 1 }, + vw: { max: 100, step: 1 }, + vh: { max: 100, step: 1 }, + em: { max: 10, step: 0.1 }, + rm: { max: 10, step: 0.1 }, + svw: { max: 100, step: 1 }, + lvw: { max: 100, step: 1 }, + dvw: { max: 100, step: 1 }, + svh: { max: 100, step: 1 }, + lvh: { max: 100, step: 1 }, + dvh: { max: 100, step: 1 }, + vi: { max: 100, step: 1 }, + svi: { max: 100, step: 1 }, + lvi: { max: 100, step: 1 }, + dvi: { max: 100, step: 1 }, + vb: { max: 100, step: 1 }, + svb: { max: 100, step: 1 }, + lvb: { max: 100, step: 1 }, + dvb: { max: 100, step: 1 }, + vmin: { max: 100, step: 1 }, + svmin: { max: 100, step: 1 }, + lvmin: { max: 100, step: 1 }, + dvmin: { max: 100, step: 1 }, + vmax: { max: 100, step: 1 }, + svmax: { max: 100, step: 1 }, + lvmax: { max: 100, step: 1 }, + dvmax: { max: 100, step: 1 }, +}; export const LABELS = { - all: __( 'All' ), - top: __( 'Top' ), - bottom: __( 'Bottom' ), - left: __( 'Left' ), - right: __( 'Right' ), + all: __( 'All sides' ), + top: __( 'Top side' ), + bottom: __( 'Bottom side' ), + left: __( 'Left side' ), + right: __( 'Right side' ), mixed: __( 'Mixed' ), - vertical: __( 'Vertical' ), - horizontal: __( 'Horizontal' ), + vertical: __( 'Top and bottom sides' ), + horizontal: __( 'Left and right sides' ), }; export const DEFAULT_VALUES = { diff --git a/packages/components/src/color-palette/index.native.js b/packages/components/src/color-palette/index.native.js index 51a61785df9afe..a3d4175b31ac92 100644 --- a/packages/components/src/color-palette/index.native.js +++ b/packages/components/src/color-palette/index.native.js @@ -33,7 +33,7 @@ let scrollPosition = 0; let customIndicatorWidth = 0; function ColorPalette( { - enableCustomColor = true, + enableCustomColor = false, setColor, activeColor, isGradientColor, @@ -62,24 +62,35 @@ function ColorPalette( { const scale = useRef( new Animated.Value( 1 ) ).current; const opacity = useRef( new Animated.Value( 1 ) ).current; - const defaultColors = [ + const mergedColors = [ ...new Set( ( defaultSettings.colors ?? [] ).map( ( { color } ) => color ) ), ]; - const mergedColors = [ + const mergedGradients = [ + ...new Set( + ( defaultSettings.gradients ?? [] ).map( + ( { gradient } ) => gradient + ) + ), + ]; + const allAvailableColors = [ ...new Set( ( defaultSettings.allColors ?? [] ).map( ( { color } ) => color ) ), ]; - const defaultGradientColors = [ + const allAvailableGradients = [ ...new Set( - ( defaultSettings.gradients ?? [] ).map( + ( defaultSettings.allGradients ?? [] ).map( ( { gradient } ) => gradient ) ), ]; - const colors = isGradientSegment ? defaultGradientColors : defaultColors; + + const colors = isGradientSegment ? mergedGradients : mergedColors; + const allColors = isGradientSegment + ? allAvailableGradients + : allAvailableColors; const customIndicatorColor = isGradientSegment ? activeColor @@ -110,7 +121,7 @@ function ColorPalette( { function isSelectedCustom() { const isWithinColors = - activeColor && mergedColors && mergedColors.includes( activeColor ); + activeColor && allColors?.includes( activeColor ); if ( enableCustomColor && activeColor ) { if ( isGradientSegment ) { return isGradientColor && ! isWithinColors; diff --git a/packages/components/src/color-picker/hsl-input.tsx b/packages/components/src/color-picker/hsl-input.tsx index 3331a97b3d4de6..8d2b0c7c444899 100644 --- a/packages/components/src/color-picker/hsl-input.tsx +++ b/packages/components/src/color-picker/hsl-input.tsx @@ -3,6 +3,11 @@ */ import { colord } from 'colord'; +/** + * WordPress dependencies + */ +import { useState, useEffect, useMemo } from '@wordpress/element'; + /** * Internal dependencies */ @@ -10,7 +15,49 @@ import { InputWithSlider } from './input-with-slider'; import type { HslInputProps } from './types'; export const HslInput = ( { color, onChange, enableAlpha }: HslInputProps ) => { - const { h, s, l, a } = color.toHsl(); + const colorPropHSLA = useMemo( () => color.toHsl(), [ color ] ); + + const [ internalHSLA, setInternalHSLA ] = useState( { ...colorPropHSLA } ); + + const isInternalColorSameAsReceivedColor = color.isEqual( + colord( internalHSLA ) + ); + + useEffect( () => { + if ( ! isInternalColorSameAsReceivedColor ) { + // Keep internal HSLA color up to date with the received color prop + setInternalHSLA( colorPropHSLA ); + } + }, [ colorPropHSLA, isInternalColorSameAsReceivedColor ] ); + + // If the internal color is equal to the received color prop, we can use the + // HSLA values from the local state which, compared to the received color prop, + // retain more details about the actual H and S values that the user selected, + // and thus allow for better UX when interacting with the H and S sliders. + const colorValue = isInternalColorSameAsReceivedColor + ? internalHSLA + : colorPropHSLA; + + const updateHSLAValue = ( + partialNewValue: Partial< typeof colorPropHSLA > + ) => { + const nextOnChangeValue = colord( { + ...colorValue, + ...partialNewValue, + } ); + + // Fire `onChange` only if the resulting color is different from the + // current one. + // Otherwise, update the internal HSLA color to cause a re-render. + if ( ! color.isEqual( nextOnChangeValue ) ) { + onChange( nextOnChangeValue ); + } else { + setInternalHSLA( ( prevHSLA ) => ( { + ...prevHSLA, + ...partialNewValue, + } ) ); + } + }; return ( <> @@ -19,9 +66,9 @@ export const HslInput = ( { color, onChange, enableAlpha }: HslInputProps ) => { max={ 359 } label="Hue" abbreviation="H" - value={ h } + value={ colorValue.h } onChange={ ( nextH: number ) => { - onChange( colord( { h: nextH, s, l, a } ) ); + updateHSLAValue( { h: nextH } ); } } /> { max={ 100 } label="Saturation" abbreviation="S" - value={ s } + value={ colorValue.s } onChange={ ( nextS: number ) => { - onChange( - colord( { - h, - s: nextS, - l, - a, - } ) - ); + updateHSLAValue( { s: nextS } ); } } /> { max={ 100 } label="Lightness" abbreviation="L" - value={ l } + value={ colorValue.l } onChange={ ( nextL: number ) => { - onChange( - colord( { - h, - s, - l: nextL, - a, - } ) - ); + updateHSLAValue( { l: nextL } ); } } /> { enableAlpha && ( @@ -64,16 +97,9 @@ export const HslInput = ( { color, onChange, enableAlpha }: HslInputProps ) => { max={ 100 } label="Alpha" abbreviation="A" - value={ Math.trunc( 100 * a ) } + value={ Math.trunc( 100 * colorValue.a ) } onChange={ ( nextA: number ) => { - onChange( - colord( { - h, - s, - l, - a: nextA / 100, - } ) - ); + updateHSLAValue( { a: nextA / 100 } ); } } /> ) } diff --git a/packages/components/src/color-picker/test/index.tsx b/packages/components/src/color-picker/test/index.tsx index 8d584d626487a4..98e059d5994ded 100644 --- a/packages/components/src/color-picker/test/index.tsx +++ b/packages/components/src/color-picker/test/index.tsx @@ -1,13 +1,19 @@ /** * External dependencies */ -import { screen, render } from '@testing-library/react'; +import { fireEvent, screen, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + /** * Internal dependencies */ import { ColorPicker } from '..'; +import { click } from '@ariakit/test'; const hslaMatcher = expect.objectContaining( { h: expect.any( Number ), @@ -133,20 +139,39 @@ describe( 'ColorPicker', () => { } ); } ); - describe.each( [ - [ 'hue', 'Hue', '#aad52a' ], - [ 'saturation', 'Saturation', '#20dfdf' ], - [ 'lightness', 'Lightness', '#95eaea' ], - ] )( 'HSL inputs', ( colorInput, inputLabel, expected ) => { - it( `should fire onChange with the correct value when the ${ colorInput } value is updated`, async () => { + describe( 'HSL inputs', () => { + it( 'sliders should use accurate H and S values based on user interaction when possible', async () => { const user = userEvent.setup(); const onChange = jest.fn(); - const color = '#2ad5d5'; + + const ControlledColorPicker = ( { + onChange: onChangeProp, + ...restProps + }: React.ComponentProps< typeof ColorPicker > ) => { + const [ colorState, setColorState ] = useState( '#000000' ); + + const internalOnChange: typeof onChangeProp = ( newColor ) => { + onChangeProp?.( newColor ); + setColorState( newColor ); + }; + + return ( + <> + + + + ); + }; render( - ); @@ -156,16 +181,165 @@ describe( 'ColorPicker', () => { await user.selectOptions( formatSelector, 'hsl' ); - const inputElement = screen.getByRole( 'spinbutton', { - name: inputLabel, + const hueSliders = screen.getAllByRole( 'slider', { + name: 'Hue', } ); - expect( inputElement ).toBeVisible(); + expect( hueSliders ).toHaveLength( 2 ); - await user.clear( inputElement ); - await user.type( inputElement, '75' ); + // Reason for the `!` post-fix expression operator: if the check above + // doesn't fail, we're guaranteed that `hueSlider` is not undefined. + const hueSlider = hueSliders.at( -1 )!; + const saturationSlider = screen.getByRole( 'slider', { + name: 'Saturation', + } ); + const lightnessSlider = screen.getByRole( 'slider', { + name: 'Lightness', + } ); + const hueNumberInput = screen.getByRole( 'spinbutton', { + name: 'Hue', + } ); + const saturationNumberInput = screen.getByRole( 'spinbutton', { + name: 'Saturation', + } ); + const lightnessNumberInput = screen.getByRole( 'spinbutton', { + name: 'Lightness', + } ); + + // All initial inputs should have a value of `0` since the color is black. + expect( hueSlider ).toHaveValue( '0' ); + expect( saturationSlider ).toHaveValue( '0' ); + expect( lightnessSlider ).toHaveValue( '0' ); + expect( hueNumberInput ).toHaveValue( 0 ); + expect( saturationNumberInput ).toHaveValue( 0 ); + expect( lightnessNumberInput ).toHaveValue( 0 ); + + // Interact with the Hue slider, it should change its value (and the + // value of the associated number input), but it shouldn't cause the + // `onChange` callback to fire, since the resulting color is still black. + fireEvent.change( hueSlider, { target: { value: 80 } } ); + + expect( hueSlider ).toHaveValue( '80' ); + expect( hueNumberInput ).toHaveValue( 80 ); + expect( onChange ).not.toHaveBeenCalled(); + + // Interact with the Saturation slider, it should change its value (and the + // value of the associated number input), but it shouldn't cause the + // `onChange` callback to fire, since the resulting color is still black. + fireEvent.change( saturationSlider, { target: { value: 50 } } ); + + expect( saturationSlider ).toHaveValue( '50' ); + expect( saturationNumberInput ).toHaveValue( 50 ); + expect( onChange ).not.toHaveBeenCalled(); + + // Interact with the Lightness slider, it should change its value (and the + // value of the associated number input). It should also cause the + // `onChange` callback to fire, since changing the lightness actually + // causes the color to change. + fireEvent.change( lightnessSlider, { target: { value: 30 } } ); + await waitFor( () => + expect( lightnessSlider ).toHaveValue( '30' ) + ); + expect( lightnessNumberInput ).toHaveValue( 30 ); + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenLastCalledWith( '#597326' ); + + // Interact with the Lightness slider, setting to 100 (ie. white). + // It should also cause the `onChange` callback to fire, and reset the + // hue and saturation inputs to `0`. + fireEvent.change( lightnessSlider, { target: { value: 100 } } ); + + await waitFor( () => + expect( lightnessSlider ).toHaveValue( '100' ) + ); + expect( lightnessNumberInput ).toHaveValue( 100 ); + expect( hueSlider ).toHaveValue( '0' ); + expect( saturationSlider ).toHaveValue( '0' ); + expect( hueNumberInput ).toHaveValue( 0 ); + expect( saturationNumberInput ).toHaveValue( 0 ); + expect( onChange ).toHaveBeenCalledTimes( 2 ); + expect( onChange ).toHaveBeenLastCalledWith( '#ffffff' ); + + // Interact with the Hue slider, it should change its value (and the + // value of the associated number input), but it shouldn't cause the + // `onChange` callback to fire, since the resulting color is still white. + fireEvent.change( hueSlider, { target: { value: 147 } } ); + + expect( hueSlider ).toHaveValue( '147' ); + expect( hueNumberInput ).toHaveValue( 147 ); + expect( onChange ).toHaveBeenCalledTimes( 2 ); + + // Interact with the Saturation slider, it should change its value (and the + // value of the associated number input), but it shouldn't cause the + // `onChange` callback to fire, since the resulting color is still white. + fireEvent.change( saturationSlider, { target: { value: 82 } } ); + + expect( saturationSlider ).toHaveValue( '82' ); + expect( saturationNumberInput ).toHaveValue( 82 ); + expect( onChange ).toHaveBeenCalledTimes( 2 ); + + // Interact with the Lightness slider, it should change its value (and the + // value of the associated number input). It should also cause the + // `onChange` callback to fire, since changing the lightness actually + // causes the color to change. + fireEvent.change( lightnessSlider, { target: { value: 14 } } ); + + await waitFor( () => + expect( lightnessSlider ).toHaveValue( '14' ) + ); + expect( lightnessNumberInput ).toHaveValue( 14 ); expect( onChange ).toHaveBeenCalledTimes( 3 ); - expect( onChange ).toHaveBeenLastCalledWith( expected ); + expect( onChange ).toHaveBeenLastCalledWith( '#064121' ); + + // Set the color externally. All inputs should update to match the H/S/L + // value of the new color. + const setColorButton = screen.getByRole( 'button', { + name: /set color/i, + } ); + await click( setColorButton ); + + expect( hueSlider ).toHaveValue( '208' ); + expect( hueNumberInput ).toHaveValue( 208 ); + expect( saturationSlider ).toHaveValue( '44' ); + expect( saturationNumberInput ).toHaveValue( 44 ); + expect( lightnessSlider ).toHaveValue( '52' ); + expect( lightnessNumberInput ).toHaveValue( 52 ); + } ); + + describe.each( [ + [ 'hue', 'Hue', '#aad52a' ], + [ 'saturation', 'Saturation', '#20dfdf' ], + [ 'lightness', 'Lightness', '#95eaea' ], + ] )( 'HSL inputs', ( colorInput, inputLabel, expected ) => { + it( `should fire onChange with the correct value when the ${ colorInput } value is updated`, async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const color = '#2ad5d5'; + + render( + + ); + + const formatSelector = screen.getByRole( 'combobox' ); + expect( formatSelector ).toBeVisible(); + + await user.selectOptions( formatSelector, 'hsl' ); + + const inputElement = screen.getByRole( 'spinbutton', { + name: inputLabel, + } ); + expect( inputElement ).toBeVisible(); + + await user.clear( inputElement ); + await user.type( inputElement, '75' ); + + expect( onChange ).toHaveBeenCalledTimes( 3 ); + expect( onChange ).toHaveBeenLastCalledWith( expected ); + } ); } ); } ); } ); diff --git a/packages/components/src/mobile/color-settings/palette.screen.native.js b/packages/components/src/mobile/color-settings/palette.screen.native.js index bc7187fd092b8c..fcf03f9ecd4483 100644 --- a/packages/components/src/mobile/color-settings/palette.screen.native.js +++ b/packages/components/src/mobile/color-settings/palette.screen.native.js @@ -29,7 +29,6 @@ import { colorsUtils } from './utils'; import styles from './style.scss'; const HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 }; -const THEME_PALETTE_NAME = 'Theme'; const PaletteScreen = () => { const route = useRoute(); @@ -48,7 +47,6 @@ const PaletteScreen = () => { const [ currentValue, setCurrentValue ] = useState( colorValue ); const isGradientColor = isGradient( currentValue ); const selectedSegmentIndex = isGradientColor ? 1 : 0; - const allAvailableColors = useMobileGlobalStylesColors(); const [ currentSegment, setCurrentSegment ] = useState( segments[ selectedSegmentIndex ] @@ -57,6 +55,10 @@ const PaletteScreen = () => { const currentSegmentColors = ! isGradientSegment ? defaultSettings.colors : defaultSettings.gradients; + const allAvailableColors = useMobileGlobalStylesColors(); + const allAvailableGradients = currentSegmentColors + .flatMap( ( { gradients } ) => gradients ) + .filter( Boolean ); const horizontalSeparatorStyle = usePreferredColorSchemeStyle( styles.horizontalSeparator, @@ -184,10 +186,10 @@ const PaletteScreen = () => { colors: palette.colors, gradients: palette.gradients, allColors: allAvailableColors, + allGradients: allAvailableGradients, }; - const enableCustomColor = - ! isGradientSegment && - palette.name === THEME_PALETTE_NAME; + // Limit to show the custom indicator to the first available palette + const enableCustomColor = paletteKey === 0; return ( {}; + describe( 'getNameForPosition', () => { test( 'should return 1 by default', () => { const slugPrefix = 'test-'; @@ -82,18 +85,324 @@ describe( 'getNameForPosition', () => { describe( 'PaletteEdit', () => { const defaultProps = { - colors: [ { color: '#ffffff', name: 'Base', slug: 'base' } ], - onChange: jest.fn(), paletteLabel: 'Test label', - emptyMessage: 'Test empty message', - canOnlyChangeValues: true, - canReset: true, slugPrefix: '', + onChange: noop, }; - it( 'opens color selector for color palettes', () => { - render( ); - fireEvent.click( screen.getByLabelText( 'Color: Base' ) ); - expect( screen.getByLabelText( 'Hex color' ) ).toBeInTheDocument(); + const colors = [ + { color: '#1a4548', name: 'Primary', slug: 'primary' }, + { color: '#0000ff', name: 'Secondary', slug: 'secondary' }, + ]; + const gradients = [ + { + gradient: + 'linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%)', + name: 'Pale ocean', + slug: 'pale-ocean', + }, + { + gradient: + 'linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%)', + name: 'Midnight', + slug: 'midnight', + }, + ]; + + it( 'shows heading label', () => { + render( ); + + const paletteLabel = screen.getByRole( 'heading', { + level: 2, + name: 'Test label', + } ); + + expect( paletteLabel ).toBeVisible(); + } ); + + it( 'shows heading label with custom heading level', () => { + render( + + ); + + expect( + screen.getByRole( 'heading', { + level: 5, + name: 'Test label', + } ) + ).toBeVisible(); + } ); + + it( 'shows empty message', () => { + render( + + ); + + expect( screen.getByText( 'Test empty message' ) ).toBeVisible(); + } ); + + it( 'shows an option to remove all colors', async () => { + const user = userEvent.setup(); + render( ); + + await user.click( + screen.getByRole( 'button', { + name: 'Color options', + } ) + ); + + expect( + screen.getByRole( 'button', { + name: 'Remove all colors', + } ) + ).toBeVisible(); + } ); + + it( 'shows a reset option when the `canReset` prop is enabled', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click( + screen.getByRole( 'button', { + name: 'Color options', + } ) + ); + expect( + screen.getByRole( 'button', { + name: 'Reset colors', + } ) + ).toBeVisible(); + } ); + + it( 'does not show a reset colors option when `canReset` is disabled', async () => { + const user = userEvent.setup(); + render( ); + + await user.click( + screen.getByRole( 'button', { + name: 'Color options', + } ) + ); + expect( + screen.queryByRole( 'button', { + name: 'Reset colors', + } ) + ).not.toBeInTheDocument(); + } ); + + it( 'calls the `onChange` with the new color appended', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( + screen.getByRole( 'button', { + name: 'Add color', + } ) + ); + + await waitFor( () => { + expect( onChange ).toHaveBeenCalledWith( [ + ...colors, + { + color: '#000', + name: 'Color 1', + slug: 'color-1', + }, + ] ); + } ); + } ); + + it( 'calls the `onChange` with the new gradient appended', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( + screen.getByRole( 'button', { + name: 'Add gradient', + } ) + ); + + await waitFor( () => { + expect( onChange ).toHaveBeenCalledWith( [ + ...gradients, + { + gradient: + 'linear-gradient(135deg, rgba(6, 147, 227, 1) 0%, rgb(155, 81, 224) 100%)', + name: 'Color 1', + slug: 'color-1', + }, + ] ); + } ); + } ); + + it( 'can not add new colors when `canOnlyChangeValues` is enabled', () => { + render( ); + + expect( + screen.queryByRole( 'button', { + name: 'Add color', + } ) + ).not.toBeInTheDocument(); + } ); + + it( 'can remove a color', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( + screen.getByRole( 'button', { + name: 'Color options', + } ) + ); + await user.click( + screen.getByRole( 'button', { + name: 'Show details', + } ) + ); + await user.click( screen.getByText( 'Primary' ) ); + await user.click( + screen.getByRole( 'button', { + name: 'Remove color', + } ) + ); + + await waitFor( () => { + expect( onChange ).toHaveBeenCalledWith( [ colors[ 1 ] ] ); + } ); + } ); + + it( 'can update palette name', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( + screen.getByRole( 'button', { + name: 'Color options', + } ) + ); + await user.click( + screen.getByRole( 'button', { + name: 'Show details', + } ) + ); + await user.click( screen.getByText( 'Primary' ) ); + const nameInput = screen.getByRole( 'textbox', { + name: 'Color name', + } ); + await user.clear( nameInput ); + await user.type( nameInput, 'Primary Updated' ); + + await waitFor( () => { + expect( onChange ).toHaveBeenCalledWith( [ + { + ...colors[ 0 ], + name: 'Primary Updated', + slug: 'primary-updated', + }, + colors[ 1 ], + ] ); + } ); + } ); + + it( 'can update color palette value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( screen.getByLabelText( 'Color: Primary' ) ); + const hexInput = screen.getByRole( 'textbox', { + name: 'Hex color', + } ); + await user.clear( hexInput ); + await user.type( hexInput, '000000' ); + + await waitFor( () => { + expect( onChange ).toHaveBeenCalledWith( [ + { + ...colors[ 0 ], + color: '#000000', + }, + colors[ 1 ], + ] ); + } ); + } ); + + it( 'can update gradient palette value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( screen.getByLabelText( 'Gradient: Pale ocean' ) ); + + const typeSelectElement = screen.getByRole( 'combobox', { + name: 'Type', + } ); + await user.selectOptions( typeSelectElement, 'radial-gradient' ); + + await waitFor( () => { + expect( onChange ).toHaveBeenCalledWith( [ + { + ...gradients[ 0 ], + gradient: + 'radial-gradient(rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%)', + }, + gradients[ 1 ], + ] ); + } ); } ); } ); diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx index b54b5764d4e0ff..99a2dd8a00421c 100644 --- a/packages/components/src/toggle-group-control/test/index.tsx +++ b/packages/components/src/toggle-group-control/test/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { press, click, hover, sleep } from '@ariakit/test'; /** * WordPress dependencies @@ -19,8 +19,13 @@ import { ToggleGroupControlOption, ToggleGroupControlOptionIcon, } from '../index'; +import { TOOLTIP_DELAY } from '../../tooltip'; import type { ToggleGroupControlProps } from '../types'; -import cleanupTooltip from '../../tooltip/test/utils'; + +const hoverOutside = async () => { + await hover( document.body ); + await hover( document.body, { clientX: 10, clientY: 10 } ); +}; const ControlledToggleGroupControl = ( { value: valueProp, @@ -113,7 +118,6 @@ describe.each( [ } ); } ); it( 'should call onChange with proper value', async () => { - const user = userEvent.setup(); const mockOnChange = jest.fn(); render( @@ -126,13 +130,12 @@ describe.each( [ ); - await user.click( screen.getByRole( 'radio', { name: 'R' } ) ); + await click( screen.getByRole( 'radio', { name: 'R' } ) ); expect( mockOnChange ).toHaveBeenCalledWith( 'rigas' ); } ); it( 'should render tooltip where `showTooltip` === `true`', async () => { - const user = userEvent.setup(); render( { optionsWithTooltip } @@ -143,19 +146,26 @@ describe.each( [ 'Click for Delicious Gnocchi' ); - await user.hover( firstRadio ); + await hover( firstRadio ); - const tooltip = await screen.findByText( - 'Click for Delicious Gnocchi' - ); + const tooltip = await screen.findByRole( 'tooltip', { + name: 'Click for Delicious Gnocchi', + } ); await waitFor( () => expect( tooltip ).toBeVisible() ); - await cleanupTooltip( user ); + // hover outside of radio + await hoverOutside(); + + // Tooltip should hide + expect( + screen.queryByRole( 'tooltip', { + name: 'Click for Delicious Gnocchi', + } ) + ).not.toBeInTheDocument(); } ); it( 'should not render tooltip', async () => { - const user = userEvent.setup(); render( { optionsWithTooltip } @@ -166,19 +176,24 @@ describe.each( [ 'Click for Sumptuous Caponata' ); - await user.hover( secondRadio ); + await hover( secondRadio ); - await waitFor( () => - expect( - screen.queryByText( 'Click for Sumptuous Caponata' ) - ).not.toBeInTheDocument() - ); + // Tooltip shouldn't show + expect( + screen.queryByText( 'Click for Sumptuous Caponata' ) + ).not.toBeInTheDocument(); + + // Advance time by default delay + await sleep( TOOLTIP_DELAY ); + + // Tooltip shouldn't show. + expect( + screen.queryByText( 'Click for Sumptuous Caponata' ) + ).not.toBeInTheDocument(); } ); if ( mode === 'controlled' ) { it( 'should reset values correctly', async () => { - const user = userEvent.setup(); - render( { options } @@ -188,25 +203,23 @@ describe.each( [ const rigasOption = screen.getByRole( 'radio', { name: 'R' } ); const jackOption = screen.getByRole( 'radio', { name: 'J' } ); - await user.click( rigasOption ); + await click( rigasOption ); expect( jackOption ).not.toBeChecked(); expect( rigasOption ).toBeChecked(); - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( rigasOption ).not.toBeChecked(); expect( jackOption ).toBeChecked(); - await user.click( screen.getByRole( 'button', { name: 'Reset' } ) ); + await click( screen.getByRole( 'button', { name: 'Reset' } ) ); expect( rigasOption ).not.toBeChecked(); expect( jackOption ).not.toBeChecked(); } ); it( 'should update correctly when triggered by external updates', async () => { - const user = userEvent.setup(); - render( { it( 'should not be deselectable', async () => { const mockOnChange = jest.fn(); - const user = userEvent.setup(); render( { - const user = userEvent.setup(); - render( - - { options } - + <> + + { options } + + + ); const rigas = screen.getByRole( 'radio', { name: 'R', } ); - await user.tab(); + await press.Tab(); expect( rigas ).toHaveFocus(); - await user.tab(); + await press.Tab(); + // When in controlled mode, there is an additional "Reset" button. const expectedFocusTarget = mode === 'uncontrolled' - ? rigas.ownerDocument.body + ? screen.getByRole( 'button', { + name: 'After ToggleGroupControl', + } ) : screen.getByRole( 'button', { name: 'Reset' } ); expect( expectedFocusTarget ).toHaveFocus(); @@ -301,7 +317,6 @@ describe.each( [ describe( 'isDeselectable = true', () => { it( 'should be deselectable', async () => { const mockOnChange = jest.fn(); - const user = userEvent.setup(); render( ); - await user.click( + await click( screen.getByRole( 'button', { name: 'R', pressed: true, @@ -323,7 +338,7 @@ describe.each( [ expect( mockOnChange ).toHaveBeenCalledTimes( 1 ); expect( mockOnChange ).toHaveBeenLastCalledWith( undefined ); - await user.click( + await click( screen.getByRole( 'button', { name: 'R', pressed: false, @@ -334,15 +349,13 @@ describe.each( [ } ); it( 'should tab to the next option button', async () => { - const user = userEvent.setup(); - render( { options } ); - await user.tab(); + await press.Tab(); expect( screen.getByRole( 'button', { name: 'R', @@ -350,7 +363,7 @@ describe.each( [ } ) ).toHaveFocus(); - await user.tab(); + await press.Tab(); expect( screen.getByRole( 'button', { name: 'J', @@ -359,7 +372,7 @@ describe.each( [ ).toHaveFocus(); // Focus should not move with arrow keys - await user.keyboard( '{ArrowLeft}' ); + await press.ArrowLeft(); expect( screen.getByRole( 'button', { name: 'J', diff --git a/packages/components/src/tooltip/README.md b/packages/components/src/tooltip/README.md index 9b214e8fc6b00e..ef2cd35d25543e 100644 --- a/packages/components/src/tooltip/README.md +++ b/packages/components/src/tooltip/README.md @@ -16,6 +16,10 @@ const MyTooltip = () => ( ); ``` +### Nested tooltips + +In case one or more `Tooltip` components are rendered inside another `Tooltip` component, only the tooltip associated to the outermost `Tooltip` component will be rendered in the browser and shown to the user appropriately. The rest of the nested `Tooltip` components will simply no-op and pass-through their anchor. + ## Props The component accepts the following props: diff --git a/packages/components/src/tooltip/index.tsx b/packages/components/src/tooltip/index.tsx index 817d6d18812ee4..1e652d9a42dbb4 100644 --- a/packages/components/src/tooltip/index.tsx +++ b/packages/components/src/tooltip/index.tsx @@ -8,22 +8,37 @@ import * as Ariakit from '@ariakit/react'; * WordPress dependencies */ import { useInstanceId } from '@wordpress/compose'; -import { Children } from '@wordpress/element'; +import { Children, cloneElement } from '@wordpress/element'; import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -import type { TooltipProps } from './types'; +import type { TooltipProps, TooltipInternalContext } from './types'; import Shortcut from '../shortcut'; import { positionToPlacement } from '../popover/utils'; +import { + contextConnect, + useContextSystem, + ContextSystemProvider, +} from '../context'; +import type { WordPressComponentProps } from '../context'; /** * Time over anchor to wait before showing tooltip */ export const TOOLTIP_DELAY = 700; -function Tooltip( props: TooltipProps ) { +const CONTEXT_VALUE = { + Tooltip: { + isNestedInTooltip: true, + }, +}; + +function UnconnectedTooltip( + props: WordPressComponentProps< TooltipProps, 'div', false >, + ref: React.ForwardedRef< any > +) { const { children, delay = TOOLTIP_DELAY, @@ -32,7 +47,15 @@ function Tooltip( props: TooltipProps ) { position, shortcut, text, - } = props; + + // From Internal Context system + isNestedInTooltip, + + ...restProps + } = useContextSystem< typeof props & TooltipInternalContext >( + props, + 'Tooltip' + ); const baseId = useInstanceId( Tooltip, 'tooltip' ); const describedById = text || shortcut ? baseId : undefined; @@ -43,7 +66,7 @@ function Tooltip( props: TooltipProps ) { if ( 'development' === process.env.NODE_ENV ) { // eslint-disable-next-line no-console console.error( - 'Tooltip should be called with only a single child element.' + 'wp-components.Tooltip should be called with only a single child element.' ); } } @@ -64,24 +87,37 @@ function Tooltip( props: TooltipProps ) { } computedPlacement = computedPlacement || 'bottom'; - const tooltipStore = Ariakit.useTooltipStore( { + // Removing the `Ariakit` namespace from the hook name allows ESLint to + // properly identify the hook, and apply the correct linting rules. + const useAriakitTooltipStore = Ariakit.useTooltipStore; + const tooltipStore = useAriakitTooltipStore( { placement: computedPlacement, showTimeout: delay, } ); + if ( isNestedInTooltip ) { + return isOnlyChild + ? cloneElement( children, { + ...restProps, + ref, + } ) + : children; + } + return ( - <> + { isOnlyChild ? undefined : children } { isOnlyChild && ( text || shortcut ) && ( ) } - + ); } +export const Tooltip = contextConnect( UnconnectedTooltip, 'Tooltip' ); + export default Tooltip; diff --git a/packages/components/src/tooltip/stories/index.story.tsx b/packages/components/src/tooltip/stories/index.story.tsx index 760f3dcc23e2fd..b006bc03aced96 100644 --- a/packages/components/src/tooltip/stories/index.story.tsx +++ b/packages/components/src/tooltip/stories/index.story.tsx @@ -30,7 +30,7 @@ const meta: Meta< typeof Tooltip > = { 'bottom right', ], }, - shortcut: { control: { type: 'text' } }, + shortcut: { control: { type: 'object' } }, }, parameters: { controls: { expanded: true }, @@ -57,3 +57,20 @@ KeyboardShortcut.args = { ariaLabel: shortcutAriaLabel.primaryShift( ',' ), }, }; + +/** + * In case one or more `Tooltip` components are rendered inside another + * `Tooltip` component, only the tooltip associated to the outermost `Tooltip` + * component will be rendered in the browser and shown to the user + * appropriately. The rest of the nested `Tooltip` components will simply no-op + * and pass-through their anchor. + */ +export const Nested: StoryFn< typeof Tooltip > = Template.bind( {} ); +Nested.args = { + children: ( + + + + ), + text: 'Outer tooltip text', +}; diff --git a/packages/components/src/tooltip/test/index.tsx b/packages/components/src/tooltip/test/index.tsx index cbe144cfa53d4d..ed6f7b5f7b4a14 100644 --- a/packages/components/src/tooltip/test/index.tsx +++ b/packages/components/src/tooltip/test/index.tsx @@ -436,4 +436,50 @@ describe( 'Tooltip', () => { await waitExpectTooltipToHide(); } ); } ); + + describe( 'nested', () => { + it( 'should render the outer tooltip and ignore nested tooltips', async () => { + render( + + + + + + + + ); + + // Hover the anchor. Only the outer tooltip should show. + await hover( + screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ) + ); + + await waitFor( () => + expect( + screen.getByRole( 'tooltip', { name: 'Outer tooltip' } ) + ).toBeVisible() + ); + expect( + screen.queryByRole( 'tooltip', { name: 'Middle tooltip' } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'tooltip', { name: 'Inner tooltip' } ) + ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'button', { + description: 'Outer tooltip', + } ) + ).toBeVisible(); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitFor( () => + expect( + screen.queryByRole( 'tooltip', { name: 'Outer tooltip' } ) + ).not.toBeInTheDocument() + ); + } ); + } ); } ); diff --git a/packages/components/src/tooltip/types.ts b/packages/components/src/tooltip/types.ts index 8708ae7005f5b3..3d28a1a0e96c67 100644 --- a/packages/components/src/tooltip/types.ts +++ b/packages/components/src/tooltip/types.ts @@ -59,3 +59,7 @@ export type TooltipProps = { */ text?: string; }; + +export type TooltipInternalContext = { + isNestedInTooltip?: boolean; +}; diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md index 47a8aec6c92a31..159c65e9ada19c 100644 --- a/packages/create-block-interactive-template/CHANGELOG.md +++ b/packages/create-block-interactive-template/CHANGELOG.md @@ -2,20 +2,24 @@ ## Unreleased +### Enhancement + +- Update the template to use `viewModule` in block.json ([#57712](https://github.com/WordPress/gutenberg/pull/57712)). + ## 1.11.0 (2023-12-13) -- Add all files to the generated plugin zip. [#56943](https://github.com/WordPress/gutenberg/pull/56943) -- Prevent crash when Gutenberg plugin is not installed. [#56941](https://github.com/WordPress/gutenberg/pull/56941) +- Add all files to the generated plugin zip ([#56943](https://github.com/WordPress/gutenberg/pull/56943)). +- Prevent crash when Gutenberg plugin is not installed ([#56941](https://github.com/WordPress/gutenberg/pull/56941)). ## 1.10.1 (2023-12-07) -- Update template to use modules instead of scripts. [#56694](https://github.com/WordPress/gutenberg/pull/56694) +- Update template to use modules instead of scripts ([#56694](https://github.com/WordPress/gutenberg/pull/56694)). ## 1.10.0 (2023-11-29) ### Enhancement -- Update `view.js` and `render.php` templates to the new `store()` API. [#56613](https://github.com/WordPress/gutenberg/pull/56613) +- Update `view.js` and `render.php` templates to the new `store()` API ([#56613](https://github.com/WordPress/gutenberg/pull/56613)). ## 1.9.0 (2023-11-16) @@ -35,4 +39,4 @@ ### Enhancement -- Moves the `example` property into block.json by leveraging changes to create-block to now support `example`. [#52801](https://github.com/WordPress/gutenberg/pull/52801) +- Moves the `example` property into block.json by leveraging changes to create-block to now support `example` ([#52801](https://github.com/WordPress/gutenberg/pull/52801)). diff --git a/packages/create-block-interactive-template/README.md b/packages/create-block-interactive-template/README.md index cc0530c0630549..adf3cab6594cc9 100644 --- a/packages/create-block-interactive-template/README.md +++ b/packages/create-block-interactive-template/README.md @@ -10,6 +10,8 @@ This block template can be used by running the following command: npx @wordpress/create-block --template @wordpress/create-block-interactive-template ``` +It requires Gutenberg 17.5 or higher. + ## Contributing to this package This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. diff --git a/packages/create-block-interactive-template/block-templates/render.php.mustache b/packages/create-block-interactive-template/block-templates/render.php.mustache index 0f6883a9362407..960da619f790a4 100644 --- a/packages/create-block-interactive-template/block-templates/render.php.mustache +++ b/packages/create-block-interactive-template/block-templates/render.php.mustache @@ -13,11 +13,6 @@ // Generate unique id for aria-controls. $unique_id = wp_unique_id( 'p-' ); - -// Enqueue the view file. -if (function_exists('gutenberg_enqueue_module')) { - gutenberg_enqueue_module( '{{namespace}}-view' ); -} ?>
!! value ) diff --git a/packages/create-block/lib/scaffold.js b/packages/create-block/lib/scaffold.js index 49d3cbf794777a..bd9ba0396b75e3 100644 --- a/packages/create-block/lib/scaffold.js +++ b/packages/create-block/lib/scaffold.js @@ -44,6 +44,7 @@ module.exports = async ( editorStyle, style, render, + viewModule, viewScript, variantVars, customPackageJSON, @@ -84,6 +85,7 @@ module.exports = async ( editorStyle, style, render, + viewModule, viewScript, variantVars, customPackageJSON, diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index 558c509139a666..86e4e3e38c3dfc 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -236,7 +236,8 @@ Array of operations that can be performed upon each record. Each action is an ob - `isLoading`: whether the data is loading. `false` by default. - `supportedLayouts`: array of layouts supported. By default, all are: `table`, `grid`, `list`. - `deferredRendering`: whether the items should be rendered asynchronously. Useful when there's a field that takes a lot of time (e.g.: previews). `false` by default. -- `onSelectionChange`: callback that returns the selected items. So far, only the `list` view implements this. +- `onSelectionChange`: callback that signals the user selected one of more items, and takes them as parameter. So far, only the `list` view implements it. +- `onDetailsChange`: callback that signals the user triggered the details for one of more items, and takes them as paremeter. So far, only the `list` view implements it. ## Contributing to this package diff --git a/packages/dataviews/src/bulk-actions.js b/packages/dataviews/src/bulk-actions.js new file mode 100644 index 00000000000000..9fd9f628286e09 --- /dev/null +++ b/packages/dataviews/src/bulk-actions.js @@ -0,0 +1,187 @@ +/** + * WordPress dependencies + */ +import { + privateApis as componentsPrivateApis, + Button, + Modal, +} from '@wordpress/components'; +import { __, sprintf, _n } from '@wordpress/i18n'; +import { useMemo, useState, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from './lock-unlock'; + +const { + DropdownMenuV2: DropdownMenu, + DropdownMenuGroupV2: DropdownMenuGroup, + DropdownMenuItemV2: DropdownMenuItem, + DropdownMenuSeparatorV2: DropdownMenuSeparator, +} = unlock( componentsPrivateApis ); + +function ActionWithModal( { + action, + selectedItems, + setActionWithModal, + onMenuOpenChange, +} ) { + const eligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => action.isEligible( item ) ); + }, [ action, selectedItems ] ); + const { RenderModal, hideModalHeader } = action; + const onCloseModal = useCallback( () => { + setActionWithModal( undefined ); + }, [ setActionWithModal ] ); + return ( + + onMenuOpenChange( false ) } + /> + + ); +} + +function BulkActionItem( { action, selectedItems, setActionWithModal } ) { + const eligibleItems = useMemo( () => { + return selectedItems.filter( ( item ) => action.isEligible( item ) ); + }, [ action, selectedItems ] ); + + const shouldShowModal = !! action.RenderModal; + + return ( + { + if ( shouldShowModal ) { + setActionWithModal( action ); + } else { + await action.callback( eligibleItems ); + } + } } + suffix={ + eligibleItems.length > 0 ? eligibleItems.length : undefined + } + > + { action.label } + + ); +} + +function ActionsMenuGroup( { actions, selectedItems, setActionWithModal } ) { + return ( + <> + + { actions.map( ( action ) => ( + + ) ) } + + + + ); +} + +export default function BulkActions( { + data, + actions, + selection, + onSelectionChange, + getItemId, +} ) { + const bulkActions = useMemo( + () => actions.filter( ( action ) => action.supportsBulk ), + [ actions ] + ); + const areAllSelected = selection && selection.length === data.length; + const [ isMenuOpen, onMenuOpenChange ] = useState( false ); + const [ actionWithModal, setActionWithModal ] = useState(); + const selectedItems = useMemo( () => { + return data.filter( ( item ) => + selection.includes( getItemId( item ) ) + ); + }, [ selection, data, getItemId ] ); + + if ( bulkActions.length === 0 ) { + return null; + } + return ( + <> + + { selection.length + ? sprintf( + /* translators: %d: Number of items. */ + _n( + 'Edit %d item', + 'Edit %d items', + selection.length + ), + selection.length + ) + : __( 'Bulk edit' ) } + + } + > + + + { + onSelectionChange( data ); + } } + suffix={ data.length } + > + { __( 'Select all' ) } + + { + onSelectionChange( [] ); + } } + > + { __( 'Deselect' ) } + + + + { actionWithModal && ( + + ) } + + ); +} diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index 91ea942ee6d22a..64a70d46c7d127 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -5,7 +5,7 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, } from '@wordpress/components'; -import { useMemo, useState, useCallback } from '@wordpress/element'; +import { useMemo, useState, useCallback, useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -14,9 +14,11 @@ import Pagination from './pagination'; import ViewActions from './view-actions'; import Filters from './filters'; import Search from './search'; -import { VIEW_LAYOUTS } from './constants'; +import { VIEW_LAYOUTS, LAYOUT_TABLE } from './constants'; +import BulkActions from './bulk-actions'; const defaultGetItemId = ( item ) => item.id; +const defaultOnSelectionChange = () => {}; export default function DataViews( { view, @@ -30,11 +32,29 @@ export default function DataViews( { isLoading = false, paginationInfo, supportedLayouts, - onSelectionChange, + onSelectionChange = defaultOnSelectionChange, + onDetailsChange = null, deferredRendering = false, } ) { const [ selection, setSelection ] = useState( [] ); + useEffect( () => { + if ( + selection.length > 0 && + selection.some( + ( id ) => ! data.some( ( item ) => item.id === id ) + ) + ) { + const newSelection = selection.filter( ( id ) => + data.some( ( item ) => item.id === id ) + ); + setSelection( newSelection ); + onSelectionChange( + data.filter( ( item ) => newSelection.includes( item.id ) ) + ); + } + }, [ selection, data, onSelectionChange ] ); + const onSetSelection = useCallback( ( items ) => { setSelection( items.map( ( item ) => item.id ) ); @@ -57,7 +77,7 @@ export default function DataViews( { { search && ( @@ -73,6 +93,15 @@ export default function DataViews( { onChangeView={ onChangeView } /> + { view.type === LAYOUT_TABLE && ( + + ) } diff --git a/packages/dataviews/src/dropdown-menu-helper.js b/packages/dataviews/src/dropdown-menu-helper.js index 0c473c50cd0c10..ce0ace8f61e551 100644 --- a/packages/dataviews/src/dropdown-menu-helper.js +++ b/packages/dataviews/src/dropdown-menu-helper.js @@ -48,7 +48,7 @@ export const DropdownMenuRadioItemCustom = forwardRef( ) : ( ) diff --git a/packages/dataviews/src/item-actions.js b/packages/dataviews/src/item-actions.js index 974c5c5d0d5c3f..ce4dd6abef0568 100644 --- a/packages/dataviews/src/item-actions.js +++ b/packages/dataviews/src/item-actions.js @@ -59,10 +59,7 @@ function ActionWithModal( { action, item, ActionTrigger } ) { { isModalOpen && ( { setIsModalOpen( false ); @@ -72,7 +69,7 @@ function ActionWithModal( { action, item, ActionTrigger } ) { ) }` } > setIsModalOpen( false ) } /> @@ -99,7 +96,7 @@ function ActionsDropdownMenuGroup( { actions, item } ) { action.callback( item ) } + onClick={ () => action.callback( [ item ] ) } /> ); } ) } @@ -160,7 +157,7 @@ export default function ItemActions( { item, actions, isCompact } ) { action.callback( item ) } + onClick={ () => action.callback( [ item ] ) } /> ); } ) } diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index 049f006dd97bd5..d934ea0df62d0a 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -10,7 +10,7 @@ } } -.dataviews__filters-view-actions { +.dataviews-filters__view-actions { padding: $grid-unit-15 $grid-unit-40; .components-search-control { flex-grow: 1; @@ -18,6 +18,10 @@ } } +.dataviews-filters__view-actions.components-h-stack { + align-items: center; +} + .dataviews-filters-button { position: relative; } @@ -55,7 +59,7 @@ margin: $grid-unit-40 0 $grid-unit-20; } -.dataviews-table-view { +.dataviews-view-table { width: 100%; text-indent: 0; border-color: inherit; @@ -81,16 +85,28 @@ &[data-field-id="actions"] { text-align: right; } + + &.dataviews-view-table__checkbox-column { + padding-right: 0; + } + + .components-checkbox-control__input-container { + margin: $grid-unit-05; + } } tr { border-bottom: 1px solid $gray-100; + .dataviews-view-table-header-button { + gap: $grid-unit-05; + } + td:first-child, th:first-child { padding-left: $grid-unit-40; - .dataviews-table-header-button, - .dataviews-table-header { + .dataviews-view-table-header-button, + .dataviews-view-table-header { margin-left: - #{$grid-unit-10}; } } @@ -105,8 +121,32 @@ } &:hover { - td { - background-color: #f8f8f8; + background-color: #f8f8f8; + } + + .components-checkbox-control__input { + opacity: 0; + + &:checked, + &:indeterminate, + &:focus { + opacity: 1; + } + } + + &:focus-within, + &:hover { + .components-checkbox-control__input { + opacity: 1; + } + } + + &.is-selected { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04); + color: $gray-700; + + &:hover { + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08); } } } @@ -129,7 +169,7 @@ } } - .dataviews-table-header-button { + .dataviews-view-table-header-button { padding: $grid-unit-05 $grid-unit-10; font-size: 11px; text-transform: uppercase; @@ -148,12 +188,12 @@ } } - .dataviews-table-header { + .dataviews-view-table-header { padding-left: $grid-unit-05; } } -.dataviews-grid-view { +.dataviews-view-grid { margin-bottom: $grid-unit-30; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; padding: 0 $grid-unit-40; @@ -221,29 +261,71 @@ } } -.dataviews-list-view { +.dataviews-view-list { margin: 0; + padding: $grid-unit-10; li { - border-bottom: $border-width solid $gray-100; margin: 0; - &:first-child { - border-top: $border-width solid $gray-100; + + .dataviews-view-list__item-wrapper { + position: relative; + padding-right: $grid-unit-30; + border-radius: $grid-unit-05; + + &::after { + position: absolute; + content: ""; + top: 100%; + left: $grid-unit-30; + right: $grid-unit-30; + background: $gray-100; + height: 1px; + } } - &:last-child { - border-bottom: 0; + + &:not(.is-selected):hover { + color: var(--wp-admin-theme-color); + + .dataviews-view-list__fields { + color: var(--wp-admin-theme-color); + } } } - .dataviews-list-view__item { - padding: $grid-unit-15 $grid-unit-40; - cursor: default; - &:focus, - &:hover { - background-color: lighten($gray-100, 3%); + li.is-selected, + li.is-selected:focus-within { + .dataviews-view-list__item-wrapper { + background-color: var(--wp-admin-theme-color); + color: $white; + + .dataviews-view-list__fields, + .components-button { + color: $white; + } + + &::after { + background: transparent; + } } + } + + .dataviews-view-list__item { + padding: $grid-unit-15 0 $grid-unit-15 $grid-unit-30; + width: 100%; + cursor: pointer; &:focus { - box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + &::before { + position: absolute; + content: ""; + top: -1px; + right: -1px; + bottom: -1px; + left: -1px; + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + z-index: -1; + border-radius: $grid-unit-05; + } } h3 { overflow: hidden; @@ -252,22 +334,12 @@ } } - .dataviews-list-view__item-selected, - .dataviews-list-view__item-selected:hover { - background-color: $gray-100; - - &:focus { - box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); - } - } - - .dataviews-list-view__media-wrapper { + .dataviews-view-list__media-wrapper { min-width: $grid-unit-40; height: $grid-unit-40; border-radius: $grid-unit-05; overflow: hidden; position: relative; - margin-top: $grid-unit-05; &::after { content: ""; @@ -281,19 +353,19 @@ } } - .dataviews-list-view__media-placeholder { + .dataviews-view-list__media-placeholder { min-width: $grid-unit-40; height: $grid-unit-40; background-color: $gray-200; } - .dataviews-list-view__fields { + .dataviews-view-list__fields { color: $gray-700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - .dataviews-list-view__field { + .dataviews-view-list__field { margin-right: $grid-unit-15; &:last-child { @@ -301,6 +373,31 @@ } } } + + & + .dataviews-pagination { + justify-content: space-between; + } + + .dataviews-view-list__details-button { + align-self: center; + opacity: 0; + } + + li.is-selected, + li:hover, + li:focus-within { + .dataviews-view-list__details-button { + opacity: 1; + } + } + + li.is-selected { + .dataviews-view-list__details-button { + &:focus { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) currentColor; + } + } + } } .dataviews-action-modal { @@ -312,7 +409,23 @@ padding: 0 $grid-unit-40; } -.dataviews__filters-custom-menu-radio-item-prefix { +.dataviews-view-table-selection-checkbox label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.dataviews-filters__custom-menu-radio-item-prefix { display: block; width: 24px; } + +.dataviews-bulk-edit-button.components-button { + flex-shrink: 0; +} diff --git a/packages/dataviews/src/view-grid.js b/packages/dataviews/src/view-grid.js index 5ac4d1d42d5739..7c18d31dccd193 100644 --- a/packages/dataviews/src/view-grid.js +++ b/packages/dataviews/src/view-grid.js @@ -42,7 +42,7 @@ export default function ViewGrid( { gap={ 8 } columns={ 2 } alignment="top" - className="dataviews-grid-view" + className="dataviews-view-grid" > { usedData.map( ( item ) => ( + { ! hasData && ( +

{ isLoading ? __( 'Loading…' ) : __( 'No results' ) }

+ ) } +
+ ); + } + return ( -
    +
      { usedData.map( ( item ) => { return ( -
    • -
      onSelectionChange( [ item ] ) } - > - -
      - { mediaField?.render( { item } ) || ( -
      - ) } -
      - +
    • + +
      onSelectionChange( [ item ] ) } + > + +
      + { mediaField?.render( { item } ) || ( +
      + ) } +
      { primaryField?.render( { item } ) } -
      +
      { visibleFields.map( ( field ) => { return ( { field.render( { item, @@ -89,8 +109,19 @@ export default function ViewList( {
      - -
      +
      + { onDetailsChange && ( +
    • ); } ) } diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index 0be2ee6767ddff..e59c4e001919c4 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -1,18 +1,19 @@ /** * External dependencies */ -import classNames from 'classnames'; +import classnames from 'classnames'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { useAsyncList } from '@wordpress/compose'; import { unseen, funnel } from '@wordpress/icons'; import { Button, Icon, privateApis as componentsPrivateApis, + CheckboxControl, } from '@wordpress/components'; import { Children, @@ -90,7 +91,7 @@ const HeaderMenu = forwardRef( function HeaderMenu( trigger={ + { showAddPageModal && ( + + ) } + + } > { view.type === LAYOUT_LIST && ( diff --git a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js index 19d4c93bb685fe..0c44c996ed373b 100644 --- a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js +++ b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js @@ -8,7 +8,7 @@ import { paramCase as kebabCase } from 'change-case'; */ import { getQueryArgs } from '@wordpress/url'; import { downloadBlob } from '@wordpress/blob'; -import { __, sprintf } from '@wordpress/i18n'; +import { __, _x, sprintf } from '@wordpress/i18n'; import { Button, TextControl, @@ -45,7 +45,7 @@ export const exportJSONaction = { id: 'export-pattern', label: __( 'Export as JSON' ), isEligible: ( item ) => item.type === PATTERN_TYPES.user, - callback: ( item ) => { + callback: ( [ item ] ) => { const json = { __file: item.type, title: item.title || item.name, @@ -71,7 +71,8 @@ export const renameAction = { const hasThemeFile = isTemplatePart && item.templatePart.has_theme_file; return isCustomPattern && ! hasThemeFile; }, - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const [ title, setTitle ] = useState( () => item.title ); const { editEntityRecord, saveEditedEntityRecord } = useDispatch( coreStore ); @@ -160,7 +161,8 @@ export const deleteAction = { return canDeleteOrReset( item ) && ! hasThemeFile; }, hideModalHeader: true, - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { __experimentalDeleteReusableBlock } = useDispatch( reusableBlocksStore ); const { createErrorNotice, createSuccessNotice } = @@ -224,7 +226,8 @@ export const resetAction = { return canDeleteOrReset( item ) && hasThemeFile; }, hideModalHeader: true, - RenderModal: ( { item, closeModal } ) => { + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { removeTemplate } = useDispatch( editSiteStore ); return ( @@ -251,10 +254,11 @@ export const resetAction = { export const duplicatePatternAction = { id: 'duplicate-pattern', - label: __( 'Duplicate' ), + label: _x( 'Duplicate', 'action label' ), isEligible: ( item ) => item.type !== TEMPLATE_PART_POST_TYPE, - modalHeader: __( 'Duplicate pattern' ), - RenderModal: ( { item, closeModal } ) => { + modalHeader: _x( 'Duplicate pattern', 'action label' ), + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { categoryId = PATTERN_DEFAULT_CATEGORY } = getQueryArgs( window.location.href ); @@ -276,7 +280,7 @@ export const duplicatePatternAction = { return ( ); @@ -285,10 +289,11 @@ export const duplicatePatternAction = { export const duplicateTemplatePartAction = { id: 'duplicate-template-part', - label: __( 'Duplicate' ), + label: _x( 'Duplicate', 'action label' ), isEligible: ( item ) => item.type === TEMPLATE_PART_POST_TYPE, - modalHeader: __( 'Duplicate template part' ), - RenderModal: ( { item, closeModal } ) => { + modalHeader: _x( 'Duplicate template part', 'action label' ), + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; const { createSuccessNotice } = useDispatch( noticesStore ); const { categoryId = PATTERN_DEFAULT_CATEGORY } = getQueryArgs( window.location.href @@ -322,7 +327,7 @@ export const duplicateTemplatePartAction = { ) } onCreate={ onTemplatePartSuccess } onError={ closeModal } - confirmLabel={ __( 'Duplicate' ) } + confirmLabel={ _x( 'Duplicate', 'action label' ) } /> ); }, diff --git a/packages/edit-site/src/components/page-templates/index.js b/packages/edit-site/src/components/page-templates/index.js index c0e0289311db6a..ddc48542ee1b7a 100644 --- a/packages/edit-site/src/components/page-templates/index.js +++ b/packages/edit-site/src/components/page-templates/index.js @@ -65,7 +65,9 @@ const { useHistory } = unlock( routerPrivateApis ); const EMPTY_ARRAY = []; const defaultConfigPerViewType = { - [ LAYOUT_TABLE ]: {}, + [ LAYOUT_TABLE ]: { + primaryField: 'title', + }, [ LAYOUT_GRID ]: { mediaField: 'preview', primaryField: 'title', @@ -84,7 +86,7 @@ const DEFAULT_VIEW = { // All fields are visible by default, so it's // better to keep track of the hidden ones. hiddenFields: [ 'preview' ], - layout: {}, + layout: defaultConfigPerViewType[ LAYOUT_TABLE ], filters: [], }; diff --git a/packages/edit-site/src/components/page-templates/template-actions.js b/packages/edit-site/src/components/page-templates/template-actions.js index 9f5897e31fb93e..7029d464ca8671 100644 --- a/packages/edit-site/src/components/page-templates/template-actions.js +++ b/packages/edit-site/src/components/page-templates/template-actions.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { backup, trash } from '@wordpress/icons'; -import { __, sprintf } from '@wordpress/i18n'; +import { __, sprintf, _n } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; import { useMemo, useState } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; @@ -19,6 +19,7 @@ import { /** * Internal dependencies */ +import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; import isTemplateRevertable from '../../utils/is-template-revertable'; import isTemplateRemovable from '../../utils/is-template-removable'; @@ -32,39 +33,64 @@ export function useResetTemplateAction() { return useMemo( () => ( { id: 'reset-template', - label: __( 'Reset template' ), + label: __( 'Reset' ), isPrimary: true, icon: backup, isEligible: isTemplateRevertable, - async callback( template ) { + supportsBulk: true, + async callback( templates ) { try { - await revertTemplate( template, { allowUndo: false } ); - await saveEditedEntityRecord( - 'postType', - template.type, - template.id - ); + for ( const template of templates ) { + await revertTemplate( template, { + allowUndo: false, + } ); + await saveEditedEntityRecord( + 'postType', + template.type, + template.id + ); + } createSuccessNotice( - sprintf( - /* translators: The template/part's name. */ - __( '"%s" reverted.' ), - decodeEntities( template.title.rendered ) - ), + templates.length > 1 + ? sprintf( + /* translators: The number of items. */ + __( '%s items reverted.' ), + templates.length + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" reverted.' ), + decodeEntities( + templates[ 0 ].title.rendered + ) + ), { type: 'snackbar', id: 'edit-site-template-reverted', } ); } catch ( error ) { - const fallbackErrorMessage = - template.type === TEMPLATE_POST_TYPE - ? __( - 'An error occurred while reverting the template.' - ) - : __( - 'An error occurred while reverting the template part.' - ); + let fallbackErrorMessage; + if ( templates[ 0 ].type === TEMPLATE_POST_TYPE ) { + fallbackErrorMessage = + templates.length === 1 + ? __( + 'An error occurred while reverting the template.' + ) + : __( + 'An error occurred while reverting the templates.' + ); + } else { + fallbackErrorMessage = + templates.length === 1 + ? __( + 'An error occurred while reverting the template part.' + ) + : __( + 'An error occurred while reverting the template parts.' + ); + } const errorMessage = error.message && error.code !== 'unknown_error' ? error.message @@ -85,21 +111,34 @@ export function useResetTemplateAction() { export const deleteTemplateAction = { id: 'delete-template', - label: __( 'Delete template' ), + label: __( 'Delete' ), isPrimary: true, icon: trash, isEligible: isTemplateRemovable, + supportsBulk: true, hideModalHeader: true, - RenderModal: ( { item: template, closeModal } ) => { - const { removeTemplate } = useDispatch( editSiteStore ); + RenderModal: ( { items: templates, closeModal, onPerform } ) => { + const { removeTemplates } = unlock( useDispatch( editSiteStore ) ); return ( - { sprintf( - // translators: %s: The template or template part's title. - __( 'Are you sure you want to delete "%s"?' ), - decodeEntities( template.title.rendered ) - ) } + { templates.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Delete %d item?', + 'Delete %d items?', + templates.length + ), + templates.length + ) + : sprintf( + // translators: %s: The template or template part's titles + __( 'Delete "%s"?' ), + decodeEntities( + templates?.[ 0 ]?.title?.rendered + ) + ) } @@ -126,7 +169,8 @@ export const renameTemplateAction = { label: __( 'Rename' ), isEligible: ( template ) => isTemplateRemovable( template ) && template.is_custom, - RenderModal: ( { item: template, closeModal } ) => { + RenderModal: ( { items: templates, closeModal } ) => { + const template = templates[ 0 ]; const title = decodeEntities( template.title.rendered ); const [ editedTitle, setEditedTitle ] = useState( title ); const { diff --git a/packages/edit-site/src/components/revisions/index.js b/packages/edit-site/src/components/revisions/index.js index 3d697880895797..bb2cc8feabb934 100644 --- a/packages/edit-site/src/components/revisions/index.js +++ b/packages/edit-site/src/components/revisions/index.js @@ -78,7 +78,7 @@ function Revisions( { userConfig, blocks } ) { { // Forming a "block formatting context" to prevent margin collapsing. // @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context - `.is-root-container { display: flow-root; } body { position: relative; padding: 32px; }` + `.is-root-container { display: flow-root; }` } diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js index 5c1bcc1df281e0..2840191b4849f1 100644 --- a/packages/edit-site/src/components/save-panel/index.js +++ b/packages/edit-site/src/components/save-panel/index.js @@ -23,10 +23,8 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as editSiteStore } from '../../store'; import { unlock } from '../../lock-unlock'; import { useActivateTheme } from '../../utils/use-activate-theme'; -import { - currentlyPreviewingTheme, - isPreviewingTheme, -} from '../../utils/is-previewing-theme'; +import { useActualCurrentTheme } from '../../utils/use-actual-current-theme'; +import { isPreviewingTheme } from '../../utils/is-previewing-theme'; const { EntitiesSavedStatesExtensible } = unlock( privateApis ); @@ -39,19 +37,22 @@ const EntitiesSavedStatesForPreview = ( { onClose } ) => { activateSaveLabel = __( 'Activate' ); } - const themeName = useSelect( ( select ) => { - const theme = select( coreStore ).getTheme( - currentlyPreviewingTheme() - ); + const currentTheme = useActualCurrentTheme(); - return theme?.name?.rendered; - }, [] ); + const previewingTheme = useSelect( + ( select ) => select( coreStore ).getCurrentTheme(), + [] + ); const additionalPrompt = (

      { sprintf( - 'Saving your changes will change your active theme to %s.', - themeName + /* translators: %1$s: The name of active theme, %2$s: The name of theme to be activated. */ + __( + 'Saving your changes will change your active theme from %1$s to %2$s.' + ), + currentTheme?.name?.rendered ?? '...', + previewingTheme?.name?.rendered ?? '...' ) }

      ); diff --git a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js index d84d85faf4d60a..4bf7a173525c90 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js +++ b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js @@ -19,7 +19,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; * Internal dependencies */ import SidebarNavigationItem from '../sidebar-navigation-item'; -import DEFAULT_VIEWS from './default-views'; +import { DEFAULT_VIEWS } from './default-views'; import { unlock } from '../../lock-unlock'; const { useHistory, useLocation } = unlock( routerPrivateApis ); diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js index 11652286e62d8d..fe9f046f31972f 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js +++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js @@ -7,10 +7,27 @@ import { trash } from '@wordpress/icons'; /** * Internal dependencies */ -import { LAYOUT_TABLE, OPERATOR_IN } from '../../utils/constants'; +import { + LAYOUT_LIST, + LAYOUT_TABLE, + LAYOUT_GRID, + OPERATOR_IN, +} from '../../utils/constants'; + +export const DEFAULT_CONFIG_PER_VIEW_TYPE = { + [ LAYOUT_TABLE ]: {}, + [ LAYOUT_GRID ]: { + mediaField: 'featured-image', + primaryField: 'title', + }, + [ LAYOUT_LIST ]: { + primaryField: 'title', + mediaField: 'featured-image', + }, +}; const DEFAULT_PAGE_BASE = { - type: LAYOUT_TABLE, + type: LAYOUT_LIST, search: '', filters: [], page: 1, @@ -22,10 +39,12 @@ const DEFAULT_PAGE_BASE = { // All fields are visible by default, so it's // better to keep track of the hidden ones. hiddenFields: [ 'date', 'featured-image' ], - layout: {}, + layout: { + ...DEFAULT_CONFIG_PER_VIEW_TYPE[ LAYOUT_LIST ], + }, }; -const DEFAULT_VIEWS = { +export const DEFAULT_VIEWS = { page: [ { title: __( 'All' ), @@ -55,5 +74,3 @@ const DEFAULT_VIEWS = { }, ], }; - -export default DEFAULT_VIEWS; diff --git a/packages/edit-site/src/components/sidebar-dataviews/index.js b/packages/edit-site/src/components/sidebar-dataviews/index.js index 9e4534ab342745..9748600907e331 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/index.js +++ b/packages/edit-site/src/components/sidebar-dataviews/index.js @@ -8,14 +8,14 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; * Internal dependencies */ -import { default as DEFAULT_VIEWS } from './default-views'; +import { DEFAULT_VIEWS } from './default-views'; import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); import DataViewItem from './dataview-item'; import CustomDataViewsList from './custom-dataviews-list'; const PATH_TO_TYPE = { - '/pages': 'page', + '/page': 'page', }; export default function DataViewsSidebarContent() { diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js index 110bc920fb0a9f..7df1aaa3ba9084 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js @@ -31,7 +31,7 @@ import SidebarNavigationScreenDetailsFooter from '../sidebar-navigation-screen-d const { useHistory } = unlock( routerPrivateApis ); -export default function SidebarNavigationScreenPage() { +export default function SidebarNavigationScreenPage( { backPath } ) { const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); const history = useHistory(); const { @@ -88,6 +88,7 @@ export default function SidebarNavigationScreenPage() { return record ? ( { + const linkInfo = useLink( + { + postType, + postId, + }, + { + backPath: '/page', + } + ); + return ; +}; + +export default function SidebarNavigationScreenPagesDataViews() { + const { records: templateRecords } = useEntityRecords( + 'postType', + TEMPLATE_POST_TYPE, + { + per_page: -1, + } + ); + + const { frontPage, postsPage } = useSelect( ( select ) => { + const { getEntityRecord } = select( coreStore ); + const siteSettings = getEntityRecord( 'root', 'site' ); + return { + frontPage: siteSettings?.page_on_front, + postsPage: siteSettings?.page_for_posts, + }; + }, [] ); + + const templates = useMemo( () => { + if ( ! templateRecords ) { + return []; + } + + const isHomePageBlog = frontPage === postsPage; + const homeTemplate = + templateRecords?.find( + ( template ) => template.slug === 'front-page' + ) || + templateRecords?.find( ( template ) => template.slug === 'home' ) || + templateRecords?.find( ( template ) => template.slug === 'index' ); + + return [ + isHomePageBlog ? homeTemplate : null, + ...templateRecords?.filter( ( { slug } ) => + [ '404', 'search' ].includes( slug ) + ), + ].filter( Boolean ); + }, [ templateRecords, frontPage, postsPage ] ); + + return ( + } + footer={ + + { templates?.map( ( item ) => ( + + + { decodeEntities( + item.title?.rendered || __( '(no title)' ) + ) } + + + ) ) } + + } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js index 527cb37ceddaf7..3ff934ac100a88 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js @@ -4,6 +4,7 @@ import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, + __experimentalVStack as VStack, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useEntityRecords } from '@wordpress/core-data'; @@ -30,20 +31,11 @@ const TemplateItem = ( { postType, postId, ...props } ) => { export default function SidebarNavigationScreenTemplates() { const isMobileViewport = useViewportMatch( 'medium', '<' ); - const { records: templates, isResolving: isLoading } = useEntityRecords( 'postType', TEMPLATE_POST_TYPE, - { - per_page: -1, - } - ); - - const sortedTemplates = templates ? [ ...templates ] : []; - sortedTemplates.sort( ( a, b ) => - a.title.rendered.localeCompare( b.title.rendered ) + { per_page: -1 } ); - const browseAllLink = useLink( { path: '/wp_template/all' } ); const canCreate = ! isMobileViewport; return ( @@ -66,24 +58,7 @@ export default function SidebarNavigationScreenTemplates() { <> { isLoading && __( 'Loading templates…' ) } { ! isLoading && ( - - { ! templates?.length && ( - { __( 'No templates found' ) } - ) } - { sortedTemplates.map( ( template ) => ( - - { decodeEntities( - template.title?.rendered || - template.slug - ) } - - ) ) } - + ) } } @@ -97,3 +72,85 @@ export default function SidebarNavigationScreenTemplates() { /> ); } + +function TemplatesGroup( { title, templates } ) { + return ( + + { !! title && ( + + { title } + + ) } + { templates.map( ( template ) => ( + + { decodeEntities( + template.title?.rendered || template.slug + ) } + + ) ) } + + ); +} +function SidebarTemplatesList( { templates } ) { + if ( ! templates?.length ) { + return ( + + { __( 'No templates found' ) } + + ); + } + const sortedTemplates = templates ? [ ...templates ] : []; + sortedTemplates.sort( ( a, b ) => + a.title.rendered.localeCompare( b.title.rendered ) + ); + const { hierarchyTemplates, customTemplates, ...plugins } = + sortedTemplates.reduce( + ( accumulator, template ) => { + const { + original_source: originalSource, + author_text: authorText, + } = template; + if ( originalSource === 'plugin' ) { + if ( ! accumulator[ authorText ] ) { + accumulator[ authorText ] = []; + } + accumulator[ authorText ].push( template ); + } else if ( template.is_custom ) { + accumulator.customTemplates.push( template ); + } else { + accumulator.hierarchyTemplates.push( template ); + } + return accumulator; + }, + { hierarchyTemplates: [], customTemplates: [] } + ); + return ( + + { !! hierarchyTemplates.length && ( + + ) } + { !! customTemplates.length && ( + + ) } + { Object.entries( plugins ).map( + ( [ plugin, pluginTemplates ] ) => { + return ( + + ); + } + ) } + + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss new file mode 100644 index 00000000000000..ec2b7744d4e233 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates/style.scss @@ -0,0 +1,9 @@ +.edit-site-sidebar-navigation-screen-templates__templates-group-title.components-item { + text-transform: uppercase; + color: $gray-200; + // 6px right padding to align with + button + padding: $grid-unit-30 6px $grid-unit-20 $grid-unit-20; + border-top: 1px solid $gray-800; + font-size: 11px; + font-weight: 500; +} diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js index 3fa1280d59f427..73c6aea7e328c5 100644 --- a/packages/edit-site/src/components/sidebar/index.js +++ b/packages/edit-site/src/components/sidebar/index.js @@ -7,7 +7,6 @@ import classNames from 'classnames'; * WordPress dependencies */ import { memo, useRef } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; import { __experimentalNavigatorProvider as NavigatorProvider, __experimentalNavigatorScreen as NavigatorScreen, @@ -32,9 +31,8 @@ import SidebarNavigationScreenTemplatesBrowse from '../sidebar-navigation-screen import SaveHub from '../save-hub'; import { unlock } from '../../lock-unlock'; import SidebarNavigationScreenPages from '../sidebar-navigation-screen-pages'; +import SidebarNavigationScreenPagesDataViews from '../sidebar-navigation-screen-pages-dataviews'; import SidebarNavigationScreenPage from '../sidebar-navigation-screen-page'; -import SidebarNavigationScreen from '../sidebar-navigation-screen'; -import DataViewsSidebarContent from '../sidebar-dataviews'; const { useLocation } = unlock( routerPrivateApis ); @@ -68,20 +66,15 @@ function SidebarScreens() { - + { window?.__experimentalAdminViews ? ( + + ) : ( + + ) } - { window?.__experimentalAdminViews && ( - - } - /> - - ) } diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index 5a8adad8e198b8..e7f2671784e1d0 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -5,7 +5,7 @@ import apiFetch from '@wordpress/api-fetch'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; import { addQueryArgs } from '@wordpress/url'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as interfaceStore } from '@wordpress/interface'; @@ -13,7 +13,6 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as editorStore } from '@wordpress/editor'; import { speak } from '@wordpress/a11y'; import { store as preferencesStore } from '@wordpress/preferences'; -import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -25,6 +24,8 @@ import { TEMPLATE_PART_POST_TYPE, NAVIGATION_POST_TYPE, } from '../utils/constants'; +import { removeTemplates } from './private-actions'; + /** * Dispatches an action that toggles a feature flag. * @@ -133,54 +134,9 @@ export const addTemplate = * * @param {Object} template The template object. */ -export const removeTemplate = - ( template ) => - async ( { registry } ) => { - try { - await registry - .dispatch( coreStore ) - .deleteEntityRecord( 'postType', template.type, template.id, { - force: true, - } ); - - const lastError = registry - .select( coreStore ) - .getLastEntityDeleteError( - 'postType', - template.type, - template.id - ); - - if ( lastError ) { - throw lastError; - } - - // Depending on how the entity was retrieved it's title might be - // an object or simple string. - const templateTitle = - typeof template.title === 'string' - ? template.title - : template.title?.rendered; - - registry.dispatch( noticesStore ).createSuccessNotice( - sprintf( - /* translators: The template/part's name. */ - __( '"%s" deleted.' ), - decodeEntities( templateTitle ) - ), - { type: 'snackbar', id: 'site-editor-template-deleted-success' } - ); - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( 'An error occurred while deleting the template.' ); - - registry - .dispatch( noticesStore ) - .createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - }; +export const removeTemplate = ( template ) => { + return removeTemplates( [ template ] ); +}; /** * Action that sets a template part. diff --git a/packages/edit-site/src/store/private-actions.js b/packages/edit-site/src/store/private-actions.js index 7354f7b9b8843a..71f35dc66399ee 100644 --- a/packages/edit-site/src/store/private-actions.js +++ b/packages/edit-site/src/store/private-actions.js @@ -4,6 +4,10 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as preferencesStore } from '@wordpress/preferences'; import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import { __, sprintf } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Action that switches the canvas mode. @@ -49,3 +53,102 @@ export const setEditorCanvasContainerView = view, } ); }; + +/** + * Action that removes an array of templates. + * + * @param {Array} templates An array of template objects to remove. + */ +export const removeTemplates = + ( templates ) => + async ( { registry } ) => { + const promiseResult = await Promise.allSettled( + templates.map( ( template ) => { + return registry + .dispatch( coreStore ) + .deleteEntityRecord( + 'postType', + template.type, + template.id, + { force: true }, + { throwOnError: true } + ); + } ) + ); + + // If all the promises were fulfilled with sucess. + if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + + if ( templates.length === 1 ) { + // Depending on how the entity was retrieved its title might be + // an object or simple string. + const templateTitle = + typeof templates[ 0 ].title === 'string' + ? templates[ 0 ].title + : templates[ 0 ].title?.rendered; + successMessage = sprintf( + /* translators: The template/part's name. */ + __( '"%s" deleted.' ), + decodeEntities( templateTitle ) + ); + } else { + successMessage = __( 'Templates deleted.' ); + } + + registry + .dispatch( noticesStore ) + .createSuccessNotice( successMessage, { + type: 'snackbar', + id: 'site-editor-template-deleted-success', + } ); + } else { + // If there was at lease one failure. + let errorMessage; + // If we were trying to delete a single template. + if ( promiseResult.length === 1 ) { + if ( promiseResult[ 0 ].reason?.message ) { + errorMessage = promiseResult[ 0 ].reason.message; + } else { + errorMessage = __( + 'An error occurred while deleting the template.' + ); + } + // If we were trying to delete a multiple templates + } else { + const errorMessages = new Set(); + const failedPromises = promiseResult.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + if ( failedPromise.reason?.message ) { + errorMessages.add( failedPromise.reason.message ); + } + } + if ( errorMessages.size === 0 ) { + errorMessage = __( + 'An error occurred while deleting the templates.' + ); + } else if ( errorMessages.size === 1 ) { + errorMessage = sprintf( + /* translators: %s: an error message */ + __( + 'An error occurred while deleting the templates: %s' + ), + [ ...errorMessages ][ 0 ] + ); + } else { + errorMessage = sprintf( + /* translators: %s: a list of comma separated error messages */ + __( + 'Some errors occurred while deleting the templates: %s' + ), + [ ...errorMessages ].join( ',' ) + ); + } + } + registry + .dispatch( noticesStore ) + .createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index c7d0609b4e771c..164a8523b19628 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -34,6 +34,7 @@ @import "./components/sidebar-navigation-screen-details-footer/style.scss"; @import "./components/sidebar-navigation-screen-navigation-menu/style.scss"; @import "./components/sidebar-navigation-screen-page/style.scss"; +@import "./components/sidebar-navigation-screen-templates/style.scss"; @import "components/sidebar-navigation-screen-details-panel/style.scss"; @import "./components/sidebar-navigation-screen-pattern/style.scss"; @import "./components/sidebar-navigation-screen-patterns/style.scss"; diff --git a/packages/edit-site/src/utils/get-is-list-page.js b/packages/edit-site/src/utils/get-is-list-page.js index 2ee661253cf063..9530cd85bf04b4 100644 --- a/packages/edit-site/src/utils/get-is-list-page.js +++ b/packages/edit-site/src/utils/get-is-list-page.js @@ -14,9 +14,8 @@ export default function getIsListPage( isMobileViewport ) { return ( - [ '/wp_template/all', '/wp_template_part/all', '/pages' ].includes( - path - ) || + [ '/wp_template/all', '/wp_template_part/all' ].includes( path ) || + ( path === '/page' && window?.__experimentalAdminViews ) || ( path === '/patterns' && // Don't treat "/patterns" without categoryType and categoryId as a // list page in mobile because the sidebar covers the whole page. diff --git a/packages/edit-site/src/utils/use-actual-current-theme.js b/packages/edit-site/src/utils/use-actual-current-theme.js new file mode 100644 index 00000000000000..6f8310c2f7de18 --- /dev/null +++ b/packages/edit-site/src/utils/use-actual-current-theme.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect } from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; + +const ACTIVE_THEMES_URL = '/wp/v2/themes?status=active'; + +export function useActualCurrentTheme() { + const [ currentTheme, setCurrentTheme ] = useState(); + + useEffect( () => { + // Set the `wp_theme_preview` to empty string to bypass the createThemePreviewMiddleware. + const path = addQueryArgs( ACTIVE_THEMES_URL, { + context: 'edit', + wp_theme_preview: '', + } ); + + apiFetch( { path } ) + .then( ( activeThemes ) => setCurrentTheme( activeThemes[ 0 ] ) ) + // Do nothing + .catch( () => {} ); + }, [] ); + + return currentTheme; +} diff --git a/packages/edit-widgets/src/components/header/document-tools/index.js b/packages/edit-widgets/src/components/header/document-tools/index.js index a9799ac993f9ab..4391ece0b89e26 100644 --- a/packages/edit-widgets/src/components/header/document-tools/index.js +++ b/packages/edit-widgets/src/components/header/document-tools/index.js @@ -83,6 +83,7 @@ function DocumentTools() { className="edit-widgets-header-toolbar" aria-label={ __( 'Document tools' ) } shouldUseKeyboardFocusShortcut={ ! blockToolbarCanBeFocused } + variant="unstyled" > { isMediumViewport && ( <> - - + + ); } + +export default forwardRef( RedoButton ); diff --git a/packages/edit-widgets/src/components/header/undo-redo/undo.js b/packages/edit-widgets/src/components/header/undo-redo/undo.js index 827ed1a415d74b..271c73a452d9ea 100644 --- a/packages/edit-widgets/src/components/header/undo-redo/undo.js +++ b/packages/edit-widgets/src/components/header/undo-redo/undo.js @@ -2,20 +2,23 @@ * WordPress dependencies */ import { __, isRTL } from '@wordpress/i18n'; -import { ToolbarButton } from '@wordpress/components'; +import { Button } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { undo as undoIcon, redo as redoIcon } from '@wordpress/icons'; import { displayShortcut } from '@wordpress/keycodes'; import { store as coreStore } from '@wordpress/core-data'; +import { forwardRef } from '@wordpress/element'; -export default function UndoButton() { +function UndoButton( props, ref ) { const hasUndo = useSelect( ( select ) => select( coreStore ).hasUndo(), [] ); const { undo } = useDispatch( coreStore ); return ( - ); } + +export default forwardRef( UndoButton ); diff --git a/packages/editor/src/components/editor-canvas/style.scss b/packages/editor/src/components/editor-canvas/style.scss deleted file mode 100644 index d5baf480124523..00000000000000 --- a/packages/editor/src/components/editor-canvas/style.scss +++ /dev/null @@ -1,5 +0,0 @@ -.editor-canvas__iframe { - &.has-history { - padding: $grid-unit-60 $grid-unit-60 0; - } -} diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index b5f3ea5b433e1c..8e531ce5801017 100644 --- a/packages/editor/src/components/entities-saved-states/index.js +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -213,13 +213,13 @@ export function EntitiesSavedStatesExtensible( { { __( 'Are you ready to save?' ) } { additionalPrompt } - { isDirty && ( -

      - { __( - 'The following changes have been made to your site, templates, and content.' - ) } -

      - ) } +

      + { isDirty + ? __( + 'The following changes have been made to your site, templates, and content.' + ) + : __( 'Select the items you want to save.' ) } +

{ sortedPartitionedSavables.map( ( list ) => { diff --git a/packages/editor/src/components/post-visibility/check.js b/packages/editor/src/components/post-visibility/check.js index 4bf9bd03772da6..116db0f546de2b 100644 --- a/packages/editor/src/components/post-visibility/check.js +++ b/packages/editor/src/components/post-visibility/check.js @@ -1,26 +1,21 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; -export function PostVisibilityCheck( { hasPublishAction, render } ) { - const canEdit = hasPublishAction; +export default function PostVisibilityCheck( { render } ) { + const canEdit = useSelect( ( select ) => { + return ( + select( editorStore ).getCurrentPost()._links?.[ + 'wp:action-publish' + ] ?? false + ); + } ); + return render( { canEdit } ); } - -export default compose( [ - withSelect( ( select ) => { - const { getCurrentPost, getCurrentPostType } = select( editorStore ); - return { - hasPublishAction: - getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false, - postType: getCurrentPostType(), - }; - } ), -] )( PostVisibilityCheck ); diff --git a/packages/editor/src/components/post-visibility/test/check.js b/packages/editor/src/components/post-visibility/test/check.js index 8ec0c2df04ec90..828e876cceb102 100644 --- a/packages/editor/src/components/post-visibility/test/check.js +++ b/packages/editor/src/components/post-visibility/test/check.js @@ -3,32 +3,43 @@ */ import { render, screen } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); + /** * Internal dependencies */ -import { PostVisibilityCheck } from '../check'; +import PostVisibilityCheck from '../check'; + +function setupMockSelect( hasPublishAction ) { + useSelect.mockImplementation( ( mapSelect ) => { + return mapSelect( () => ( { + getCurrentPost: () => ( { + _links: { + 'wp:action-publish': hasPublishAction, + }, + } ), + } ) ); + } ); +} describe( 'PostVisibilityCheck', () => { const renderProp = ( { canEdit } ) => ( canEdit ? 'yes' : 'no' ); it( "should not render the edit link if the user doesn't have the right capability", () => { - render( - - ); + setupMockSelect( false ); + render( ); expect( screen.queryByText( 'yes' ) ).not.toBeInTheDocument(); expect( screen.getByText( 'no' ) ).toBeVisible(); } ); it( 'should render if the user has the correct capability', () => { - render( - - ); + setupMockSelect( true ); + render( ); expect( screen.queryByText( 'no' ) ).not.toBeInTheDocument(); expect( screen.getByText( 'yes' ) ).toBeVisible(); } ); diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js index 40bd1e16dfc00d..a940890dfa693a 100644 --- a/packages/editor/src/hooks/pattern-partial-syncing.js +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -5,7 +5,6 @@ import { addFilter } from '@wordpress/hooks'; import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useBlockEditingMode } from '@wordpress/block-editor'; -import { hasBlockSupport } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; /** @@ -32,11 +31,6 @@ const { const withPartialSyncingControls = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const blockEditingMode = useBlockEditingMode(); - const hasCustomFieldsSupport = hasBlockSupport( - props.name, - '__experimentalConnections', - false - ); const isEditingPattern = useSelect( ( select ) => select( editorStore ).getCurrentPostType() === @@ -45,7 +39,6 @@ const withPartialSyncingControls = createHigherOrderComponent( ); const shouldShowPartialSyncingControls = - hasCustomFieldsSupport && props.isSelected && isEditingPattern && blockEditingMode === 'default' && diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index ff5a55a3881f99..09e50d1abed79a 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -29,4 +29,3 @@ @import "./components/preview-dropdown/style.scss"; @import "./components/table-of-contents/style.scss"; @import "./components/template-validation-notice/style.scss"; -@import "./components/editor-canvas/style.scss"; diff --git a/packages/interactivity/docs/1-getting-started.md b/packages/interactivity/docs/1-getting-started.md index 85af2021807351..660671a8b10cd8 100644 --- a/packages/interactivity/docs/1-getting-started.md +++ b/packages/interactivity/docs/1-getting-started.md @@ -26,18 +26,6 @@ We can scaffold a WordPress plugin that registers an interactive block (using th npx @wordpress/create-block@latest my-first-interactive-block --template @wordpress/create-block-interactive-template ``` -> **Note** -> The Interactivity API recently switched from [using modules instead of scripts in the frontend](https://github.com/WordPress/gutenberg/pull/56143). Therefore, in order to test this scaffolded block, you will need to add the following line to the `package.json` file of the generated plugin: - -```json -"files": [ - "src/view.js" -] -``` -> This should be updated in the [scripts package](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/) soon. - - - #### 2. Generate the build When the plugin folder is generated, we should launch the build process to get the final version of the interactive block that can be used from WordPress. @@ -61,7 +49,7 @@ At this point you should be able to insert the "My First Interactive Block" bloc ## Requirements of the Interactivity API -To start working with the Interactivity API you'll need to have a [proper WordPress development environment for blocks](https://developer.wordpress.org/block-editor/getting-started/devenv/) and some specific code in your block, which should include: +To start working with the Interactivity API you'll need to have a [proper WordPress development environment for blocks](https://developer.wordpress.org/block-editor/getting-started/devenv/) and some specific code in your block, which should include: #### A local WordPress installation @@ -71,7 +59,7 @@ To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) #### Latest vesion of Gutenberg -The Interactivity API is currently only available as an experimental feature from Gutenberg 17.2, so you'll need to have Gutenberg 17.2 or higher version installed and activated in your WordPress installation. +The Interactivity API is currently only available as an experimental feature from Gutenberg, so you'll need to have Gutenberg 17.5 or higher version installed and activated in your WordPress installation. #### Node.js diff --git a/packages/interface/lock-unlock.js b/packages/interface/lock-unlock.js new file mode 100644 index 00000000000000..b6e29bb71c7c02 --- /dev/null +++ b/packages/interface/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.', + '@wordpress/interface' + ); diff --git a/packages/interface/package.json b/packages/interface/package.json index df3d53990e0f59..7356a7b52d1d45 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -42,6 +42,7 @@ "@wordpress/icons": "file:../icons", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", + "@wordpress/private-apis": "file:../private-apis", "@wordpress/viewport": "file:../viewport", "classnames": "^2.3.1" }, diff --git a/packages/interface/src/components/pinned-items/style.scss b/packages/interface/src/components/pinned-items/style.scss index 66062b7fa3dbbf..420b94e2994b16 100644 --- a/packages/interface/src/components/pinned-items/style.scss +++ b/packages/interface/src/components/pinned-items/style.scss @@ -27,7 +27,4 @@ // Gap between pinned items. gap: $grid-unit-10; - - // Account for larger grid from parent container gap. - margin-right: -$grid-unit-05; } diff --git a/packages/interface/src/components/preferences-modal-tabs/index.js b/packages/interface/src/components/preferences-modal-tabs/index.js index bc8f7358b834d4..985d963227257f 100644 --- a/packages/interface/src/components/preferences-modal-tabs/index.js +++ b/packages/interface/src/components/preferences-modal-tabs/index.js @@ -13,15 +13,22 @@ import { __experimentalText as Text, __experimentalTruncate as Truncate, FlexItem, - TabPanel, Card, CardHeader, CardBody, + privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { useMemo, useCallback, useState } from '@wordpress/element'; +import { useMemo, useState } from '@wordpress/element'; import { chevronLeft, chevronRight, Icon } from '@wordpress/icons'; import { isRTL, __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { unlock } from '../../../lock-unlock'; + +const { Tabs } = unlock( componentsPrivateApis ); + const PREFERENCES_MENU = 'preferences-menu'; export default function PreferencesModalTabs( { sections } ) { @@ -32,7 +39,7 @@ export default function PreferencesModalTabs( { sections } ) { const [ activeMenu, setActiveMenu ] = useState( PREFERENCES_MENU ); /** * Create helper objects from `sections` for easier data handling. - * `tabs` is used for creating the `TabPanel` and `sectionsContentMap` + * `tabs` is used for creating the `Tabs` and `sectionsContentMap` * is used for easier access to active tab's content. */ const { tabs, sectionsContentMap } = useMemo( () => { @@ -53,26 +60,41 @@ export default function PreferencesModalTabs( { sections } ) { return mappedTabs; }, [ sections ] ); - const getCurrentTab = useCallback( - ( tab ) => sectionsContentMap[ tab.name ] || null, - [ sectionsContentMap ] - ); - let modalContent; // We render different components based on the viewport size. if ( isLargeViewport ) { modalContent = ( - - { getCurrentTab } - +
+ + + { tabs.map( ( tab ) => ( + + { tab.title } + + ) ) } + + { tabs.map( ( tab ) => ( + + { sectionsContentMap[ tab.name ] || null } + + ) ) } + +
); } else { modalContent = ( diff --git a/packages/interface/src/components/preferences-modal-tabs/style.scss b/packages/interface/src/components/preferences-modal-tabs/style.scss index 04b71f0a773a20..f598545d69c250 100644 --- a/packages/interface/src/components/preferences-modal-tabs/style.scss +++ b/packages/interface/src/components/preferences-modal-tabs/style.scss @@ -1,45 +1,44 @@ $vertical-tabs-width: 160px; -.interface-preferences__tabs { - .components-tab-panel__tabs { - position: absolute; - top: $header-height + $grid-unit-30; - // Aligns button text instead of button box. - left: $grid-unit-20; - width: $vertical-tabs-width; - - .components-tab-panel__tabs-item { - border-radius: $radius-block-ui; - font-weight: 400; - - &.is-active { - background: $gray-100; - box-shadow: none; - font-weight: 500; - } - - &.is-active::after { - content: none; - } - - &:focus:not(:disabled) { - box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); - // Windows high contrast mode. - outline: 2px solid transparent; - } - - &:focus-visible::before { - content: none; - } - } +.interface-preferences__tabs-tablist { + position: absolute; + top: $header-height + $grid-unit-30; + // Aligns button text instead of button box. + left: $grid-unit-20; + width: $vertical-tabs-width; + +} + +.interface-preferences__tabs-tab { + border-radius: $radius-block-ui; + font-weight: 400; + + &[aria-selected="true"] { + background: $gray-100; + box-shadow: none; + font-weight: 500; + } + + &[aria-selected="true"]::after { + content: none; + } + + &[role="tab"]:focus:not(:disabled) { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + // Windows high contrast mode. + outline: 2px solid transparent; } - .components-tab-panel__tab-content { - padding-left: $grid-unit-30; - margin-left: $vertical-tabs-width; + &:focus-visible::before { + content: none; } } +.interface-preferences__tabs-tabpanel { + padding-left: $grid-unit-30; + margin-left: $vertical-tabs-width; +} + @media (max-width: #{ ($break-medium - 1) }) { // Keep the navigator component from overflowing the modal content area // to ensure that sticky position elements stick where intended. diff --git a/packages/patterns/src/components/create-pattern-modal.js b/packages/patterns/src/components/create-pattern-modal.js index e69390b9997521..137c14222ced34 100644 --- a/packages/patterns/src/components/create-pattern-modal.js +++ b/packages/patterns/src/components/create-pattern-modal.js @@ -77,24 +77,27 @@ export function CreatePatternModalContents( { const categoryMap = useMemo( () => { // Merge the user and core pattern categories and remove any duplicates. const uniqueCategories = new Map(); - [ ...userPatternCategories, ...corePatternCategories ].forEach( - ( category ) => { - if ( - ! uniqueCategories.has( category.label ) && - // There are two core categories with `Post` label so explicitly remove the one with - // the `query` slug to avoid any confusion. - category.name !== 'query' - ) { - // We need to store the name separately as this is used as the slug in the - // taxonomy and may vary from the label. - uniqueCategories.set( category.label, { - label: category.label, - value: category.label, - name: category.name, - } ); - } + userPatternCategories.forEach( ( category ) => { + uniqueCategories.set( category.label.toLowerCase(), { + label: category.label, + name: category.name, + id: category.id, + } ); + } ); + + corePatternCategories.forEach( ( category ) => { + if ( + ! uniqueCategories.has( category.label.toLowerCase() ) && + // There are two core categories with `Post` label so explicitly remove the one with + // the `query` slug to avoid any confusion. + category.name !== 'query' + ) { + uniqueCategories.set( category.label.toLowerCase(), { + label: category.label, + name: category.name, + } ); } - ); + } ); return uniqueCategories; }, [ userPatternCategories, corePatternCategories ] ); @@ -140,9 +143,13 @@ export function CreatePatternModalContents( { */ async function findOrCreateTerm( term ) { try { - // We need to match any existing term to the correct slug to prevent duplicates, eg. - // the core `Headers` category uses the singular `header` as the slug. - const existingTerm = categoryMap.get( term ); + const existingTerm = categoryMap.get( term.toLowerCase() ); + if ( existingTerm && existingTerm.id ) { + return existingTerm.id; + } + // If we have an existing core category we need to match the new user category to the + // correct slug rather than autogenerating it to prevent duplicates, eg. the core `Headers` + // category uses the singular `header` as the slug. const termData = existingTerm ? { name: existingTerm.label, slug: existingTerm.name } : { name: term }; diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js index d20bd1d347012e..f5ac19bc05f3d7 100644 --- a/packages/patterns/src/components/partial-syncing-controls.js +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -19,7 +19,7 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; const attributeSources = Object.keys( syncedAttributes ).map( ( attributeName ) => - attributes.connections?.attributes?.[ attributeName ]?.source + attributes.metadata?.bindings?.[ attributeName ]?.source?.name ); const isConnectedToOtherSources = attributeSources.every( ( source ) => source && source !== 'pattern_attributes' @@ -30,52 +30,58 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { return null; } - function updateConnections( isChecked ) { - let updatedConnections = { - ...attributes.connections, - attributes: { ...attributes.connections?.attributes }, + function updateBindings( isChecked ) { + let updatedBindings = { + ...attributes?.metadata?.bindings, }; if ( ! isChecked ) { for ( const attributeName of Object.keys( syncedAttributes ) ) { if ( - updatedConnections.attributes[ attributeName ]?.source === + updatedBindings[ attributeName ]?.source?.name === 'pattern_attributes' ) { - delete updatedConnections.attributes[ attributeName ]; + delete updatedBindings[ attributeName ]; } } - if ( ! Object.keys( updatedConnections.attributes ).length ) { - delete updatedConnections.attributes; - } - if ( ! Object.keys( updatedConnections ).length ) { - updatedConnections = undefined; + if ( ! Object.keys( updatedBindings ).length ) { + updatedBindings = undefined; } setAttributes( { - connections: updatedConnections, + metadata: { + ...attributes.metadata, + bindings: updatedBindings, + }, } ); return; } for ( const attributeName of Object.keys( syncedAttributes ) ) { - if ( ! updatedConnections.attributes[ attributeName ] ) { - updatedConnections.attributes[ attributeName ] = { - source: 'pattern_attributes', + if ( ! updatedBindings[ attributeName ] ) { + updatedBindings[ attributeName ] = { + source: { + name: 'pattern_attributes', + }, }; } } if ( typeof attributes.metadata?.id === 'string' ) { - setAttributes( { connections: updatedConnections } ); + setAttributes( { + metadata: { + ...attributes.metadata, + bindings: updatedBindings, + }, + } ); return; } const id = nanoid( 6 ); setAttributes( { - connections: updatedConnections, metadata: { ...attributes.metadata, id, + bindings: updatedBindings, }, } ); } @@ -93,7 +99,7 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { ( source ) => source === 'pattern_attributes' ) } onChange={ ( isChecked ) => { - updateConnections( isChecked ); + updateBindings( isChecked ); } } /> diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js index 619478cf76386d..4195991381d02c 100644 --- a/packages/private-apis/src/implementation.js +++ b/packages/private-apis/src/implementation.js @@ -25,6 +25,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/edit-widgets', '@wordpress/editor', '@wordpress/format-library', + '@wordpress/interface', '@wordpress/patterns', '@wordpress/reusable-blocks', '@wordpress/router', diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 381ce0e93e4454..7a1f6997d2a130 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -11,6 +11,8 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [**] Image block media uploads display a custom error message when there is no internet connection [#56937] +- [*] Fix missing custom color indicator for custom gradients [#57605] +- [**] Display a notice when a network connection unavailable [#56934] ## 1.110.0 - [*] [internal] Move InserterButton from components package to block-editor package [#56494] diff --git a/packages/react-native-editor/__device-tests__/README.md b/packages/react-native-editor/__device-tests__/README.md index e917a297a491c7..719adbbcf94265 100644 --- a/packages/react-native-editor/__device-tests__/README.md +++ b/packages/react-native-editor/__device-tests__/README.md @@ -6,6 +6,7 @@ The Mobile Gutenberg (MG) project maintains a suite of automated end-to-end (E2E 1. Complete the [React Native Getting Started](https://reactnative.dev/docs/environment-setup) guide for both iOS and Android, which covers setting up Xcode, Android Studio, the Android SDK. 1. Open [Xcode settings](https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes#Install-and-manage-Simulator-runtimes-in-settings) to install the iOS 16.2 simulator runtime. +1. Install [`jq`](https://jqlang.github.io/jq/download/) via [Homebrew](https://brew.sh/) or your preferred package manager. 1. `npm run native test:e2e:setup` ## Running Tests diff --git a/packages/scripts/CHANGELOG.md b/packages/scripts/CHANGELOG.md index f6fb412377f7ef..a917cd119c17a1 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- Add experimental support for `viewModule` field in block.json for `build` and `start` scripts ([#57461](https://github.com/WordPress/gutenberg/pull/57461)). + ### Breaking Changes - Drop support for Node.js versions < 18. diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 42a9c563ea77f7..e4a2be8d9a3fae 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -108,6 +108,11 @@ This script automatically use the optimized config but sometimes you may want to - `--webpack-src-dir` – Allows customization of the source code directory. Default is `src`. - `--output-path` – Allows customization of the output directory. Default is `build`. +Experimental support for the block.json `viewModule` field is available via the +`--experimental-modules` option. With this option enabled, script and module fields will all be +compiled. The `viewModule` field is analogous to the `viewScript` field, but will compile a module +and should be registered in WordPress using the Modules API. + #### Advanced information This script uses [webpack](https://webpack.js.org/) behind the scenes. It’ll look for a webpack config in the top-level directory of your package and will use it if it finds one. If none is found, it’ll use the default config provided by `@wordpress/scripts` packages. Learn more in the [Advanced Usage](#advanced-usage) section. @@ -391,6 +396,11 @@ This script automatically use the optimized config but sometimes you may want to - `--webpack-src-dir` – Allows customization of the source code directory. Default is `src`. - `--output-path` – Allows customization of the output directory. Default is `build`. +Experimental support for the block.json `viewModule` field is available via the +`--experimental-modules` option. With this option enabled, script and module fields will all be +compiled. The `viewModule` field is analogous to the `viewScript` field, but will compile a module +and should be registered in WordPress using the Modules API. + #### Advanced information This script uses [webpack](https://webpack.js.org/) behind the scenes. It’ll look for a webpack config in the top-level directory of your package and will use it if it finds one. If none is found, it’ll use the default config provided by `@wordpress/scripts` packages. Learn more in the [Advanced Usage](#advanced-usage) section. @@ -723,8 +733,8 @@ module.exports = { If you follow this approach, please, be aware that: -- You should keep using the `wp-scripts` commands (`start` and `build`). Do not use `webpack` directly. -- Future versions of this package may change what webpack and Babel plugins we bundle, default configs, etc. Should those changes be necessary, they will be registered in the [package’s CHANGELOG](https://github.com/WordPress/gutenberg/blob/HEAD/packages/scripts/CHANGELOG.md), so make sure to read it before upgrading. +- You should keep using the `wp-scripts` commands (`start` and `build`). Do not use `webpack` directly. +- Future versions of this package may change what webpack and Babel plugins we bundle, default configs, etc. Should those changes be necessary, they will be registered in the [package’s CHANGELOG](https://github.com/WordPress/gutenberg/blob/HEAD/packages/scripts/CHANGELOG.md), so make sure to read it before upgrading. ## Contributing to this package diff --git a/packages/scripts/config/webpack.config.js b/packages/scripts/config/webpack.config.js index 1e060d0e142c91..cc76568f6fb1b1 100644 --- a/packages/scripts/config/webpack.config.js +++ b/packages/scripts/config/webpack.config.js @@ -4,7 +4,7 @@ const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' ); const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); -const { DefinePlugin } = require( 'webpack' ); +const webpack = require( 'webpack' ); const browserslist = require( 'browserslist' ); const MiniCSSExtractPlugin = require( 'mini-css-extract-plugin' ); const { basename, dirname, resolve } = require( 'path' ); @@ -30,6 +30,9 @@ const { getWordPressSrcDirectory, getWebpackEntryPoints, getRenderPropPaths, + getAsBooleanFromENV, + getBlockJsonModuleFields, + getBlockJsonScriptFields, } = require( '../utils' ); const isProduction = process.env.NODE_ENV === 'production'; @@ -39,6 +42,9 @@ if ( ! browserslist.findConfig( '.' ) ) { target += ':' + fromConfigRoot( '.browserslistrc' ); } const hasReactFastRefresh = hasArgInCLI( '--hot' ) && ! isProduction; +const hasExperimentalModulesFlag = getAsBooleanFromENV( + 'WP_EXPERIMENTAL_MODULES' +); /** * The plugin recomputes the render paths once on each compilation. It is necessary to avoid repeating processing @@ -110,10 +116,10 @@ const cssLoaders = [ }, ]; -const config = { +/** @type {webpack.Configuration} */ +const baseConfig = { mode, target, - entry: getWebpackEntryPoints, output: { filename: '[name].js', path: resolve( process.cwd(), 'build' ), @@ -165,7 +171,7 @@ const config = { module: { rules: [ { - test: /\.(j|t)sx?$/, + test: /\.m?(j|t)sx?$/, exclude: /node_modules/, use: [ { @@ -245,21 +251,72 @@ const config = { }, ], }, + stats: { + children: false, + }, +}; + +// WP_DEVTOOL global variable controls how source maps are generated. +// See: https://webpack.js.org/configuration/devtool/#devtool. +if ( process.env.WP_DEVTOOL ) { + baseConfig.devtool = process.env.WP_DEVTOOL; +} + +if ( ! isProduction ) { + // Set default sourcemap mode if it wasn't set by WP_DEVTOOL. + baseConfig.devtool = baseConfig.devtool || 'source-map'; +} + +// Add source-map-loader if devtool is set, whether in dev mode or not. +if ( baseConfig.devtool ) { + baseConfig.module.rules.unshift( { + test: /\.(j|t)sx?$/, + exclude: [ /node_modules/ ], + use: require.resolve( 'source-map-loader' ), + enforce: 'pre', + } ); +} + +/** @type {webpack.Configuration} */ +const scriptConfig = { + ...baseConfig, + + entry: getWebpackEntryPoints( 'script' ), + + devServer: isProduction + ? undefined + : { + devMiddleware: { + writeToDisk: true, + }, + allowedHosts: 'auto', + host: 'localhost', + port: 8887, + proxy: { + '/build': { + pathRewrite: { + '^/build': '', + }, + }, + }, + }, + plugins: [ - new DefinePlugin( { + new webpack.DefinePlugin( { // Inject the `SCRIPT_DEBUG` global, used for development features flagging. SCRIPT_DEBUG: ! isProduction, } ), - // During rebuilds, all webpack assets that are not used anymore will be - // removed automatically. There is an exception added in watch mode for - // fonts and images. It is a known limitations: - // https://github.com/johnagan/clean-webpack-plugin/issues/159 - new CleanWebpackPlugin( { - cleanAfterEveryBuildPatterns: [ '!fonts/**', '!images/**' ], - // Prevent it from deleting webpack assets during builds that have - // multiple configurations returned in the webpack config. - cleanStaleWebpackAssets: false, - } ), + + // If we run a modules build, the 2 compilations can "clean" each other's output + // Prevent the cleaning from happening + ! hasExperimentalModulesFlag && + new CleanWebpackPlugin( { + cleanAfterEveryBuildPatterns: [ '!fonts/**', '!images/**' ], + // Prevent it from deleting webpack assets during builds that have + // multiple configurations returned in the webpack config. + cleanStaleWebpackAssets: false, + } ), + new RenderPathsPlugin(), new CopyWebpackPlugin( { patterns: [ @@ -269,27 +326,33 @@ const config = { noErrorOnMissing: true, transform( content, absoluteFrom ) { const convertExtension = ( path ) => { - return path.replace( /\.(j|t)sx?$/, '.js' ); + return path.replace( /\.m?(j|t)sx?$/, '.js' ); }; if ( basename( absoluteFrom ) === 'block.json' ) { const blockJson = JSON.parse( content.toString() ); - [ 'viewScript', 'script', 'editorScript' ].forEach( - ( key ) => { - if ( Array.isArray( blockJson[ key ] ) ) { - blockJson[ key ] = - blockJson[ key ].map( - convertExtension - ); - } else if ( - typeof blockJson[ key ] === 'string' - ) { - blockJson[ key ] = convertExtension( - blockJson[ key ] - ); + + [ + getBlockJsonScriptFields( blockJson ), + getBlockJsonModuleFields( blockJson ), + ].forEach( ( fields ) => { + if ( fields ) { + for ( const [ + key, + value, + ] of Object.entries( fields ) ) { + if ( Array.isArray( value ) ) { + blockJson[ key ] = + value.map( convertExtension ); + } else if ( + typeof value === 'string' + ) { + blockJson[ key ] = + convertExtension( value ); + } } } - ); + } ); return JSON.stringify( blockJson, null, 2 ); } @@ -324,45 +387,52 @@ const config = { ! process.env.WP_NO_EXTERNALS && new DependencyExtractionWebpackPlugin(), ].filter( Boolean ), - stats: { - children: false, - }, }; -// WP_DEVTOOL global variable controls how source maps are generated. -// See: https://webpack.js.org/configuration/devtool/#devtool. -if ( process.env.WP_DEVTOOL ) { - config.devtool = process.env.WP_DEVTOOL; -} +if ( hasExperimentalModulesFlag ) { + /** @type {webpack.Configuration} */ + const moduleConfig = { + ...baseConfig, -if ( ! isProduction ) { - // Set default sourcemap mode if it wasn't set by WP_DEVTOOL. - config.devtool = config.devtool || 'source-map'; - config.devServer = { - devMiddleware: { - writeToDisk: true, + entry: getWebpackEntryPoints( 'module' ), + + experiments: { + ...baseConfig.experiments, + outputModule: true, }, - allowedHosts: 'auto', - host: 'localhost', - port: 8887, - proxy: { - '/build': { - pathRewrite: { - '^/build': '', - }, + + output: { + ...baseConfig.output, + module: true, + chunkFormat: 'module', + environment: { + ...baseConfig.output.environment, + module: true, + }, + library: { + ...baseConfig.output.library, + type: 'module', }, }, + + plugins: [ + new webpack.DefinePlugin( { + // Inject the `SCRIPT_DEBUG` global, used for development features flagging. + SCRIPT_DEBUG: ! isProduction, + } ), + // The WP_BUNDLE_ANALYZER global variable enables a utility that represents + // bundle content as a convenient interactive zoomable treemap. + process.env.WP_BUNDLE_ANALYZER && new BundleAnalyzerPlugin(), + // MiniCSSExtractPlugin to extract the CSS thats gets imported into JavaScript. + new MiniCSSExtractPlugin( { filename: '[name].css' } ), + // WP_NO_EXTERNALS global variable controls whether scripts' assets get + // generated, and the default externals set. + ! process.env.WP_NO_EXTERNALS && + new DependencyExtractionWebpackPlugin(), + ].filter( Boolean ), }; -} -// Add source-map-loader if devtool is set, whether in dev mode or not. -if ( config.devtool ) { - config.module.rules.unshift( { - test: /\.(j|t)sx?$/, - exclude: [ /node_modules/ ], - use: require.resolve( 'source-map-loader' ), - enforce: 'pre', - } ); + module.exports = [ scriptConfig, moduleConfig ]; +} else { + module.exports = scriptConfig; } - -module.exports = config; diff --git a/packages/scripts/scripts/build.js b/packages/scripts/scripts/build.js index 714038fd80ee4e..0eef2afb451bfc 100644 --- a/packages/scripts/scripts/build.js +++ b/packages/scripts/scripts/build.js @@ -12,6 +12,10 @@ const EXIT_ERROR_CODE = 1; process.env.NODE_ENV = process.env.NODE_ENV || 'production'; +if ( hasArgInCLI( '--experimental-modules' ) ) { + process.env.WP_EXPERIMENTAL_MODULES = true; +} + if ( hasArgInCLI( '--webpack-no-externals' ) ) { process.env.WP_NO_EXTERNALS = true; } diff --git a/packages/scripts/scripts/start.js b/packages/scripts/scripts/start.js index cf29709f3eff15..6296192ef302b1 100644 --- a/packages/scripts/scripts/start.js +++ b/packages/scripts/scripts/start.js @@ -10,6 +10,10 @@ const { sync: resolveBin } = require( 'resolve-bin' ); const { getArgFromCLI, getWebpackArgs, hasArgInCLI } = require( '../utils' ); const EXIT_ERROR_CODE = 1; +if ( hasArgInCLI( '--experimental-modules' ) ) { + process.env.WP_EXPERIMENTAL_MODULES = true; +} + if ( hasArgInCLI( '--webpack-no-externals' ) ) { process.env.WP_NO_EXTERNALS = true; } diff --git a/packages/scripts/utils/block-json.js b/packages/scripts/utils/block-json.js new file mode 100644 index 00000000000000..892cc63c889e50 --- /dev/null +++ b/packages/scripts/utils/block-json.js @@ -0,0 +1,41 @@ +const moduleFields = new Set( [ 'viewModule' ] ); +const scriptFields = new Set( [ 'viewScript', 'script', 'editorScript' ] ); + +/** + * @param {Object} blockJson + * @return {null|Record} Fields + */ +function getBlockJsonModuleFields( blockJson ) { + let result = null; + for ( const field of moduleFields ) { + if ( Object.hasOwn( blockJson, field ) ) { + if ( ! result ) { + result = {}; + } + result[ field ] = blockJson[ field ]; + } + } + return result; +} + +/** + * @param {Object} blockJson + * @return {null|Record} Fields + */ +function getBlockJsonScriptFields( blockJson ) { + let result = null; + for ( const field of scriptFields ) { + if ( Object.hasOwn( blockJson, field ) ) { + if ( ! result ) { + result = {}; + } + result[ field ] = blockJson[ field ]; + } + } + return result; +} + +module.exports = { + getBlockJsonModuleFields, + getBlockJsonScriptFields, +}; diff --git a/packages/scripts/utils/config.js b/packages/scripts/utils/config.js index e4e42255f95dd3..8b1bbb1ca50590 100644 --- a/packages/scripts/utils/config.js +++ b/packages/scripts/utils/config.js @@ -17,6 +17,10 @@ const { } = require( './cli' ); const { fromConfigRoot, fromProjectRoot, hasProjectFile } = require( './file' ); const { hasPackageProp } = require( './package' ); +const { + getBlockJsonModuleFields, + getBlockJsonScriptFields, +} = require( './block-json' ); const { log } = console; // See https://babeljs.io/docs/en/config-files#configuration-file-types. @@ -108,7 +112,10 @@ const hasPostCSSConfig = () => */ const getWebpackArgs = () => { // Gets all args from CLI without those prefixed with `--webpack`. - let webpackArgs = getArgsFromCLI( [ '--webpack' ] ); + let webpackArgs = getArgsFromCLI( [ + '--experimental-modules', + '--webpack', + ] ); const hasWebpackOutputOption = hasArgInCLI( '-o' ) || hasArgInCLI( '--output' ); @@ -186,104 +193,52 @@ function getWordPressSrcDirectory() { * * @see https://webpack.js.org/concepts/entry-points/ * - * @return {Object} The list of entry points. + * @param {'script' | 'module'} buildType */ -function getWebpackEntryPoints() { - // 1. Handles the legacy format for entry points when explicitly provided with the `process.env.WP_ENTRY`. - if ( process.env.WP_ENTRY ) { - return JSON.parse( process.env.WP_ENTRY ); - } +function getWebpackEntryPoints( buildType ) { + /** + * @return {Object} The list of entry points. + */ + return () => { + // 1. Handles the legacy format for entry points when explicitly provided with the `process.env.WP_ENTRY`. + if ( process.env.WP_ENTRY ) { + return buildType === 'script' + ? JSON.parse( process.env.WP_ENTRY ) + : {}; + } - // Continue only if the source directory exists. - if ( ! hasProjectFile( getWordPressSrcDirectory() ) ) { - log( - chalk.yellow( - `Source directory "${ getWordPressSrcDirectory() }" was not found. Please confirm there is a "src" directory in the root or the value passed to --webpack-src-dir is correct.` - ) - ); - return {}; - } + // Continue only if the source directory exists. + if ( ! hasProjectFile( getWordPressSrcDirectory() ) ) { + log( + chalk.yellow( + `Source directory "${ getWordPressSrcDirectory() }" was not found. Please confirm there is a "src" directory in the root or the value passed to --webpack-src-dir is correct.` + ) + ); + return {}; + } - // 2. Checks whether any block metadata files can be detected in the defined source directory. - // It scans all discovered files looking for JavaScript assets and converts them to entry points. - const blockMetadataFiles = glob( '**/block.json', { - absolute: true, - cwd: fromProjectRoot( getWordPressSrcDirectory() ), - } ); + // 2. Checks whether any block metadata files can be detected in the defined source directory. + // It scans all discovered files looking for JavaScript assets and converts them to entry points. + const blockMetadataFiles = glob( '**/block.json', { + absolute: true, + cwd: fromProjectRoot( getWordPressSrcDirectory() ), + } ); + + if ( blockMetadataFiles.length > 0 ) { + const srcDirectory = fromProjectRoot( + getWordPressSrcDirectory() + sep + ); + + const entryPoints = {}; - if ( blockMetadataFiles.length > 0 ) { - const srcDirectory = fromProjectRoot( - getWordPressSrcDirectory() + sep - ); - const entryPoints = blockMetadataFiles.reduce( - ( accumulator, blockMetadataFile ) => { + for ( const blockMetadataFile of blockMetadataFiles ) { + const fileContents = readFileSync( blockMetadataFile ); + let parsedBlockJson; // wrapping in try/catch in case the file is malformed // this happens especially when new block.json files are added // at which point they are completely empty and therefore not valid JSON try { - const { editorScript, script, viewScript } = JSON.parse( - readFileSync( blockMetadataFile ) - ); - [ editorScript, script, viewScript ] - .flat() - .filter( - ( value ) => value && value.startsWith( 'file:' ) - ) - .forEach( ( value ) => { - // Removes the `file:` prefix. - const filepath = join( - dirname( blockMetadataFile ), - value.replace( 'file:', '' ) - ); - - // Takes the path without the file extension, and relative to the defined source directory. - if ( ! filepath.startsWith( srcDirectory ) ) { - log( - chalk.yellow( - `Skipping "${ value.replace( - 'file:', - '' - ) }" listed in "${ blockMetadataFile.replace( - fromProjectRoot( sep ), - '' - ) }". File is located outside of the "${ getWordPressSrcDirectory() }" directory.` - ) - ); - return; - } - const entryName = filepath - .replace( extname( filepath ), '' ) - .replace( srcDirectory, '' ) - .replace( /\\/g, '/' ); - - // Detects the proper file extension used in the defined source directory. - const [ entryFilepath ] = glob( - `${ entryName }.[jt]s?(x)`, - { - absolute: true, - cwd: fromProjectRoot( - getWordPressSrcDirectory() - ), - } - ); - - if ( ! entryFilepath ) { - log( - chalk.yellow( - `Skipping "${ value.replace( - 'file:', - '' - ) }" listed in "${ blockMetadataFile.replace( - fromProjectRoot( sep ), - '' - ) }". File does not exist in the "${ getWordPressSrcDirectory() }" directory.` - ) - ); - return; - } - accumulator[ entryName ] = entryFilepath; - } ); - return accumulator; + parsedBlockJson = JSON.parse( fileContents ); } catch ( error ) { chalk.yellow( `Skipping "${ blockMetadataFile.replace( @@ -291,35 +246,105 @@ function getWebpackEntryPoints() { '' ) }" due to malformed JSON.` ); - return accumulator; } - }, - {} - ); - if ( Object.keys( entryPoints ).length > 0 ) { - return entryPoints; + const fields = + buildType === 'script' + ? getBlockJsonScriptFields( parsedBlockJson ) + : getBlockJsonModuleFields( parsedBlockJson ); + + if ( ! fields ) { + continue; + } + + for ( const value of Object.values( fields ).flat() ) { + if ( ! value.startsWith( 'file:' ) ) { + continue; + } + + // Removes the `file:` prefix. + const filepath = join( + dirname( blockMetadataFile ), + value.replace( 'file:', '' ) + ); + + // Takes the path without the file extension, and relative to the defined source directory. + if ( ! filepath.startsWith( srcDirectory ) ) { + log( + chalk.yellow( + `Skipping "${ value.replace( + 'file:', + '' + ) }" listed in "${ blockMetadataFile.replace( + fromProjectRoot( sep ), + '' + ) }". File is located outside of the "${ getWordPressSrcDirectory() }" directory.` + ) + ); + return; + } + const entryName = filepath + .replace( extname( filepath ), '' ) + .replace( srcDirectory, '' ) + .replace( /\\/g, '/' ); + + // Detects the proper file extension used in the defined source directory. + const [ entryFilepath ] = glob( + `${ entryName }.?(m)[jt]s?(x)`, + { + absolute: true, + cwd: fromProjectRoot( getWordPressSrcDirectory() ), + } + ); + + if ( ! entryFilepath ) { + log( + chalk.yellow( + `Skipping "${ value.replace( + 'file:', + '' + ) }" listed in "${ blockMetadataFile.replace( + fromProjectRoot( sep ), + '' + ) }". File does not exist in the "${ getWordPressSrcDirectory() }" directory.` + ) + ); + return; + } + entryPoints[ entryName ] = entryFilepath; + } + } + + if ( Object.keys( entryPoints ).length > 0 ) { + return entryPoints; + } } - } - // 3. Checks whether a standard file name can be detected in the defined source directory, - // and converts the discovered file to entry point. - const [ entryFile ] = glob( 'index.[jt]s?(x)', { - absolute: true, - cwd: fromProjectRoot( getWordPressSrcDirectory() ), - } ); + // Don't do any further processing if this is a module build. + // This only respects *module block.json fields. + if ( buildType === 'module' ) { + return {}; + } - if ( ! entryFile ) { - log( - chalk.yellow( - `No entry file discovered in the "${ getWordPressSrcDirectory() }" directory.` - ) - ); - return {}; - } + // 3. Checks whether a standard file name can be detected in the defined source directory, + // and converts the discovered file to entry point. + const [ entryFile ] = glob( 'index.[jt]s?(x)', { + absolute: true, + cwd: fromProjectRoot( getWordPressSrcDirectory() ), + } ); + + if ( ! entryFile ) { + log( + chalk.yellow( + `No entry file discovered in the "${ getWordPressSrcDirectory() }" directory.` + ) + ); + return {}; + } - return { - index: entryFile, + return { + index: entryFile, + }; }; } diff --git a/packages/scripts/utils/index.js b/packages/scripts/utils/index.js index ae93160381df44..148895ecbc4edf 100644 --- a/packages/scripts/utils/index.js +++ b/packages/scripts/utils/index.js @@ -25,6 +25,10 @@ const { } = require( './config' ); const { fromProjectRoot, fromConfigRoot, hasProjectFile } = require( './file' ); const { getPackageProp, hasPackageProp } = require( './package' ); +const { + getBlockJsonModuleFields, + getBlockJsonScriptFields, +} = require( './block-json' ); module.exports = { fromProjectRoot, @@ -40,6 +44,8 @@ module.exports = { getWordPressSrcDirectory, getWebpackEntryPoints, getRenderPropPaths, + getBlockJsonModuleFields, + getBlockJsonScriptFields, hasArgInCLI, hasBabelConfig, hasCssnanoConfig, diff --git a/patches/react-native+0.71.11.patch b/patches/react-native+0.71.11+001+initial.patch similarity index 100% rename from patches/react-native+0.71.11.patch rename to patches/react-native+0.71.11+001+initial.patch diff --git a/patches/react-native+0.71.11+002+boost-podspec.patch b/patches/react-native+0.71.11+002+boost-podspec.patch new file mode 100644 index 00000000000000..ad785b77a47fcc --- /dev/null +++ b/patches/react-native+0.71.11+002+boost-podspec.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native/third-party-podspecs/boost.podspec b/node_modules/react-native/third-party-podspecs/boost.podspec +index 3d9331c..bbbb738 100644 +--- a/node_modules/react-native/third-party-podspecs/boost.podspec ++++ b/node_modules/react-native/third-party-podspecs/boost.podspec +@@ -10,7 +10,7 @@ Pod::Spec.new do |spec| + spec.homepage = 'http://www.boost.org' + spec.summary = 'Boost provides free peer-reviewed portable C++ source libraries.' + spec.authors = 'Rene Rivera' +- spec.source = { :http => 'https://boostorg.jfrog.io/artifactory/main/release/1.76.0/source/boost_1_76_0.tar.bz2', ++ spec.source = { :http => 'https://archives.boost.io/release/1.76.0/source/boost_1_76_0.tar.bz2', + :sha256 => 'f0397ba6e982c4450f27bf32a2a83292aba035b827a5623a14636ea583318c41' } + + # Pinning to the same version as React.podspec. diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php new file mode 100644 index 00000000000000..a95c3482ec80d1 --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php @@ -0,0 +1,115 @@ +assertEmpty( WP_Interactivity_Initial_State::get_data() ); + } + + public function test_initial_state_can_be_merged() { + $state = array( + 'a' => 1, + 'b' => 2, + 'nested' => array( + 'c' => 3, + ), + ); + WP_Interactivity_Initial_State::merge_state( 'core', $state ); + $this->assertSame( $state, WP_Interactivity_Initial_State::get_state( 'core' ) ); + } + + public function test_initial_state_can_be_extended() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) ); + WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => 1, + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_existing_props_should_be_overwritten() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 'overwritten' ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_existing_indexed_arrays_should_be_replaced() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 1, 2 ) ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 3, 4 ) ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_should_be_correctly_rendered() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) ); + WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) ); + + ob_start(); + WP_Interactivity_Initial_State::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '', + $rendered + ); + } + + public function test_initial_state_should_also_escape_tags_and_amps() { + WP_Interactivity_Initial_State::merge_state( + 'test', + array( + 'amps' => 'http://site.test/?foo=1&baz=2&bar=3', + 'tags' => 'Do not do this: + + + + + + '; + + $html = do_blocks( $post_content ); + $tags = new WP_HTML_Tag_Processor( $html ); + + $tags->next_tag( array( 'class_name' => 'bind-state' ) ); + $this->assertSame( 'state', $tags->get_attribute( 'data-value' ) ); + + $tags->next_tag( array( 'class_name' => 'bind-context' ) ); + $this->assertSame( 'context', $tags->get_attribute( 'data-value' ) ); + } + + public function test_namespace_should_be_inherited_from_same_element() { + /* + * This function call should be done inside block render functions. We + * run it here instead just for conveninence. + */ + wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); + + $post_content = ' + + + + + + + '; + + $html = do_blocks( $post_content ); + $tags = new WP_HTML_Tag_Processor( $html ); + + $tags->next_tag( array( 'class_name' => 'bind-state' ) ); + $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) ); + + $tags->next_tag( array( 'class_name' => 'bind-context' ) ); + $this->assertSame( 'context-2', $tags->get_attribute( 'data-value' ) ); + } + + public function test_namespace_should_not_leak_from_descendant() { + /* + * This function call should be done inside block render functions. We + * run it here instead just for conveninence. + */ + wp_initial_state( 'test-1', array( 'text' => 'state-1' ) ); + wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); + + $post_content = ' + + + + + + '; + + $html = do_blocks( $post_content ); + $tags = new WP_HTML_Tag_Processor( $html ); + + $tags->next_tag( array( 'class_name' => 'target' ) ); + $this->assertSame( 'state-1', $tags->get_attribute( 'data-state' ) ); + $this->assertSame( 'context-1', $tags->get_attribute( 'data-context' ) ); + } + + public function test_namespace_should_not_leak_from_sibling() { + /* + * This function call should be done inside block render functions. We + * run it here instead just for conveninence. + */ + wp_initial_state( 'test-1', array( 'text' => 'state-1' ) ); + wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); + + $post_content = ' + + + + + + + '; + + $html = do_blocks( $post_content ); + $tags = new WP_HTML_Tag_Processor( $html ); + + $tags->next_tag( array( 'class_name' => 'target' ) ); + $this->assertSame( 'state-1', $tags->get_attribute( 'data-from-state' ) ); + $this->assertSame( 'context-1', $tags->get_attribute( 'data-from-context' ) ); + } + + public function test_namespace_can_be_overwritten_in_directives() { + /* + * This function call should be done inside block render functions. We + * run it here instead just for conveninence. + */ + wp_initial_state( 'test-1', array( 'text' => 'state-1' ) ); + wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); + + $post_content = ' + + + + + + '; + + $html = do_blocks( $post_content ); + $tags = new WP_HTML_Tag_Processor( $html ); + + $tags->next_tag( array( 'class_name' => 'inherited-ns' ) ); + $this->assertSame( 'state-1', $tags->get_attribute( 'data-value' ) ); + + $tags->next_tag( array( 'class_name' => 'custom-ns' ) ); + $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) ); + + $tags->next_tag( array( 'class_name' => 'mixed-ns' ) ); + $this->assertSame( 'state-1', $tags->get_attribute( 'data-inherited-ns' ) ); + $this->assertSame( 'state-2', $tags->get_attribute( 'data-custom-ns' ) ); + } } diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php index bfb4c428cd9466..8fe212bb8ed93a 100644 --- a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php +++ b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php @@ -14,16 +14,17 @@ */ class Tests_Directives_WpBind extends WP_UnitTestCase { public function test_directive_sets_attribute() { - $markup = ''; + $markup = ''; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_bind( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns ); $this->assertSame( - '', + '', $tags->get_updated_html() ); $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) ); @@ -31,13 +32,14 @@ public function test_directive_sets_attribute() { } public function test_directive_ignores_empty_bound_attribute() { - $markup = ''; + $markup = ''; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_bind( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns ); $this->assertSame( $markup, $tags->get_updated_html() ); $this->assertNull( $tags->get_attribute( 'src' ) ); diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php index 419546c6d9ef8b..f40486647ff8b8 100644 --- a/phpunit/experimental/interactivity-api/directives/wp-class-test.php +++ b/phpunit/experimental/interactivity-api/directives/wp-class-test.php @@ -14,16 +14,17 @@ */ class Tests_Directives_WpClass extends WP_UnitTestCase { public function test_directive_adds_class() { - $markup = '
Test
'; + $markup = '
Test
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_class( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); $this->assertSame( - '
Test
', + '
Test
', $tags->get_updated_html() ); $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) ); @@ -31,16 +32,17 @@ public function test_directive_adds_class() { } public function test_directive_removes_class() { - $markup = '
Test
'; + $markup = '
Test
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_class( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); $this->assertSame( - '
Test
', + '
Test
', $tags->get_updated_html() ); $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) ); @@ -48,17 +50,18 @@ public function test_directive_removes_class() { } public function test_directive_removes_empty_class_attribute() { - $markup = '
Test
'; + $markup = '
Test
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_class( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); $this->assertSame( // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal. - '
Test
', + '
Test
', $tags->get_updated_html() ); $this->assertNull( $tags->get_attribute( 'class' ) ); @@ -66,16 +69,17 @@ public function test_directive_removes_empty_class_attribute() { } public function test_directive_does_not_remove_non_existant_class() { - $markup = '
Test
'; + $markup = '
Test
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_class( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); $this->assertSame( - '
Test
', + '
Test
', $tags->get_updated_html() ); $this->assertSame( 'green red', $tags->get_attribute( 'class' ) ); @@ -83,13 +87,14 @@ public function test_directive_does_not_remove_non_existant_class() { } public function test_directive_ignores_empty_class_name() { - $markup = '
Test
'; + $markup = '
Test
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_class( $tags, $context ); + $directive_ns = 'myblock'; + gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); $this->assertSame( $markup, $tags->get_updated_html() ); $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) ); diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php index 1277b016848cce..788feec95fe7c5 100644 --- a/phpunit/experimental/interactivity-api/directives/wp-context-test.php +++ b/phpunit/experimental/interactivity-api/directives/wp-context-test.php @@ -21,11 +21,12 @@ public function test_directive_merges_context_correctly_upon_wp_context_attribut ) ); - $markup = '
'; + $ns = 'myblock'; + $markup = '
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, $ns ); $this->assertSame( array( @@ -38,39 +39,39 @@ public function test_directive_merges_context_correctly_upon_wp_context_attribut public function test_directive_resets_context_correctly_upon_closing_tag() { $context = new WP_Directive_Context( - array( 'my-key' => 'original-value' ) + array( 'myblock' => array( 'my-key' => 'original-value' ) ) ); $context->set_context( - array( 'my-key' => 'new-value' ) + array( 'myblock' => array( 'my-key' => 'new-value' ) ) ); $markup = '
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); $this->assertSame( array( 'my-key' => 'original-value' ), - $context->get_context() + $context->get_context()['myblock'] ); } public function test_directive_doesnt_throw_on_malformed_context_objects() { $context = new WP_Directive_Context( - array( 'my-key' => 'some-value' ) + array( 'myblock' => array( 'my-key' => 'some-value' ) ) ); $markup = '
'; $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); } @@ -87,36 +88,36 @@ public function test_directive_keeps_working_after_malformed_context_objects() { // Parent div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Children div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Still the same context. $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Closing children div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Still the same context. $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Closing parent div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Now the context is empty. $this->assertSame( @@ -138,36 +139,36 @@ public function test_directive_keeps_working_with_a_directive_without_value() { // Parent div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Children div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Still the same context. $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Closing children div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Still the same context. $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Closing parent div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Now the context is empty. $this->assertSame( @@ -189,36 +190,36 @@ public function test_directive_keeps_working_with_an_empty_directive() { // Parent div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Children div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Still the same context. $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Closing children div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Still the same context. $this->assertSame( array( 'my-key' => 'some-value' ), - $context->get_context() + $context->get_context()['myblock'] ); // Closing parent div. $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context ); + gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); // Now the context is empty. $this->assertSame( diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php index 51468bd8a28141..9625803ebca78f 100644 --- a/phpunit/experimental/interactivity-api/directives/wp-style-test.php +++ b/phpunit/experimental/interactivity-api/directives/wp-style-test.php @@ -18,9 +18,9 @@ public function test_directive_adds_style() { $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); - $context_before = new WP_Directive_Context( array( 'color' => 'green' ) ); + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_style( $tags, $context ); + gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' ); $this->assertSame( '
Test
', @@ -35,9 +35,9 @@ public function test_directive_ignores_empty_style() { $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); - $context_before = new WP_Directive_Context( array( 'color' => 'green' ) ); + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_style( $tags, $context ); + gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' ); $this->assertSame( $markup, $tags->get_updated_html() ); $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); @@ -49,9 +49,9 @@ public function test_directive_works_without_style_attribute() { $tags = new WP_HTML_Tag_Processor( $markup ); $tags->next_tag(); - $context_before = new WP_Directive_Context( array( 'color' => 'green' ) ); + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); $context = $context_before; - gutenberg_interactivity_process_wp_style( $tags, $context ); + gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' ); $this->assertSame( '
Test
', diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php index 81d2d0f370a64b..9c889a3f0eb68f 100644 --- a/phpunit/experimental/interactivity-api/directives/wp-text-test.php +++ b/phpunit/experimental/interactivity-api/directives/wp-text-test.php @@ -14,31 +14,31 @@ */ class Tests_Directives_WpText extends WP_UnitTestCase { public function test_directive_sets_inner_html_based_on_attribute_value_and_escapes_html() { - $markup = '
'; + $markup = '
'; $tags = new WP_Directive_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) ); $context = clone $context_before; - gutenberg_interactivity_process_wp_text( $tags, $context ); + gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' ); - $expected_markup = '
The HTML tag <br> produces a line break.
'; + $expected_markup = '
The HTML tag <br> produces a line break.
'; $this->assertSame( $expected_markup, $tags->get_updated_html() ); $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); } public function test_directive_overwrites_inner_html_based_on_attribute_value() { - $markup = '
Lorem ipsum dolor sit.
'; + $markup = '
Lorem ipsum dolor sit.
'; $tags = new WP_Directive_Processor( $markup ); $tags->next_tag(); $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) ); $context = clone $context_before; - gutenberg_interactivity_process_wp_text( $tags, $context ); + gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' ); - $expected_markup = '
Honi soit qui mal y pense.
'; + $expected_markup = '
Honi soit qui mal y pense.
'; $this->assertSame( $expected_markup, $tags->get_updated_html() ); $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); } diff --git a/phpunit/tests/fonts/font-library/fontsDir.php b/phpunit/tests/fonts/font-library/fontsDir.php new file mode 100644 index 00000000000000..5c13f1d120f9a5 --- /dev/null +++ b/phpunit/tests/fonts/font-library/fontsDir.php @@ -0,0 +1,70 @@ +dir_defaults = array( + 'path' => path_join( WP_CONTENT_DIR, 'fonts' ), + 'url' => content_url( 'fonts' ), + 'subdir' => '', + 'basedir' => path_join( WP_CONTENT_DIR, 'fonts' ), + 'baseurl' => content_url( 'fonts' ), + 'error' => false, + ); + } + + public function test_fonts_dir() { + $font_dir = wp_get_font_dir(); + $this->assertEquals( $font_dir, $this->dir_defaults ); + } + + public function test_fonts_dir_with_filter() { + // Define a callback function to pass to the filter. + function set_new_values( $defaults ) { + $defaults['path'] = '/custom-path/fonts/my-custom-subdir'; + $defaults['url'] = 'http://example.com/custom-path/fonts/my-custom-subdir'; + $defaults['subdir'] = 'my-custom-subdir'; + $defaults['basedir'] = '/custom-path/fonts'; + $defaults['baseurl'] = 'http://example.com/custom-path/fonts'; + $defaults['error'] = false; + return $defaults; + } + + // Add the filter. + add_filter( 'font_dir', 'set_new_values' ); + + // Gets the fonts dir. + $font_dir = wp_get_font_dir(); + + $expected = array( + 'path' => '/custom-path/fonts/my-custom-subdir', + 'url' => 'http://example.com/custom-path/fonts/my-custom-subdir', + 'subdir' => 'my-custom-subdir', + 'basedir' => '/custom-path/fonts', + 'baseurl' => 'http://example.com/custom-path/fonts', + 'error' => false, + ); + + $this->assertEquals( $font_dir, $expected, 'The wp_get_font_dir() method should return the expected values.' ); + + // Remove the filter. + remove_filter( 'font_dir', 'set_new_values' ); + + // Gets the fonts dir. + $font_dir = wp_get_font_dir(); + + $this->assertEquals( $font_dir, $this->dir_defaults, 'The wp_get_font_dir() method should return the default values.' ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php b/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php index 5c2b7b5c02793a..380226ee8af8a3 100644 --- a/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php +++ b/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php @@ -17,7 +17,7 @@ public function test_should_initialize_data() { $property->setAccessible( true ); $config = array( - 'id' => 'my-collection', + 'slug' => 'my-collection', 'name' => 'My Collection', 'description' => 'My collection description', 'src' => 'my-collection-data.json', @@ -55,7 +55,7 @@ public function data_should_throw_exception() { 'description' => 'My collection description', 'src' => 'my-collection-data.json', ), - 'Font Collection config ID is required as a non-empty string.', + 'Font Collection config slug is required as a non-empty string.', ), 'no config' => array( @@ -80,11 +80,11 @@ public function data_should_throw_exception() { 'missing src' => array( array( - 'id' => 'my-collection', + 'slug' => 'my-collection', 'name' => 'My Collection', 'description' => 'My collection description', ), - 'Font Collection config "src" option is required as a non-empty string.', + 'Font Collection config "src" option OR "data" option is required.', ), ); diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php b/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php new file mode 100644 index 00000000000000..5f1f082297d418 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php @@ -0,0 +1,76 @@ +assertSame( $expected_data, $collection->get_config() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_get_config() { + $mock_file = wp_tempnam( 'my-collection-data-' ); + file_put_contents( $mock_file, '{"this is mock data":true}' ); + + return array( + 'with a file' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'src' => $mock_file, + ), + 'expected_data' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + ), + ), + 'with a url' => array( + 'config' => array( + 'slug' => 'my-collection-with-url', + 'name' => 'My Collection with URL', + 'description' => 'My collection description', + 'src' => 'https://localhost/fonts/mock-font-collection.json', + ), + 'expected_data' => array( + 'slug' => 'my-collection-with-url', + 'name' => 'My Collection with URL', + 'description' => 'My collection description', + ), + ), + 'with data' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'data' => array( 'this is mock data' => true ), + ), + 'expected_data' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + ), + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/getData.php b/phpunit/tests/fonts/font-library/wpFontCollection/getConfigAndData.php similarity index 66% rename from phpunit/tests/fonts/font-library/wpFontCollection/getData.php rename to phpunit/tests/fonts/font-library/wpFontCollection/getConfigAndData.php index 4d0b2eb92b595e..885b0a0b9036cb 100644 --- a/phpunit/tests/fonts/font-library/wpFontCollection/getData.php +++ b/phpunit/tests/fonts/font-library/wpFontCollection/getConfigAndData.php @@ -1,6 +1,6 @@ assertSame( $expected_data, $collection->get_data() ); + $this->assertSame( $expected_data, $collection->get_config_and_data() ); } /** @@ -62,20 +62,20 @@ public function test_should_get_data( $config, $expected_data ) { * * @return array[] */ - public function data_should_get_data() { + public function data_should_get_config_and_data() { $mock_file = wp_tempnam( 'my-collection-data-' ); file_put_contents( $mock_file, '{"this is mock data":true}' ); return array( 'with a file' => array( 'config' => array( - 'id' => 'my-collection', + 'slug' => 'my-collection', 'name' => 'My Collection', 'description' => 'My collection description', 'src' => $mock_file, ), 'expected_data' => array( - 'id' => 'my-collection', + 'slug' => 'my-collection', 'name' => 'My Collection', 'description' => 'My collection description', 'data' => array( 'this is mock data' => true ), @@ -83,13 +83,13 @@ public function data_should_get_data() { ), 'with a url' => array( 'config' => array( - 'id' => 'my-collection-with-url', + 'slug' => 'my-collection-with-url', 'name' => 'My Collection with URL', 'description' => 'My collection description', 'src' => 'https://localhost/fonts/mock-font-collection.json', ), 'expected_data' => array( - 'id' => 'my-collection-with-url', + 'slug' => 'my-collection-with-url', 'name' => 'My Collection with URL', 'description' => 'My collection description', 'data' => array( @@ -98,6 +98,20 @@ public function data_should_get_data() { ), ), ), + 'with data' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'data' => array( 'this is mock data' => true ), + ), + 'expected_data' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'data' => array( 'this is mock data' => true ), + ), + ), ); } } diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/base.php b/phpunit/tests/fonts/font-library/wpFontFamily/base.php index 3650ac7dab9972..3f6ff153fa12f5 100644 --- a/phpunit/tests/fonts/font-library/wpFontFamily/base.php +++ b/phpunit/tests/fonts/font-library/wpFontFamily/base.php @@ -28,7 +28,7 @@ abstract class WP_Font_Family_UnitTestCase extends WP_UnitTestCase { public static function set_up_before_class() { parent::set_up_before_class(); - static::$fonts_dir = WP_Font_Library::get_fonts_dir(); + static::$fonts_dir = wp_get_font_dir()['path']; wp_mkdir_p( static::$fonts_dir ); } diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php index 00d5ca2dcb2e73..082ca892114659 100644 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollection.php @@ -14,7 +14,7 @@ class Tests_Fonts_WpFontLibrary_GetFontCollection extends WP_Font_Library_UnitTe public function test_should_get_font_collection() { $my_font_collection_config = array( - 'id' => 'my-font-collection', + 'slug' => 'my-font-collection', 'name' => 'My Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => path_join( __DIR__, 'my-font-collection-data.json' ), @@ -24,7 +24,7 @@ public function test_should_get_font_collection() { $this->assertInstanceOf( 'WP_Font_Collection', $font_collection ); } - public function test_should_get_no_font_collection_if_the_id_is_not_registered() { + public function test_should_get_no_font_collection_if_the_slug_is_not_registered() { $font_collection = WP_Font_Library::get_font_collection( 'not-registered-font-collection' ); $this->assertWPError( $font_collection ); } diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php index 40eacba8e18c56..a405584efccc23 100644 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontCollections.php @@ -18,7 +18,7 @@ public function test_should_get_an_empty_list() { public function test_should_get_mock_font_collection() { $my_font_collection_config = array( - 'id' => 'my-font-collection', + 'slug' => 'my-font-collection', 'name' => 'My Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => path_join( __DIR__, 'my-font-collection-data.json' ), diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontsDir.php b/phpunit/tests/fonts/font-library/wpFontLibrary/getFontsDir.php deleted file mode 100644 index 1200200d7160b2..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/getFontsDir.php +++ /dev/null @@ -1,18 +0,0 @@ -assertStringEndsWith( '/wp-content/fonts', WP_Font_Library::get_fonts_dir() ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php index 2569830f6bf2aa..a7ea2870957e9d 100644 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php @@ -14,7 +14,7 @@ class Tests_Fonts_WpFontLibrary_RegisterFontCollection extends WP_Font_Library_U public function test_should_register_font_collection() { $config = array( - 'id' => 'my-collection', + 'slug' => 'my-collection', 'name' => 'My Collection', 'description' => 'My Collection Description', 'src' => 'my-collection-data.json', @@ -23,20 +23,20 @@ public function test_should_register_font_collection() { $this->assertInstanceOf( 'WP_Font_Collection', $collection ); } - public function test_should_return_error_if_id_is_missing() { + public function test_should_return_error_if_slug_is_missing() { $config = array( 'name' => 'My Collection', 'description' => 'My Collection Description', 'src' => 'my-collection-data.json', ); $this->expectException( 'Exception' ); - $this->expectExceptionMessage( 'Font Collection config ID is required as a non-empty string.' ); + $this->expectExceptionMessage( 'Font Collection config slug is required as a non-empty string.' ); WP_Font_Library::register_font_collection( $config ); } public function test_should_return_error_if_name_is_missing() { $config = array( - 'id' => 'my-collection', + 'slug' => 'my-collection', 'description' => 'My Collection Description', 'src' => 'my-collection-data.json', ); @@ -52,15 +52,15 @@ public function test_should_return_error_if_config_is_empty() { WP_Font_Library::register_font_collection( $config ); } - public function test_should_return_error_if_id_is_repeated() { + public function test_should_return_error_if_slug_is_repeated() { $config1 = array( - 'id' => 'my-collection-1', + 'slug' => 'my-collection-1', 'name' => 'My Collection 1', 'description' => 'My Collection 1 Description', 'src' => 'my-collection-1-data.json', ); $config2 = array( - 'id' => 'my-collection-1', + 'slug' => 'my-collection-1', 'name' => 'My Collection 2', 'description' => 'My Collection 2 Description', 'src' => 'my-collection-2-data.json', @@ -72,7 +72,7 @@ public function test_should_return_error_if_id_is_repeated() { // Expects a _doing_it_wrong notice. $this->setExpectedIncorrectUsage( 'WP_Font_Library::register_font_collection' ); - // Try to register a second collection with same id. + // Try to register a second collection with same slug. $collection2 = WP_Font_Library::register_font_collection( $config2 ); $this->assertWPError( $collection2, 'A WP_Error should be returned.' ); } diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/setUploadDir.php b/phpunit/tests/fonts/font-library/wpFontLibrary/setUploadDir.php deleted file mode 100644 index 29d481d8afd6bc..00000000000000 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/setUploadDir.php +++ /dev/null @@ -1,32 +0,0 @@ - '/abc', - 'basedir' => '/any/path', - 'baseurl' => 'http://example.com/an/arbitrary/url', - 'path' => '/any/path/abc', - 'url' => 'http://example.com/an/arbitrary/url/abc', - ); - $expected = array( - 'subdir' => '/fonts', - 'basedir' => WP_CONTENT_DIR, - 'baseurl' => content_url(), - 'path' => path_join( WP_CONTENT_DIR, 'fonts' ), - 'url' => content_url() . '/fonts', - ); - $this->assertSame( $expected, WP_Font_Library::set_upload_dir( $defaults ) ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php index e6e16956814fb4..3c19a1d2089e7a 100644 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/unregisterFontCollection.php @@ -15,7 +15,7 @@ class Tests_Fonts_WpFontLibrary_UnregisterFontCollection extends WP_Font_Library public function test_should_unregister_font_collection() { // Registers two mock font collections. $config = array( - 'id' => 'mock-font-collection-1', + 'slug' => 'mock-font-collection-1', 'name' => 'Mock Collection to be unregistered', 'description' => 'A mock font collection to be unregistered.', 'src' => 'my-collection-data.json', @@ -23,7 +23,7 @@ public function test_should_unregister_font_collection() { WP_Font_Library::register_font_collection( $config ); $config = array( - 'id' => 'mock-font-collection-2', + 'slug' => 'mock-font-collection-2', 'name' => 'Mock Collection', 'description' => 'A mock font collection.', 'src' => 'my-mock-data.json', diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php index 94e7daaa166345..c9d003389997b4 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php +++ b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php @@ -25,7 +25,7 @@ public function set_up() { add_filter( 'pre_http_request', array( $this, 'mock_request' ), 10, 3 ); $config_with_file = array( - 'id' => 'one-collection', + 'slug' => 'one-collection', 'name' => 'One Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => $mock_file, @@ -33,7 +33,7 @@ public function set_up() { wp_register_font_collection( $config_with_file ); $config_with_url = array( - 'id' => 'collection-with-url', + 'slug' => 'collection-with-url', 'name' => 'Another Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => 'https://wordpress.org/fonts/mock-font-collection.json', @@ -42,7 +42,7 @@ public function set_up() { wp_register_font_collection( $config_with_url ); $config_with_non_existing_file = array( - 'id' => 'collection-with-non-existing-file', + 'slug' => 'collection-with-non-existing-file', 'name' => 'Another Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => '/home/non-existing-file.json', @@ -51,7 +51,7 @@ public function set_up() { wp_register_font_collection( $config_with_non_existing_file ); $config_with_non_existing_url = array( - 'id' => 'collection-with-non-existing-url', + 'slug' => 'collection-with-non-existing-url', 'name' => 'Another Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => 'https://non-existing-url-1234x.com.ar/fake-path/missing-file.json', diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php index 224dab07cf0b7a..0a8d24e8f392ba 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php +++ b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php @@ -27,7 +27,7 @@ public function test_get_font_collections() { // Add a font collection. $config = array( - 'id' => 'my-font-collection', + 'slug' => 'my-font-collection', 'name' => 'My Font Collection', 'description' => 'Demo about how to a font collection to your WordPress Font Library.', 'src' => $mock_file, @@ -39,7 +39,7 @@ public function test_get_font_collections() { $data = $response->get_data(); $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); $this->assertCount( 1, $data, 'The response data is not an array with one element.' ); - $this->assertArrayHasKey( 'id', $data[0], 'The response data does not have the key with the collection ID.' ); + $this->assertArrayHasKey( 'slug', $data[0], 'The response data does not have the key with the collection slug.' ); $this->assertArrayHasKey( 'name', $data[0], 'The response data does not have the key with the collection name.' ); } } diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php index c2c019fa70a022..fb100a400fb4cf 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php +++ b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php @@ -16,9 +16,9 @@ class Tests_Fonts_WPRESTFontCollectionsController_RegisterRoutes extends WP_Unit public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertCount( 1, $routes['/wp/v2/font-collections'], 'Rest server has not the collections path initialized.' ); - $this->assertCount( 1, $routes['/wp/v2/font-collections/(?P[\/\w-]+)'], 'Rest server has not the collection path initialized.' ); + $this->assertCount( 1, $routes['/wp/v2/font-collections/(?P[\/\w-]+)'], 'Rest server has not the collection path initialized.' ); $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections'][0]['methods'], 'Rest server has not the GET method for collections intialized.' ); - $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections/(?P[\/\w-]+)'][0]['methods'], 'Rest server has not the GET method for collection intialized.' ); + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections/(?P[\/\w-]+)'][0]['methods'], 'Rest server has not the GET method for collection intialized.' ); } } diff --git a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php index 5ab71a4379851f..e2d190cd76af1f 100644 --- a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php +++ b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php @@ -18,7 +18,7 @@ abstract class WP_REST_Font_Families_Controller_UnitTestCase extends WP_UnitTest public function set_up() { parent::set_up(); - static::$fonts_dir = WP_Font_Library::get_fonts_dir(); + static::$fonts_dir = wp_get_font_dir()['path']; // Create a user with administrator role. $admin_id = $this->factory->user->create( diff --git a/schemas/README.md b/schemas/README.md index 1b2d8992cccb90..fa695115f33abe 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -1,6 +1,6 @@ # Schemas -The collection of schemas used in WordPress, including the `theme.json` and `block.json` schemas. +The collection of schemas used in WordPress, including the `theme.json`, `block.json` and `font-collection.json` schemas. JSON schemas are used by code editors to offer tooltips, autocomplete, and validation. @@ -24,6 +24,14 @@ Or in your `theme.json`: } ``` +Or in your `font-collection.json`: + +```json +{ + "$schema": "https://schemas.wp.org/trunk/font-collection.json" +} +``` + For a specific version of the schema, replace `trunk` with `wp/X.X`: ```json @@ -56,8 +64,16 @@ To allow this you will need to: } ``` +- update your font collections's `font-collection.json` to include: + +```json +{ + "$schema": "file://{{FULL_FILE_PATH}}/schemas/json/font-collection.json" +} +``` + Be sure to replace `{{FULL_FILE_PATH}}` with the full local path to your Gutenberg repo. -With this in place you should now be able to edit either `schemas/json/theme .json` or `schemas/json/block.json` in order to see changes reflected in `theme.json` or `block.json` in your IDE. +With this in place you should now be able to edit either `schemas/json/theme .json`, `schemas/json/block.json` or `schemas/json/font-collection.json` in order to see changes reflected in `theme.json`, `block.json` or `font-collection.json` in your IDE.

Code is Poetry.

diff --git a/schemas/json/font-collection.json b/schemas/json/font-collection.json new file mode 100644 index 00000000000000..a6ca2b1412e6d2 --- /dev/null +++ b/schemas/json/font-collection.json @@ -0,0 +1,59 @@ +{ + "title": "JSON schema for WordPress Font Collections", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "$schema": { + "description": "JSON schema URI for font-collection.json.", + "type": "string" + }, + "version": { + "description": "Version of font-collection.json schema to use.", + "type": "integer", + "enum": [ 1 ] + }, + "font_families": { + "type": "array", + "description": "Array of font families ready to be installed", + "items": { + "type": "object", + "properties": { + "font_family_settings": { + "description": "Font family settings as in theme.json", + "allOf": [ + { "$ref": "./theme.json#/definitions/fontFamily" } + ] + }, + "categories": { + "type": "array", + "description": "Array of category slugs", + "items": { + "type": "string" + } + } + }, + "required": [ "font_family_settings" ], + "additionalProperties": false + } + }, + "categories": { + "type": "array", + "description": "Array of category objects", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ "slug", "name" ], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "required": [ "$schema", "version", "font_families" ] +} diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 10695f493c40dd..6ae8d15df63d2d 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -484,6 +484,11 @@ "description": "Settings related to typography.", "type": "object", "properties": { + "defaultFontSizes": { + "description": "Allow users to choose font sizes from the default font size presets.", + "type": "boolean", + "default": true + }, "customFontSize": { "description": "Allow users to set custom font sizes.", "type": "boolean", @@ -609,132 +614,136 @@ "description": "Font family presets for the font family selector.\nGenerates a single custom property (`--wp--preset--font-family--{slug}`) per preset value.", "type": "array", "items": { - "type": "object", - "properties": { - "name": { - "description": "Name of the font family preset, translatable.", - "type": "string" - }, - "slug": { - "description": "Kebab-case unique identifier for the font family preset.", - "type": "string" - }, - "fontFamily": { - "description": "CSS font-family value.", + "$ref": "#/definitions/fontFamily" + } + } + }, + "additionalProperties": false + } + } + }, + "fontFamily": { + "type": "object", + "description": "Font family preset", + "properties": { + "name": { + "description": "Name of the font family preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the font family preset.", + "type": "string" + }, + "fontFamily": { + "description": "CSS font-family value.", + "type": "string" + }, + "preview": { + "description": "URL to a preview image of the font family.", + "type": "string" + }, + "fontFace": { + "description": "Array of font-face declarations.", + "type": "array", + "items": { + "type": "object", + "properties": { + "fontFamily": { + "description": "CSS font-family value.", + "type": "string", + "default": "" + }, + "fontStyle": { + "description": "CSS font-style value.", + "type": "string", + "default": "normal" + }, + "fontWeight": { + "description": "List of available font weights, separated by a space.", + "default": "400", + "oneOf": [ + { "type": "string" }, - "preview": { - "description": "URL to a preview image of the font family.", + { + "type": "integer" + } + ] + }, + "fontDisplay": { + "description": "CSS font-display value.", + "type": "string", + "default": "fallback", + "enum": [ + "auto", + "block", + "fallback", + "swap", + "optional" + ] + }, + "src": { + "description": "Paths or URLs to the font files.", + "oneOf": [ + { "type": "string" }, - "fontFace": { - "description": "Array of font-face declarations.", + { "type": "array", "items": { - "type": "object", - "properties": { - "fontFamily": { - "description": "CSS font-family value.", - "type": "string", - "default": "" - }, - "fontStyle": { - "description": "CSS font-style value.", - "type": "string", - "default": "normal" - }, - "fontWeight": { - "description": "List of available font weights, separated by a space.", - "default": "400", - "oneOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "fontDisplay": { - "description": "CSS font-display value.", - "type": "string", - "default": "fallback", - "enum": [ - "auto", - "block", - "fallback", - "swap", - "optional" - ] - }, - "src": { - "description": "Paths or URLs to the font files.", - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "default": [] - }, - "fontStretch": { - "description": "CSS font-stretch value.", - "type": "string" - }, - "ascentOverride": { - "description": "CSS ascent-override value.", - "type": "string" - }, - "descentOverride": { - "description": "CSS descent-override value.", - "type": "string" - }, - "fontVariant": { - "description": "CSS font-variant value.", - "type": "string" - }, - "fontFeatureSettings": { - "description": "CSS font-feature-settings value.", - "type": "string" - }, - "fontVariationSettings": { - "description": "CSS font-variation-settings value.", - "type": "string" - }, - "lineGapOverride": { - "description": "CSS line-gap-override value.", - "type": "string" - }, - "sizeAdjust": { - "description": "CSS size-adjust value.", - "type": "string" - }, - "unicodeRange": { - "description": "CSS unicode-range value.", - "type": "string" - }, - "preview": { - "description": "URL to a preview image of the font face.", - "type": "string" - } - }, - "required": [ "fontFamily", "src" ], - "additionalProperties": false + "type": "string" } } - }, - "additionalProperties": false + ], + "default": [] + }, + "fontStretch": { + "description": "CSS font-stretch value.", + "type": "string" + }, + "ascentOverride": { + "description": "CSS ascent-override value.", + "type": "string" + }, + "descentOverride": { + "description": "CSS descent-override value.", + "type": "string" + }, + "fontVariant": { + "description": "CSS font-variant value.", + "type": "string" + }, + "fontFeatureSettings": { + "description": "CSS font-feature-settings value.", + "type": "string" + }, + "fontVariationSettings": { + "description": "CSS font-variation-settings value.", + "type": "string" + }, + "lineGapOverride": { + "description": "CSS line-gap-override value.", + "type": "string" + }, + "sizeAdjust": { + "description": "CSS size-adjust value.", + "type": "string" + }, + "unicodeRange": { + "description": "CSS unicode-range value.", + "type": "string" + }, + "preview": { + "description": "URL to a preview image of the font face.", + "type": "string" } - } - }, - "additionalProperties": false + }, + "required": [ "fontFamily", "src" ], + "additionalProperties": false + } } - } + }, + "additionalProperties": false }, "settingsPropertiesCustom": { "type": "object", diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index adeabc860c8342..9080a6dc194021 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -696,7 +696,9 @@ test.describe( 'Image', () => { await expect( linkDom ).toHaveAttribute( 'href', url ); } ); - test( 'should upload external image', async ( { editor } ) => { + test( 'should upload external image to media library', async ( { + editor, + } ) => { await editor.insertBlock( { name: 'core/image', attributes: { @@ -704,7 +706,7 @@ test.describe( 'Image', () => { }, } ); - await editor.clickBlockToolbarButton( 'Upload external image' ); + await editor.clickBlockToolbarButton( 'Upload image to media library' ); const imageBlock = editor.canvas.locator( 'role=document[name="Block: Image"i]' diff --git a/packages/e2e-tests/specs/editor/various/core-settings.test.js b/test/e2e/specs/editor/various/core-settings.spec.js similarity index 58% rename from packages/e2e-tests/specs/editor/various/core-settings.test.js rename to test/e2e/specs/editor/various/core-settings.spec.js index 0eb98a2de050b4..9dddc273e6b16e 100644 --- a/packages/e2e-tests/specs/editor/various/core-settings.test.js +++ b/test/e2e/specs/editor/various/core-settings.spec.js @@ -1,10 +1,10 @@ /** * WordPress dependencies */ -import { visitAdminPage } from '@wordpress/e2e-test-utils'; +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -async function getOptionsValues( selector ) { - await visitAdminPage( 'options.php' ); +async function getOptionsValues( selector, admin, page ) { + await admin.visitAdminPage( 'options.php' ); return page.evaluate( ( theSelector ) => { const inputs = Array.from( document.querySelectorAll( theSelector ) ); return inputs.reduce( ( memo, input ) => { @@ -16,22 +16,32 @@ async function getOptionsValues( selector ) { // It might make sense to include a similar test in WP core (or move this one over). // See discussion here: https://github.com/WordPress/gutenberg/pull/32797#issuecomment-864192088. -describe( 'Settings', () => { - test( 'Regression: updating a specific option will only change its value and will not corrupt others', async () => { +test.describe( 'Settings', () => { + test( 'Regression: updating a specific option will only change its value and will not corrupt others', async ( { + page, + admin, + } ) => { // We won't select the option that we updated and will also remove some // _transient options that seem to change at every update. const optionsInputsSelector = 'form#all-options table.form-table input:not([id*="_transient"]):not([id="blogdescription"])'; - const optionsBefore = await getOptionsValues( optionsInputsSelector ); - - await visitAdminPage( 'options-general.php' ); - await page.type( - 'input#blogdescription', - 'Just another Gutenberg site' + const optionsBefore = await getOptionsValues( + optionsInputsSelector, + admin, + page ); - await page.click( 'input#submit' ); - const optionsAfter = await getOptionsValues( optionsInputsSelector ); + await admin.visitAdminPage( 'options-general.php' ); + await page + .getByRole( 'textbox', { name: 'Tagline' } ) + .fill( 'Just another Gutenberg site' ); + await page.getByRole( 'button', { name: 'Save Changes' } ).click(); + + const optionsAfter = await getOptionsValues( + optionsInputsSelector, + admin, + page + ); Object.entries( optionsBefore ).forEach( ( optionBefore ) => { const [ id ] = optionBefore; diff --git a/test/e2e/specs/editor/various/dropdown-menu.spec.js b/test/e2e/specs/editor/various/dropdown-menu.spec.js new file mode 100644 index 00000000000000..916ef3447d80a4 --- /dev/null +++ b/test/e2e/specs/editor/various/dropdown-menu.spec.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Dropdown Menu', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'keyboard navigiation', async ( { page, pageUtils } ) => { + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + const menuItems = page.locator( + '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]' + ); + const totalItems = await menuItems.count(); + + // Catch any issues with the selector, which could cause a false positive test result. + expect( totalItems ).toBeGreaterThan( 0 ); + + await test.step( 'allows navigation through each item using arrow keys', async () => { + // Expect the first menu item to be focused. + await expect( menuItems.first() ).toBeFocused(); + + // Arrow down to the last item. + await pageUtils.pressKeys( 'ArrowDown', { times: totalItems - 1 } ); + await expect( menuItems.last() ).toBeFocused(); + + // Arrow back up to the first item. + await pageUtils.pressKeys( 'ArrowUp', { times: totalItems - 1 } ); + await expect( menuItems.first() ).toBeFocused(); + } ); + + await test.step( 'loops to the beginning and end when navigating past the boundaries of the menu', async () => { + // Expect the first menu item to be focused. + await expect( menuItems.first() ).toBeFocused(); + + // Arrow up to the last item. + await page.keyboard.press( 'ArrowUp' ); + await expect( menuItems.last() ).toBeFocused(); + + // Arrow back down to the first item. + await page.keyboard.press( 'ArrowDown' ); + await expect( menuItems.first() ).toBeFocused(); + } ); + + await test.step( 'ignores arrow key navigation that is orthogonal to the orientation of the menu, but stays open', async () => { + // Expect the first menu item to be focused. + await expect( menuItems.first() ).toBeFocused(); + + // Press left and right keys an arbitrary (but > 1) number of times. + await pageUtils.pressKeys( 'ArrowLeft', { times: 5 } ); + await pageUtils.pressKeys( 'ArrowRight', { times: 5 } ); + + // Expect the first menu item to still be focused. + await expect( menuItems.first() ).toBeFocused(); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/taxonomies.spec.js b/test/e2e/specs/editor/various/taxonomies.spec.js new file mode 100644 index 00000000000000..efd8c9c6ee7fe0 --- /dev/null +++ b/test/e2e/specs/editor/various/taxonomies.spec.js @@ -0,0 +1,136 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +function generateRandomNumber() { + return Math.round( 1 + Math.random() * ( Number.MAX_SAFE_INTEGER - 1 ) ); +} + +test.describe( 'Taxonomies', () => { + test.beforeEach( async ( { admin, editor } ) => { + await admin.createNewPost(); + await editor.openDocumentSettingsSidebar(); + } ); + + test( 'should be able to open the categories panel and create a new main category', async ( { + editor, + page, + } ) => { + // Open the Document -> Categories panel. + const panelToggle = page.getByRole( 'button', { + name: 'Categories', + } ); + + if ( + ( await panelToggle.getAttribute( 'aria-expanded' ) ) === 'false' + ) { + await panelToggle.click(); + } + + await page + .getByRole( 'button', { + name: 'Add New Category', + expanded: false, + } ) + .click(); + await page + .getByRole( 'textbox', { name: 'New Category Name' } ) + .fill( 'z rand category 1' ); + await page.keyboard.press( 'Enter' ); + + const categories = page.getByRole( 'group', { name: 'Categories' } ); + const selectedCategories = categories.getByRole( 'checkbox', { + checked: true, + } ); + const newCategory = categories.getByRole( 'checkbox', { + name: 'z rand category 1', + } ); + + await expect( selectedCategories ).toHaveCount( 1 ); + await expect( newCategory ).toBeChecked(); + + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + await editor.publishPost(); + await page.reload(); + + // The category selection was persisted after the publish process. + await expect( selectedCategories ).toHaveCount( 1 ); + await expect( newCategory ).toBeChecked(); + } ); + + test( 'should be able to open the tags panel and create a new tag', async ( { + editor, + page, + } ) => { + // Open the Document -> Tags panel. + const panelToggle = page.getByRole( 'button', { + name: 'Tags', + } ); + + if ( + ( await panelToggle.getAttribute( 'aria-expanded' ) ) === 'false' + ) { + await panelToggle.click(); + } + + const tagName = 'tag-' + generateRandomNumber(); + const tags = page.locator( '.components-form-token-field__token-text' ); + + await page + .getByRole( 'combobox', { name: 'Add New Tag' } ) + .fill( tagName ); + await page.keyboard.press( 'Enter' ); + + await expect( tags ).toHaveCount( 1 ); + await expect( tags ).toContainText( tagName ); + + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + await editor.publishPost(); + await page.reload(); + + await expect( tags ).toHaveCount( 1 ); + await expect( tags ).toContainText( tagName ); + } ); + + // See: https://github.com/WordPress/gutenberg/pull/21693. + test( `should be able to create a new tag with ' on the name`, async ( { + editor, + page, + } ) => { + // Open the Document -> Tags panel. + const panelToggle = page.getByRole( 'button', { + name: 'Tags', + } ); + + if ( + ( await panelToggle.getAttribute( 'aria-expanded' ) ) === 'false' + ) { + await panelToggle.click(); + } + + const tagName = "tag'-" + generateRandomNumber(); + const tags = page.locator( '.components-form-token-field__token-text' ); + + await page + .getByRole( 'combobox', { name: 'Add New Tag' } ) + .fill( tagName ); + await page.keyboard.press( 'Enter' ); + + await expect( tags ).toHaveCount( 1 ); + await expect( tags ).toContainText( tagName ); + + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + await editor.publishPost(); + await page.reload(); + + await expect( tags ).toHaveCount( 1 ); + await expect( tags ).toContainText( tagName ); + } ); +} ); diff --git a/packages/e2e-tests/specs/editor/various/typewriter.test.js b/test/e2e/specs/editor/various/typewriter.spec.js similarity index 58% rename from packages/e2e-tests/specs/editor/various/typewriter.test.js rename to test/e2e/specs/editor/various/typewriter.spec.js index d935197b14f87f..abf24cbfc298ec 100644 --- a/packages/e2e-tests/specs/editor/various/typewriter.test.js +++ b/test/e2e/specs/editor/various/typewriter.spec.js @@ -1,40 +1,40 @@ /** * WordPress dependencies */ -import { createNewPost } from '@wordpress/e2e-test-utils'; +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -describe( 'TypeWriter', () => { - beforeEach( async () => { - await createNewPost(); - } ); +/** @typedef {import('@playwright/test').Page} Page */ - const getCaretPosition = async () => - await page.evaluate( - () => - wp.dom.computeCaretRect( - document.activeElement?.contentWindow ?? window - ).y - ); +// Allow the scroll position to be 1px off. +const BUFFER = 1; - // Allow the scroll position to be 1px off. - const BUFFER = 1; +test.use( { + typewriterUtils: async ( { page }, use ) => { + await use( new TypewriterUtils( { page } ) ); + }, +} ); - const getDiff = async ( caretPosition ) => - Math.abs( ( await getCaretPosition() ) - caretPosition ); +test.describe( 'Typewriter', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); - it( 'should maintain caret position', async () => { - // Create first block. + test( 'should maintain caret position', async ( { + page, + typewriterUtils, + } ) => { + // Create test blocks. await page.keyboard.press( 'Enter' ); - - // Create second block. await page.keyboard.press( 'Enter' ); - const initialPosition = await getCaretPosition(); + const initialPosition = await typewriterUtils.getCaretPosition(); // The page shouldn't be scrolled when it's being filled. await page.keyboard.press( 'Enter' ); - expect( await getCaretPosition() ).toBeGreaterThan( initialPosition ); + expect( await typewriterUtils.getCaretPosition() ).toBeGreaterThan( + initialPosition + ); // Create blocks until the typewriter effect kicks in. while ( @@ -42,19 +42,22 @@ describe( 'TypeWriter', () => { const { activeElement } = document.activeElement?.contentDocument ?? document; return ( - wp.dom.getScrollContainer( activeElement ).scrollTop === 0 + window.wp.dom.getScrollContainer( activeElement ) + .scrollTop === 0 ); } ) ) { await page.keyboard.press( 'Enter' ); } - const newPosition = await getCaretPosition(); + const newPosition = await typewriterUtils.getCaretPosition(); // Now the scroll position should be maintained. await page.keyboard.press( 'Enter' ); - expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + expect( + await typewriterUtils.getDiff( newPosition ) + ).toBeLessThanOrEqual( BUFFER ); // Type until the text wraps. while ( @@ -63,37 +66,47 @@ describe( 'TypeWriter', () => { document.activeElement?.contentDocument ?? document; return ( activeElement.clientHeight <= - parseInt( getComputedStyle( activeElement ).lineHeight, 10 ) + parseInt( + window.getComputedStyle( activeElement ).lineHeight, + 10 + ) ); } ) ) { await page.keyboard.type( 'a' ); } - expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + expect( + await typewriterUtils.getDiff( newPosition ) + ).toBeLessThanOrEqual( BUFFER ); // Pressing backspace will reposition the caret to the previous line. // Scroll position should be adjusted again. await page.keyboard.press( 'Backspace' ); - expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + expect( + await typewriterUtils.getDiff( newPosition ) + ).toBeLessThanOrEqual( BUFFER ); // Should reset scroll position to maintain. await page.keyboard.press( 'ArrowUp' ); - const positionAfterArrowUp = await getCaretPosition(); + const positionAfterArrowUp = await typewriterUtils.getCaretPosition(); expect( positionAfterArrowUp ).toBeLessThanOrEqual( newPosition ); // Should be scrolled to new position. await page.keyboard.press( 'Enter' ); - expect( await getDiff( positionAfterArrowUp ) ).toBeLessThanOrEqual( - BUFFER - ); + expect( + await typewriterUtils.getDiff( positionAfterArrowUp ) + ).toBeLessThanOrEqual( BUFFER ); } ); - it( 'should maintain caret position after scroll', async () => { + test( 'should maintain caret position after scroll', async ( { + page, + typewriterUtils, + } ) => { // Create first block. await page.keyboard.press( 'Enter' ); @@ -104,7 +117,7 @@ describe( 'TypeWriter', () => { const { activeElement } = document.activeElement?.contentDocument ?? document; const scrollContainer = - wp.dom.getScrollContainer( activeElement ); + window.wp.dom.getScrollContainer( activeElement ); return ( scrollContainer.scrollHeight === scrollContainer.clientHeight @@ -117,8 +130,9 @@ describe( 'TypeWriter', () => { const scrollPosition = await page.evaluate( () => { const { activeElement } = document.activeElement?.contentDocument ?? document; - return wp.dom.getScrollContainer( activeElement ).scrollTop; + return window.wp.dom.getScrollContainer( activeElement ).scrollTop; } ); + // Expect scrollbar to be at the top. expect( scrollPosition ).toBe( 0 ); @@ -127,7 +141,7 @@ describe( 'TypeWriter', () => { await page.evaluate( () => { const { activeElement } = document.activeElement?.contentDocument ?? document; - wp.dom.getScrollContainer( activeElement ).scrollTop += 2; + window.wp.dom.getScrollContainer( activeElement ).scrollTop += 2; } ); // Wait for the caret rectangle to be recalculated. await page.evaluate( @@ -136,39 +150,44 @@ describe( 'TypeWriter', () => { // After hitting Enter to create a new block, the caret screen // coordinates should be the same. - const initialPosition = await getCaretPosition(); + const initialPosition = await typewriterUtils.getCaretPosition(); await page.keyboard.press( 'Enter' ); await page.waitForFunction( () => { const { activeElement } = document.activeElement?.contentDocument ?? document; // Wait for the Typewriter to scroll down past the initial position. - return wp.dom.getScrollContainer( activeElement ).scrollTop > 2; + return ( + window.wp.dom.getScrollContainer( activeElement ).scrollTop > 2 + ); } ); - expect( await getDiff( initialPosition ) ).toBe( 0 ); + + expect( await typewriterUtils.getDiff( initialPosition ) ).toBe( 0 ); } ); - it( 'should maintain caret position after leaving last editable', async () => { - // Create first block. + test( 'should maintain caret position after leaving last editable', async ( { + page, + typewriterUtils, + } ) => { + // Create test blocks. await page.keyboard.press( 'Enter' ); - // Create second block. await page.keyboard.press( 'Enter' ); - // Create third block. await page.keyboard.press( 'Enter' ); // Move to first block. await page.keyboard.press( 'ArrowUp' ); await page.keyboard.press( 'ArrowUp' ); - const initialPosition = await getCaretPosition(); + const initialPosition = await typewriterUtils.getCaretPosition(); // Should maintain scroll position. - await page.keyboard.press( 'Enter' ); - - expect( await getDiff( initialPosition ) ).toBeLessThanOrEqual( - BUFFER - ); + expect( + await typewriterUtils.getDiff( initialPosition ) + ).toBeLessThanOrEqual( BUFFER ); } ); - it( 'should scroll caret into view from the top', async () => { + test( 'should scroll caret into view from the top', async ( { + page, + typewriterUtils, + } ) => { // Create first block. await page.keyboard.press( 'Enter' ); @@ -177,7 +196,7 @@ describe( 'TypeWriter', () => { await page.evaluate( () => { const { activeElement } = document.activeElement?.contentDocument ?? document; - return ! wp.dom.getScrollContainer( activeElement ); + return ! window.wp.dom.getScrollContainer( activeElement ); } ) ) { await page.keyboard.press( 'Enter' ); @@ -186,13 +205,14 @@ describe( 'TypeWriter', () => { let count = 0; // Create blocks until the typewriter effect kicks in, create at - // least 10 blocks to properly test the . + // least 10 blocks to properly test it. while ( ( await page.evaluate( () => { const { activeElement } = document.activeElement?.contentDocument ?? document; return ( - wp.dom.getScrollContainer( activeElement ).scrollTop === 0 + window.wp.dom.getScrollContainer( activeElement ) + .scrollTop === 0 ); } ) ) || count < 10 @@ -207,25 +227,25 @@ describe( 'TypeWriter', () => { const { activeElement } = document.activeElement?.contentDocument ?? document; activeElement.scrollIntoView( false ); - wp.dom.getScrollContainer( activeElement ).scrollTop -= + window.wp.dom.getScrollContainer( activeElement ).scrollTop -= activeElement.offsetHeight + 10; } ); - const bottomPostition = await getCaretPosition(); + const bottomPostition = await typewriterUtils.getCaretPosition(); // Should scroll the caret back into view (preserve browser behaviour). await page.keyboard.type( 'a' ); - const newBottomPosition = await getCaretPosition(); + const newBottomPosition = await typewriterUtils.getCaretPosition(); expect( newBottomPosition ).toBeLessThanOrEqual( bottomPostition ); // Should maintain new caret position. await page.keyboard.press( 'Enter' ); - expect( await getDiff( newBottomPosition ) ).toBeLessThanOrEqual( - BUFFER - ); + expect( + await typewriterUtils.getDiff( newBottomPosition ) + ).toBeLessThanOrEqual( BUFFER ); await page.keyboard.press( 'Backspace' ); @@ -239,22 +259,45 @@ describe( 'TypeWriter', () => { const { activeElement } = document.activeElement?.contentDocument ?? document; activeElement.scrollIntoView(); - wp.dom.getScrollContainer( activeElement ).scrollTop += + window.wp.dom.getScrollContainer( activeElement ).scrollTop += activeElement.offsetHeight + 10; } ); - const topPostition = await getCaretPosition(); + const topPostition = await typewriterUtils.getCaretPosition(); // Should scroll the caret back into view (preserve browser behaviour). await page.keyboard.type( 'a' ); - const newTopPosition = await getCaretPosition(); + const newTopPosition = await typewriterUtils.getCaretPosition(); expect( newTopPosition ).toBeGreaterThan( topPostition ); // Should maintain new caret position. await page.keyboard.press( 'Enter' ); - expect( await getDiff( newTopPosition ) ).toBeLessThanOrEqual( BUFFER ); + expect( + await typewriterUtils.getDiff( newTopPosition ) + ).toBeLessThanOrEqual( BUFFER ); } ); } ); + +class TypewriterUtils { + /** @type {Page} */ + #page; + + constructor( { page } ) { + this.#page = page; + } + + async getCaretPosition() { + return await this.#page.evaluate( () => { + return window.wp.dom.computeCaretRect( + document.activeElement?.contentWindow ?? window + ).y; + } ); + } + + async getDiff( caretPosition ) { + return Math.abs( ( await this.getCaretPosition() ) - caretPosition ); + } +}