-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react-motions): add PresenceGroup (#30543)
- Loading branch information
1 parent
0caeee1
commit 5fb68e9
Showing
18 changed files
with
850 additions
and
3 deletions.
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-motions-preview-806677e6-3564-4fa7-8da6-d3372a84161f.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "patch", | ||
"comment": "feat: add PresenceGroup component", | ||
"packageName": "@fluentui/react-motions-preview", | ||
"email": "olfedias@microsoft.com", | ||
"dependentChangeType": "patch" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
packages/react-components/react-motions-preview/src/components/PresenceGroup.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import { render } from '@testing-library/react'; | ||
import * as React from 'react'; | ||
|
||
import { PresenceGroupChildContext, PresenceGroupChildContextValue } from '../contexts/PresenceGroupChildContext'; | ||
import { PresenceGroup } from './PresenceGroup'; | ||
|
||
const TestComponent: React.FC<{ | ||
id: string; | ||
onRender: (data: { id: string; appear: boolean; visible: boolean }) => void; | ||
}> = props => { | ||
const { id, onRender } = props; | ||
const context = React.useContext(PresenceGroupChildContext) as PresenceGroupChildContextValue; | ||
|
||
React.useEffect(() => { | ||
onRender({ | ||
id, | ||
appear: context.appear, | ||
visible: context.visible, | ||
}); | ||
}); | ||
|
||
return <div>{id}</div>; | ||
}; | ||
|
||
describe('PresenceGroup', () => { | ||
it('renders items an provides context to them', () => { | ||
const onItemRender = jest.fn(); | ||
|
||
render( | ||
<PresenceGroup> | ||
<TestComponent id="1" onRender={onItemRender} /> | ||
<TestComponent id="2" onRender={onItemRender} /> | ||
</PresenceGroup>, | ||
); | ||
|
||
expect(onItemRender).toHaveBeenCalledTimes(2); | ||
expect(onItemRender).toHaveBeenNthCalledWith(1, { id: '1', appear: false, visible: true }); | ||
expect(onItemRender).toHaveBeenNthCalledWith(2, { id: '2', appear: false, visible: true }); | ||
}); | ||
|
||
it('updates context when items are added or removed', () => { | ||
const onItemRender = jest.fn(); | ||
|
||
const { rerender } = render( | ||
<PresenceGroup> | ||
<TestComponent key="1" id="1" onRender={onItemRender} /> | ||
<TestComponent key="2" id="2" onRender={onItemRender} /> | ||
</PresenceGroup>, | ||
); | ||
|
||
expect(onItemRender).toHaveBeenCalledTimes(2); | ||
onItemRender.mockClear(); | ||
|
||
rerender( | ||
<PresenceGroup> | ||
<TestComponent key="1" id="1" onRender={onItemRender} /> | ||
<TestComponent key="3" id="3" onRender={onItemRender} /> | ||
</PresenceGroup>, | ||
); | ||
|
||
expect(onItemRender).toHaveBeenCalledTimes(3); | ||
expect(onItemRender).toHaveBeenNthCalledWith(1, { id: '1', appear: false, visible: true }); | ||
expect(onItemRender).toHaveBeenNthCalledWith(2, { id: '3', appear: true, visible: true }); | ||
expect(onItemRender).toHaveBeenNthCalledWith(3, { id: '2', appear: false, visible: false }); | ||
}); | ||
}); |
80 changes: 80 additions & 0 deletions
80
packages/react-components/react-motions-preview/src/components/PresenceGroup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import * as React from 'react'; | ||
|
||
import { getNextChildMapping } from '../utils/groups/getNextChildMapping'; | ||
import { getChildMapping } from '../utils/groups/getChildMapping'; | ||
import type { PresenceGroupChildMapping } from '../utils/groups/types'; | ||
import { PresenceGroupItemProvider } from './PresenceGroupItemProvider'; | ||
|
||
type PresenceGroupProps = { | ||
children: React.ReactNode; | ||
}; | ||
|
||
type PresenceGroupState = { | ||
childMapping: PresenceGroupChildMapping; | ||
firstRender: boolean; | ||
}; | ||
|
||
/* eslint-disable @typescript-eslint/explicit-member-accessibility */ | ||
/* eslint-disable @typescript-eslint/naming-convention */ | ||
|
||
export class PresenceGroup extends React.Component<PresenceGroupProps, PresenceGroupState> { | ||
#mounted: boolean = false; | ||
|
||
static getDerivedStateFromProps( | ||
nextProps: PresenceGroupProps, | ||
{ childMapping: prevChildMapping, firstRender }: PresenceGroupState, | ||
) { | ||
const nextChildMapping = getChildMapping(nextProps.children); | ||
|
||
return { | ||
childMapping: firstRender ? nextChildMapping : getNextChildMapping(prevChildMapping, nextChildMapping), | ||
firstRender: false, | ||
}; | ||
} | ||
|
||
constructor(props: PresenceGroupProps, context: unknown) { | ||
super(props, context); | ||
|
||
this.state = { | ||
childMapping: {}, | ||
firstRender: true, | ||
}; | ||
} | ||
|
||
#handleExit(childKey: string) { | ||
const currentChildMapping = getChildMapping(this.props.children); | ||
|
||
if (childKey in currentChildMapping) { | ||
return; | ||
} | ||
|
||
if (this.#mounted) { | ||
this.setState(state => { | ||
const childMapping = { ...state.childMapping }; | ||
delete childMapping[childKey]; | ||
|
||
return { childMapping }; | ||
}); | ||
} | ||
} | ||
|
||
componentDidMount() { | ||
this.#mounted = true; | ||
} | ||
|
||
componentWillUnmount() { | ||
this.#mounted = false; | ||
} | ||
|
||
render() { | ||
return ( | ||
<> | ||
{Object.entries(this.state.childMapping).map(([childKey, childProps]) => ( | ||
<PresenceGroupItemProvider {...childProps} childKey={childKey} key={childKey} onExit={this.#handleExit}> | ||
{childProps.element} | ||
</PresenceGroupItemProvider> | ||
))} | ||
</> | ||
); | ||
} | ||
} |
30 changes: 30 additions & 0 deletions
30
packages/react-components/react-motions-preview/src/components/PresenceGroupItemProvider.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import * as React from 'react'; | ||
|
||
import { PresenceGroupChildContext } from '../contexts/PresenceGroupChildContext'; | ||
import type { PresenceGroupChildContextValue } from '../contexts/PresenceGroupChildContext'; | ||
|
||
type PresenceGroupItemProviderProps = PresenceGroupChildContextValue & { | ||
children: React.ReactElement; | ||
childKey: string; | ||
}; | ||
|
||
/** | ||
* @internal | ||
* | ||
* Provides context for a single child of a `PresenceGroup`. Exists only to make a stable context value for a child. | ||
* Not intended for direct use. | ||
*/ | ||
export const PresenceGroupItemProvider: React.FC<PresenceGroupItemProviderProps> = props => { | ||
const { appear, childKey, onExit, visible, unmountOnExit } = props; | ||
const contextValue = React.useMemo( | ||
() => ({ | ||
appear, | ||
visible, | ||
onExit: () => onExit(childKey), | ||
unmountOnExit, | ||
}), | ||
[appear, childKey, onExit, visible, unmountOnExit], | ||
); | ||
|
||
return <PresenceGroupChildContext.Provider value={contextValue}>{props.children}</PresenceGroupChildContext.Provider>; | ||
}; |
17 changes: 17 additions & 0 deletions
17
packages/react-components/react-motions-preview/src/contexts/PresenceGroupChildContext.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import * as React from 'react'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export type PresenceGroupChildContextValue = { | ||
appear: boolean; | ||
visible: boolean; | ||
unmountOnExit: boolean; | ||
|
||
onExit: (childKey: string) => void; | ||
}; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export const PresenceGroupChildContext = React.createContext<PresenceGroupChildContextValue | undefined>(undefined); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
56 changes: 56 additions & 0 deletions
56
packages/react-components/react-motions-preview/src/utils/groups/getChildMapping.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import * as React from 'react'; | ||
import { getChildMapping } from './getChildMapping'; | ||
import { render } from '@testing-library/react'; | ||
|
||
const TestComponent: React.FC<{ | ||
children: React.ReactNode; | ||
onMapping: (mapping: ReturnType<typeof getChildMapping>) => void; | ||
}> = props => { | ||
React.useEffect(() => { | ||
props.onMapping(getChildMapping(props.children)); | ||
}); | ||
|
||
return null; | ||
}; | ||
|
||
describe('getChildMapping', () => { | ||
it('should return an object mapping key to child', () => { | ||
const onMapping = jest.fn(); | ||
|
||
render( | ||
<TestComponent onMapping={onMapping}> | ||
<div>Hello</div> | ||
<div>World</div> | ||
</TestComponent>, | ||
); | ||
|
||
expect(onMapping).toHaveBeenCalledTimes(1); | ||
expect(onMapping.mock.calls[0]).toMatchInlineSnapshot(` | ||
Array [ | ||
Object { | ||
".0": Object { | ||
"appear": false, | ||
"element": <div> | ||
Hello | ||
</div>, | ||
"unmountOnExit": true, | ||
"visible": true, | ||
}, | ||
".1": Object { | ||
"appear": false, | ||
"element": <div> | ||
World | ||
</div>, | ||
"unmountOnExit": true, | ||
"visible": true, | ||
}, | ||
}, | ||
] | ||
`); | ||
}); | ||
|
||
it('should return an empty object if children is nullable', () => { | ||
expect(getChildMapping(null)).toEqual({}); | ||
expect(getChildMapping(undefined)).toEqual({}); | ||
}); | ||
}); |
24 changes: 24 additions & 0 deletions
24
packages/react-components/react-motions-preview/src/utils/groups/getChildMapping.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import * as React from 'react'; | ||
import type { PresenceGroupChildMapping } from './types'; | ||
|
||
/** | ||
* Given `children`, return an object mapping key to child. | ||
*/ | ||
export function getChildMapping(children: React.ReactNode | undefined) { | ||
const childMapping: PresenceGroupChildMapping = {}; | ||
|
||
if (children) { | ||
React.Children.toArray(children).forEach(child => { | ||
if (React.isValidElement(child)) { | ||
childMapping[child.key ?? ''] = { | ||
appear: false, | ||
element: child, | ||
visible: true, | ||
unmountOnExit: true, | ||
}; | ||
} | ||
}); | ||
} | ||
|
||
return childMapping; | ||
} |
Oops, something went wrong.