Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Components: Refactor SlotFill #19242

Merged
merged 31 commits into from
Feb 26, 2020
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6061ecd
SlotFill initial implementation
diegohaz Dec 19, 2019
7e38b5a
Add manifest-devhub.json
diegohaz Dec 19, 2019
d032bee
Accept as prop on Slot
diegohaz Dec 19, 2019
13b60ef
Update stories
diegohaz Dec 19, 2019
0c77a8a
Update README.md
diegohaz Dec 20, 2019
bd84603
Update code
diegohaz Dec 20, 2019
23d3abc
Add slot-fill2 entries to components index file
diegohaz Dec 20, 2019
229d432
Add unit tests
diegohaz Dec 20, 2019
7aff149
Merge branch 'master' into update/slot-fill
diegohaz Jan 29, 2020
e8bbb9b
Fix git conflicts
diegohaz Jan 29, 2020
df31e9c
Merge branch 'master' into update/slot-fill
diegohaz Feb 3, 2020
1fc8026
Lint code
diegohaz Feb 3, 2020
81c4741
Merge branch 'master' into update/slot-fill
diegohaz Feb 18, 2020
c6f6341
Update manifest.json
diegohaz Feb 18, 2020
7085a53
Try: replace <Slot bubblesVirtually /> by Slot2
diegohaz Feb 18, 2020
4ab2b9e
Set a default value for SlotFillContext
diegohaz Feb 18, 2020
ba2ea9f
Update SlotFillContext slots initial value
diegohaz Feb 18, 2020
2db94e9
Refactor code
diegohaz Feb 19, 2020
a275949
Update docs/manifest.json
diegohaz Feb 19, 2020
0b11256
Merge branch 'master' into update/slot-fill
diegohaz Feb 19, 2020
94020b0
Update story title separator
diegohaz Feb 19, 2020
fcf9398
Update snapshots
diegohaz Feb 19, 2020
104335e
Remove bubblesVirtually implementation from BaseFill/Slot
diegohaz Feb 19, 2020
aa61605
Make sure fills are being created in the right order
diegohaz Feb 21, 2020
d1bfe35
Merge branch 'master' into update/slot-fill
diegohaz Feb 21, 2020
64987ca
Add comment on Fill dual rendering
diegohaz Feb 21, 2020
7c3e5fc
Add code comments on Fill
diegohaz Feb 22, 2020
2b31dbd
Try with compareDocumentPosition
diegohaz Feb 23, 2020
04f44ce
Revert ordering feature
diegohaz Feb 23, 2020
ce97491
Uncomment test
diegohaz Feb 25, 2020
d3ebcc2
Fix top toolbar not updating properly
diegohaz Feb 25, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/manifest-devhub.json
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,12 @@
"markdown_source": "../packages/components/src/slot-fill/README.md",
"parent": "components"
},
{
"title": "SlotFill2",
"slug": "slot-fill2",
"markdown_source": "../packages/components/src/slot-fill2/README.md",
"parent": "components"
},
{
"title": "Snackbar",
"slug": "snackbar",
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export {
Consumer as __experimentalSlotFillConsumer,
} from './slot-fill';

export {
createSlotFill as __experimentalCreateSlotFill,
Slot as __experimentalSlot,
Fill as __experimentalFill,
SlotFillProvider as __experimentalSlotFillProvider,
} from './slot-fill2';

// Higher-Order Components
export { default as navigateRegions } from './higher-order/navigate-regions';
export {
Expand Down
6 changes: 6 additions & 0 deletions packages/components/src/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export { default as Icon } from './icon';
export { default as IconButton } from './icon-button';
export { default as Spinner } from './spinner';
export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot-fill';
export {
createSlotFill as __experimentalCreateSlotFill,
Slot as __experimentalSlot,
Fill as __experimentalFill,
SlotFillProvider as __experimentalSlotFillProvider,
} from './slot-fill2';
export { default as BaseControl } from './base-control';
export { default as TextareaControl } from './textarea-control';
export { default as PanelBody } from './panel/body';
Expand Down
66 changes: 66 additions & 0 deletions packages/components/src/slot-fill2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Slot Fill

Slot and Fill are a pair of components which enable developers to render elsewhere in a React element tree, a pattern often referred to as "portal" rendering. It is a pattern for component extensibility, where a single Slot may be occupied by an indeterminate number of Fills elsewhere in the application.

Slot Fill is heavily inspired by the [`react-slot-fill` library](https://github.com/camwest/react-slot-fill), but uses [React's own portal rendering API](https://reactjs.org/docs/portals.html).

## Usage

At the root of your application, you must render a `SlotFillProvider` which coordinates Slot and Fill rendering.

Then, render a Slot component anywhere in your application, giving it a name.

Any Fill will automatically occupy this Slot space, even if rendered elsewhere in the application.

You can either use the Fill component directly, or a wrapper component type as in the below example to abstract the slot name from consumer awareness.

```jsx
import { SlotFillProvider, Slot, Fill, Panel, PanelBody } from '@wordpress/components';

const MySlotFillProvider = () => {
const MyPanelSlot = () => (
<Panel header="Panel with slot">
<PanelBody>
<Slot name="MyPanelSlot"/>
</PanelBody>
</Panel>
);

MyPanelSlot.Content = () => (
<Fill name="MyPanelSlot">
Panel body
</Fill>
);

return (
<SlotFillProvider>
<MyPanelSlot />
<MyPanelSlot.Content />
</SlotFillProvider>
);
};
```

There is also `createSlotFill` helper method which was created to simplify the process of matching the corresponding `Slot` and `Fill` components:

```jsx
const { Fill, Slot } = createSlotFill( 'Toolbar' );

const ToolbarItem = () => (
<Fill>
My item
</Fill>
);

const Toolbar = () => (
<div className="toolbar">
<Slot />
</div>
);
```

## Props

The `SlotFillProvider` component does not accept any props.

Both `Slot` and `Fill` accept a `name` string prop, where a `Slot` with a given `name` will render the `children` of any associated `Fill`s.
diegohaz marked this conversation as resolved.
Show resolved Hide resolved
94 changes: 94 additions & 0 deletions packages/components/src/slot-fill2/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* WordPress dependencies
*/
import {
createContext,
useMemo,
useCallback,
useState,
useContext,
} from '@wordpress/element';

export const SlotFillContext = createContext();

function useSlotRegistry() {
const [ slots, setSlots ] = useState( {} );
diegohaz marked this conversation as resolved.
Show resolved Hide resolved

const register = useCallback( ( name, ref, fillProps = {} ) => {
setSlots( ( prevSlots ) => ( {
...prevSlots,
[ name ]: {
ref,
fillProps,
},
} ) );
}, [] );

const update = useCallback( ( name, ref, fillProps ) => {
setSlots( ( prevSlots ) => ( {
...prevSlots,
[ name ]: {
ref: ref || prevSlots[ name ].ref,
fillProps: fillProps || prevSlots[ name ].fillProps || {},
},
} ) );
}, [] );

const unregister = useCallback( ( name ) => {
setSlots( ( prevSlots ) => {
// eslint-disable-next-line no-unused-vars
const { [ name ]: _, ...nextSlots } = prevSlots;
return nextSlots;
} );
}, [] );

// Memoizing the return value so it can be directly passed to Provider value
const registry = useMemo(
() => ( {
slots,
register,
update,
unregister,
} ),
[ slots, register, update, unregister ]
);

return registry;
}

export function useSlot( name ) {
const registry = useContext( SlotFillContext );

const { ref, fillProps } = registry.slots[ name ] || {};

const update = useCallback(
( slotRef, slotFillProps ) => {
registry.update( name, slotRef, slotFillProps );
},
[ registry.update ]
);

const unregister = useCallback( () => {
registry.unregister( name );
}, [ registry.unregister ] );

if ( ! registry.slots[ name ] ) {
return null;
}

return {
ref,
fillProps,
update,
unregister,
};
}

export default function SlotFillProvider( { children } ) {
const registry = useSlotRegistry();
return (
<SlotFillContext.Provider value={ registry }>
{ children }
</SlotFillContext.Provider>
);
}
23 changes: 23 additions & 0 deletions packages/components/src/slot-fill2/fill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* WordPress dependencies
*/
import { createPortal } from '@wordpress/element';

/**
* Internal dependencies
*/
import { useSlot } from './context';

export default function Fill( { name, children } ) {
const slot = useSlot( name );
diegohaz marked this conversation as resolved.
Show resolved Hide resolved

if ( ! slot ) {
return null;
}

if ( typeof children === 'function' ) {
children = children( slot.fillProps );
}

return createPortal( children, slot.ref.current );
}
23 changes: 23 additions & 0 deletions packages/components/src/slot-fill2/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
diegohaz marked this conversation as resolved.
Show resolved Hide resolved
* Internal dependencies
*/
import Slot from './slot';
import Fill from './fill';
import SlotFillProvider from './context';

export { Slot };
export { Fill };
export { SlotFillProvider };

export function createSlotFill( name ) {
const FillComponent = ( props ) => <Fill name={ name } { ...props } />;
FillComponent.displayName = name + 'Fill';

const SlotComponent = ( props ) => <Slot name={ name } { ...props } />;
SlotComponent.displayName = name + 'Slot';

return {
Fill: FillComponent,
Slot: SlotComponent,
};
}
42 changes: 42 additions & 0 deletions packages/components/src/slot-fill2/slot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef, useLayoutEffect, useContext } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';

/**
* Internal dependencies
*/
import { SlotFillContext, useSlot } from './context';

export default function Slot( {
name,
fillProps = {},
as: Component = 'div',
...props
} ) {
const registry = useContext( SlotFillContext );
const ref = useRef();
const slot = useSlot( name );

useEffect( () => {
registry.register( name, ref, fillProps );
return () => {
registry.unregister( name );
diegohaz marked this conversation as resolved.
Show resolved Hide resolved
};
// We are not including fillProps in the deps because we don't want to
// unregister and register the slot whenever fillProps change, which would
// cause the fill to be re-mounted. We are only considering the initial value
// of fillProps.
}, [ registry.register, registry.unregister, name ] );

// fillProps may be an update that interact with the layout, so
// we useLayoutEffect
useLayoutEffect( () => {
if ( slot && ! isShallowEqual( slot.fillProps, fillProps ) ) {
registry.update( name, ref, fillProps );
}
} );

return <Component ref={ ref } { ...props } />;
diegohaz marked this conversation as resolved.
Show resolved Hide resolved
}
63 changes: 63 additions & 0 deletions packages/components/src/slot-fill2/stories/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { text, number } from '@storybook/addon-knobs';

/**
* WordPress dependencies
*/
import { createContext, useContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import { Slot, Fill, SlotFillProvider } from '../';

export default { title: 'Components|SlotFill', component: Slot };

export const _default = () => {
return (
<SlotFillProvider>
<h2>Profile</h2>
<p>Name: <Slot as="span" name="name" /></p>
<p>Age: <Slot as="span" name="age" /></p>
<Fill name="name">Grace</Fill>
<Fill name="age">33</Fill>
</SlotFillProvider>
);
};

export const withFillProps = () => {
const name = text( 'name', 'Grace' );
const age = number( 'age', 33 );
return (
<SlotFillProvider>
<h2>Profile</h2>
<p>Name: <Slot as="span" name="name" fillProps={ { name } } /></p>
<p>Age: <Slot as="span" name="age" fillProps={ { age } } /></p>
<Fill name="name">{ ( fillProps ) => fillProps.name }</Fill>
<Fill name="age">{ ( fillProps ) => fillProps.age }</Fill>
</SlotFillProvider>
);
};

export const withContext = () => {
const Context = createContext();
const ContextFill = ( { name } ) => {
const value = useContext( Context );
return <Fill name={ name }>{ value }</Fill>;
};
return (
<SlotFillProvider>
<h2>Profile</h2>
<p>Name: <Slot as="span" name="name" /></p>
<p>Age: <Slot as="span" name="age" /></p>
<Context.Provider value="Grace">
<ContextFill name="name" />
</Context.Provider>
<Context.Provider value={ 33 }>
<ContextFill name="age" />
</Context.Provider>
</SlotFillProvider>
);
};
28 changes: 28 additions & 0 deletions packages/components/src/slot-fill2/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { shallow } from 'enzyme';

/**
* Internal dependencies
*/
import { createSlotFill, Fill, Slot } from '../';

describe( 'createSlotFill', () => {
const SLOT_NAME = 'MySlotFill';
const MySlotFill = createSlotFill( SLOT_NAME );

test( 'should match snapshot for Fill', () => {
const wrapper = shallow( <MySlotFill.Fill /> );

expect( wrapper.type() ).toBe( Fill );
expect( wrapper.prop( 'name' ) ).toBe( SLOT_NAME );
} );

test( 'should match snapshot for Slot', () => {
const wrapper = shallow( <MySlotFill.Slot /> );

expect( wrapper.type() ).toBe( Slot );
expect( wrapper.prop( 'name' ) ).toBe( SLOT_NAME );
} );
} );
Loading