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]',
+ ] );
+ } );
+} );