-
Notifications
You must be signed in to change notification settings - Fork 64
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
LG-4444: OverlayContext and useOverlay (#2454)
* OverlayContext, useOverlayContext, and OverlayProvider * useOverlay to register, remove, and track isTopMostOverlay * Export overlay context related components and hooks and include provider in LG provider * README and changeset * Fix type and lint * Update registerOverlay logic and use more explicit generic type names
- Loading branch information
Showing
9 changed files
with
360 additions
and
1 deletion.
There are no files selected for viewing
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,5 @@ | ||
--- | ||
'@leafygreen-ui/leafygreen-provider': minor | ||
--- | ||
|
||
[LG-4444](https://jira.mongodb.org/browse/LG-4444): Adds `OverlayContext` to track overlays that stack on z-axis. `OverlayProvider` is included in `LeafyGreenProvider`. Adds `useOverlay` hook to register/remove overlays into `OverlayContext`. |
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
99 changes: 99 additions & 0 deletions
99
packages/leafygreen-provider/src/OverlayContext/OverlayContext.spec.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,99 @@ | ||
import React, { createRef, useState } from 'react'; | ||
import { render } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
|
||
import { renderHook } from '@leafygreen-ui/testing-lib'; | ||
|
||
import OverlayProvider, { useOverlayContext } from './OverlayContext'; | ||
|
||
function TestComponent() { | ||
const { getOverlays, registerOverlay, removeOverlay, topMostOverlay } = | ||
useOverlayContext(); | ||
const [id, setId] = useState(1); | ||
|
||
const registerNewOverlay = () => { | ||
registerOverlay({ | ||
element: document.createElement('div'), | ||
id: '' + id, | ||
ref: createRef<HTMLElement>(), | ||
}); | ||
setId(prev => prev + 1); | ||
}; | ||
|
||
const removeTopMostOverlay = () => { | ||
if (!topMostOverlay) return; | ||
|
||
removeOverlay(topMostOverlay.id); | ||
setId(prev => prev - 1); | ||
}; | ||
|
||
return ( | ||
<div> | ||
<span>Overlays count: {getOverlays().length}</span> | ||
<span>Top most overlay ID: {topMostOverlay?.id}</span> | ||
<button onClick={registerNewOverlay}>Register overlay</button> | ||
<button onClick={removeTopMostOverlay}>Remove overlay</button> | ||
</div> | ||
); | ||
} | ||
|
||
function renderProviderWithChildren() { | ||
return render( | ||
<OverlayProvider> | ||
<TestComponent /> | ||
</OverlayProvider>, | ||
); | ||
} | ||
|
||
describe('OverlayProvider', () => { | ||
test('registers an overlay', () => { | ||
const { getByText } = renderProviderWithChildren(); | ||
|
||
expect(getByText('Overlays count: 0')).toBeInTheDocument(); | ||
|
||
userEvent.click(getByText('Register overlay')); | ||
|
||
expect(getByText('Overlays count: 1')).toBeInTheDocument(); | ||
expect(getByText('Top most overlay ID: 1')).toBeInTheDocument(); | ||
}); | ||
|
||
test('removes an overlay', () => { | ||
const { getByText } = renderProviderWithChildren(); | ||
|
||
userEvent.click(getByText('Register overlay')); | ||
userEvent.click(getByText('Register overlay')); | ||
|
||
expect(getByText('Overlays count: 2')).toBeInTheDocument(); | ||
expect(getByText('Top most overlay ID: 2')).toBeInTheDocument(); | ||
|
||
userEvent.click(getByText('Remove overlay')); | ||
|
||
expect(getByText('Overlays count: 1')).toBeInTheDocument(); | ||
expect(getByText('Top most overlay ID: 1')).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
describe('useOverlayContext', () => { | ||
test('provides expected context values when used within OverlayProvider', () => { | ||
const wrapper = ({ children }) => ( | ||
<OverlayProvider>{children}</OverlayProvider> | ||
); | ||
const { result } = renderHook(useOverlayContext, { wrapper }); | ||
|
||
expect(result.current).toHaveProperty('getOverlays'); | ||
expect(result.current).toHaveProperty('registerOverlay'); | ||
expect(result.current).toHaveProperty('removeOverlay'); | ||
expect(result.current).toHaveProperty('topMostOverlay'); | ||
}); | ||
|
||
test('throws an error when used outside of OverlayProvider', () => { | ||
try { | ||
renderHook(useOverlayContext); | ||
} catch (error) { | ||
expect(error).toBeInstanceOf(Error); | ||
expect(error.message).toBe( | ||
'useOverlayContext must be used within an OverlayProvider', | ||
); | ||
} | ||
}); | ||
}); |
79 changes: 79 additions & 0 deletions
79
packages/leafygreen-provider/src/OverlayContext/OverlayContext.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,79 @@ | ||
import React, { | ||
createContext, | ||
ReactNode, | ||
RefObject, | ||
useCallback, | ||
useContext, | ||
useMemo, | ||
useState, | ||
} from 'react'; | ||
import PropTypes from 'prop-types'; | ||
|
||
interface OverlayItem<TOverlayElement extends HTMLElement = HTMLElement> { | ||
element: TOverlayElement; | ||
ref: RefObject<TOverlayElement>; | ||
id: string; | ||
} | ||
|
||
export interface OverlayContextProps { | ||
getOverlays: () => Array<OverlayItem>; | ||
registerOverlay: (overlay: OverlayItem) => void; | ||
removeOverlay: (idToRemove: string) => void; | ||
topMostOverlay: OverlayItem | undefined; | ||
} | ||
|
||
export const OverlayContext = createContext<OverlayContextProps | undefined>( | ||
undefined, | ||
); | ||
|
||
export const useOverlayContext = (): OverlayContextProps => { | ||
const context = useContext(OverlayContext); | ||
|
||
if (!context) { | ||
throw new Error('useOverlayContext must be used within an OverlayProvider'); | ||
} | ||
|
||
return context; | ||
}; | ||
|
||
/** | ||
* Global provider tracking overlays that stack on z-axis | ||
*/ | ||
const OverlayProvider = ({ children }: { children: ReactNode }) => { | ||
const [overlays, setOverlays] = useState<Array<OverlayItem>>([]); | ||
|
||
const topMostOverlay = useMemo( | ||
() => overlays[overlays.length - 1], | ||
[overlays], | ||
); | ||
|
||
const registerOverlay = useCallback((overlay: OverlayItem) => { | ||
if (overlays.some(item => item.id === overlay.id)) return; | ||
setOverlays(prev => [...prev, { ...overlay }]); | ||
}, []); | ||
|
||
const removeOverlay = useCallback((idToRemove: string) => { | ||
setOverlays(prev => prev.filter(item => item.id !== idToRemove)); | ||
}, []); | ||
|
||
return ( | ||
<OverlayContext.Provider | ||
value={{ | ||
getOverlays: () => overlays, | ||
registerOverlay, | ||
removeOverlay, | ||
topMostOverlay, | ||
}} | ||
> | ||
{children} | ||
</OverlayContext.Provider> | ||
); | ||
}; | ||
|
||
OverlayProvider.displayName = 'OverlayProvider'; | ||
|
||
OverlayProvider.propTypes = { | ||
children: PropTypes.node, | ||
}; | ||
|
||
export default OverlayProvider; |
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 @@ | ||
export { | ||
OverlayContext, | ||
type OverlayContextProps, | ||
default as OverlayProvider, | ||
useOverlayContext, | ||
} from './OverlayContext'; | ||
export { useOverlay } from './useOverlay'; |
92 changes: 92 additions & 0 deletions
92
packages/leafygreen-provider/src/OverlayContext/useOverlay.spec.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,92 @@ | ||
import { renderHook } from '@leafygreen-ui/testing-lib'; | ||
|
||
import * as OverlayContextModule from './OverlayContext'; | ||
import OverlayProvider from './OverlayContext'; | ||
import { useOverlay } from './useOverlay'; | ||
|
||
jest.mock('@leafygreen-ui/hooks', () => ({ | ||
...jest.requireActual('@leafygreen-ui/hooks'), | ||
useForwardedRef: jest.fn(ref => ref), | ||
useIdAllocator: jest.fn(() => 'mock-id'), | ||
})); | ||
|
||
describe('useOverlay', () => { | ||
let mockRegisterOverlay; | ||
let mockRemoveOverlay; | ||
let mockTopMostOverlay; | ||
|
||
beforeEach(() => { | ||
mockRegisterOverlay = jest.fn(); | ||
mockRemoveOverlay = jest.fn(); | ||
mockTopMostOverlay = undefined; | ||
|
||
jest | ||
.spyOn(OverlayContextModule, 'useOverlayContext') | ||
.mockImplementation(() => ({ | ||
getOverlays: jest.fn(), | ||
registerOverlay: mockRegisterOverlay, | ||
removeOverlay: mockRemoveOverlay, | ||
topMostOverlay: mockTopMostOverlay, | ||
})); | ||
|
||
document.contains = jest.fn(() => true); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
test('registers overlay when component mounts', () => { | ||
const ref = { current: document.createElement('div') }; | ||
renderHook(() => useOverlay(ref), { wrapper: OverlayProvider }); | ||
|
||
expect(mockRegisterOverlay).toHaveBeenCalledTimes(1); | ||
expect(mockRegisterOverlay).toHaveBeenCalledWith({ | ||
element: ref.current, | ||
id: 'mock-id', | ||
ref, | ||
}); | ||
}); | ||
|
||
test('does not register overlay when ref is not attached', () => { | ||
const ref = { current: null }; | ||
renderHook(() => useOverlay(ref), { wrapper: OverlayProvider }); | ||
|
||
expect(mockRegisterOverlay).not.toHaveBeenCalled(); | ||
}); | ||
|
||
test('removes overlay on unmount', () => { | ||
mockRemoveOverlay = jest | ||
.spyOn(OverlayContextModule.useOverlayContext(), 'removeOverlay') | ||
.mockImplementation(jest.fn); | ||
const ref = { current: document.createElement('div') }; | ||
const { unmount } = renderHook(() => useOverlay(ref), { | ||
wrapper: OverlayProvider, | ||
}); | ||
|
||
unmount(); | ||
|
||
expect(mockRemoveOverlay).toHaveBeenCalledTimes(1); | ||
expect(mockRemoveOverlay).toHaveBeenCalledWith('mock-id'); | ||
}); | ||
|
||
test('returns id and ref', () => { | ||
const ref = { current: document.createElement('div') }; | ||
const { result } = renderHook(() => useOverlay(ref), { | ||
wrapper: OverlayProvider, | ||
}); | ||
|
||
expect(result.current.id).toBe('mock-id'); | ||
expect(result.current.ref).toBe(ref); | ||
}); | ||
|
||
test('returns correct isTopMostOverlay value', () => { | ||
const ref = { current: document.createElement('div') }; | ||
mockTopMostOverlay = { id: 'mock-id' }; | ||
const { result } = renderHook(() => useOverlay(ref), { | ||
wrapper: OverlayProvider, | ||
}); | ||
|
||
expect(result.current.isTopMostOverlay).toBe(true); | ||
}); | ||
}); |
49 changes: 49 additions & 0 deletions
49
packages/leafygreen-provider/src/OverlayContext/useOverlay.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,49 @@ | ||
import { ForwardedRef, RefObject, useMemo } from 'react'; | ||
|
||
import { | ||
useForwardedRef, | ||
useIdAllocator, | ||
useIsomorphicLayoutEffect, | ||
} from '@leafygreen-ui/hooks'; | ||
|
||
import { useOverlayContext } from './OverlayContext'; | ||
|
||
/** | ||
* Registers a component as an overlay in the {@link OverlayContext} on mount. | ||
* Removes the overlay component on unmount. Checks if `isTopMostOverlay` | ||
*/ | ||
export const useOverlay = <TOverlayElement extends HTMLElement>( | ||
fwdRef: RefObject<TOverlayElement> | ForwardedRef<TOverlayElement>, | ||
) => { | ||
const ref: RefObject<TOverlayElement> = useForwardedRef(fwdRef, null); | ||
const { topMostOverlay, registerOverlay, removeOverlay } = | ||
useOverlayContext(); | ||
const id = useIdAllocator({ prefix: 'overlay' }); | ||
|
||
useIsomorphicLayoutEffect(() => { | ||
const refAttached = ref.current !== null; | ||
const refExists = document.contains(ref.current); | ||
|
||
if (refAttached && refExists) { | ||
registerOverlay({ | ||
element: ref.current, | ||
id, | ||
ref, | ||
}); | ||
} | ||
|
||
return () => { | ||
removeOverlay(id); | ||
}; | ||
}, [ref.current]); | ||
|
||
const isTopMostOverlay = useMemo(() => { | ||
return topMostOverlay ? topMostOverlay.id === id : false; | ||
}, [topMostOverlay]); | ||
|
||
return { | ||
id, | ||
isTopMostOverlay, | ||
ref, | ||
}; | ||
}; |
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