diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-slots/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/block.json new file mode 100644 index 0000000000000..f79f89a6e81b8 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-slots", + "title": "E2E Interactivity tests - directive slots", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-slots-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-slots/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/render.php new file mode 100644 index 0000000000000..5c1558d35403d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/render.php @@ -0,0 +1,67 @@ + +
+
+
[1]
+
[2]
+
[3]
+
[4]
+
[5]
+
+ +
+ initial +
+ +
+ + + + + + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-slots/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/view.js new file mode 100644 index 0000000000000..ab5b39379f3a8 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-slots/view.js @@ -0,0 +1,18 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + slot: '' + }, + actions: { + changeSlot: ( { state, event } ) => { + state.slot = event.target.dataset.slot; + }, + updateSlotText: ( { context } ) => { + const n = context.text[1]; + context.text = `[${n} updated]`; + }, + }, + } ); +} )( window ); diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 62515115484fa..6a4565f3ad183 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -9,6 +9,10 @@ - Support region-based client-side navigation. ([#53733](https://github.com/WordPress/gutenberg/pull/53733)) - Improve `data-wp-bind` hydration to match Preact's logic. ([#54003](https://github.com/WordPress/gutenberg/pull/54003)) +### New Features + +- Add new directives that implement the Slot and Fill pattern: `data-wp-slot-provider`, `data-wp-slot` and `data-wp-fill`. ([#53958](https://github.com/WordPress/gutenberg/pull/53958)) + ## 2.1.0 (2023-08-16) ### New Features diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 0fd532debc775..f0a3d7e32e09e 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -10,6 +10,7 @@ import { deepSignal, peek } from 'deepsignal'; import { createPortal } from './portals'; import { useSignalEffect } from './utils'; import { directive } from './hooks'; +import { SlotProvider, Slot, Fill } from './slots'; const isObject = ( item ) => item && typeof item === 'object' && ! Array.isArray( item ); @@ -305,4 +306,72 @@ export default () => { } ); } ); + + // data-wp-slot + directive( + 'slot', + ( { + directives: { + slot: { default: slot }, + }, + props: { children }, + element, + } ) => { + const name = typeof slot === 'string' ? slot : slot.name; + const position = slot.position || 'children'; + + if ( position === 'before' ) { + return ( + <> + + { children } + + ); + } + if ( position === 'after' ) { + return ( + <> + { children } + + + ); + } + if ( position === 'replace' ) { + return { children }; + } + if ( position === 'children' ) { + element.props.children = ( + { element.props.children } + ); + } + }, + { priority: 4 } + ); + + // data-wp-fill + directive( + 'fill', + ( { + directives: { + fill: { default: fill }, + }, + props: { children }, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + const slot = evaluate( fill, { context: contextValue } ); + return { children }; + }, + { priority: 4 } + ); + + // data-wp-slot-provider + directive( + 'slot-provider', + ( { props: { children } } ) => ( + { children } + ), + { priority: 4 } + ); }; diff --git a/packages/interactivity/src/slots.js b/packages/interactivity/src/slots.js new file mode 100644 index 0000000000000..e8bc6ddfa368f --- /dev/null +++ b/packages/interactivity/src/slots.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { createContext } from 'preact'; +import { useContext, useEffect } from 'preact/hooks'; +import { signal } from '@preact/signals'; + +const slotsContext = createContext(); + +export const Fill = ( { slot, children } ) => { + const slots = useContext( slotsContext ); + + useEffect( () => { + if ( slot ) { + slots.value = { ...slots.value, [ slot ]: children }; + return () => { + slots.value = { ...slots.value, [ slot ]: null }; + }; + } + }, [ slots, slot, children ] ); + + return !! slot ? null : children; +}; + +export const SlotProvider = ( { children } ) => { + return ( + // TODO: We can change this to use deepsignal once this PR is merged. + // https://github.com/luisherranz/deepsignal/pull/38 + + { children } + + ); +}; + +export const Slot = ( { name, children } ) => { + const slots = useContext( slotsContext ); + return slots.value[ name ] || children; +}; diff --git a/test/e2e/specs/interactivity/directive-slots.spec.ts b/test/e2e/specs/interactivity/directive-slots.spec.ts new file mode 100644 index 0000000000000..d93e50f767215 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-slots.spec.ts @@ -0,0 +1,186 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-slot', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-slots' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-slots' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should render the fill in its children by default', async ( { + page, + } ) => { + const slot1 = page.getByTestId( 'slot-1' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-1-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot1.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slot1 ).toHaveText( 'fill inside slot 1' ); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + 'fill inside slot 1', + '[2]', + '[3]', + '[4]', + '[5]', + ] ); + } ); + + test( 'should render the fill before if specified', async ( { page } ) => { + const slot2 = page.getByTestId( 'slot-2' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-2-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot2 ).toHaveText( '[2]' ); + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + 'fill inside slots', + '[2]', + '[3]', + '[4]', + '[5]', + ] ); + } ); + + test( 'should render the fill after if specified', async ( { page } ) => { + const slot3 = page.getByTestId( 'slot-3' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-3-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot3 ).toHaveText( '[3]' ); + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3]', + 'fill inside slots', + '[4]', + '[5]', + ] ); + } ); + + test( 'should render the fill in its children if specified', async ( { + page, + } ) => { + const slot4 = page.getByTestId( 'slot-4' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-4-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot4.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slot4 ).toHaveText( 'fill inside slot 4' ); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3]', + 'fill inside slot 4', + '[5]', + ] ); + } ); + + test( 'should be replaced by the fill if specified', async ( { page } ) => { + const slot5 = page.getByTestId( 'slot-5' ); + const slots = page.getByTestId( 'slots' ); + const fillContainer = page.getByTestId( 'fill-container' ); + + await page.getByTestId( 'slot-5-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + await expect( slot5 ).not.toBeVisible(); + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3]', + '[4]', + 'fill inside slots', + ] ); + } ); + + test( 'should keep the fill in its original position if no slot matches', async ( { + page, + } ) => { + const fillContainer = page.getByTestId( 'fill-container' ); + await expect( fillContainer.getByTestId( 'fill' ) ).toBeVisible(); + + await page.getByTestId( 'slot-1-button' ).click(); + + await expect( fillContainer ).toBeEmpty(); + + await page.getByTestId( 'reset' ).click(); + + await expect( fillContainer.getByTestId( 'fill' ) ).toBeVisible(); + } ); + + test( 'should not be re-mounted when adding the fill before', async ( { + page, + } ) => { + const slot2 = page.getByTestId( 'slot-2' ); + const slots = page.getByTestId( 'slots' ); + + await expect( slot2 ).toHaveText( '[2]' ); + + await slot2.click(); + + await expect( slot2 ).toHaveText( '[2 updated]' ); + + await page.getByTestId( 'slot-2-button' ).click(); + + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + 'fill inside slots', + '[2 updated]', + '[3]', + '[4]', + '[5]', + ] ); + } ); + + test( 'should not be re-mounted when adding the fill after', async ( { + page, + } ) => { + const slot3 = page.getByTestId( 'slot-3' ); + const slots = page.getByTestId( 'slots' ); + + await expect( slot3 ).toHaveText( '[3]' ); + + await slot3.click(); + + await expect( slot3 ).toHaveText( '[3 updated]' ); + + await page.getByTestId( 'slot-3-button' ).click(); + + await expect( slots.getByTestId( 'fill' ) ).toBeVisible(); + await expect( slots.locator( 'css= > *' ) ).toHaveText( [ + '[1]', + '[2]', + '[3 updated]', + 'fill inside slots', + '[4]', + '[5]', + ] ); + } ); +} );