Last active
March 23, 2024 03:40
-
-
Save maapteh/8a44f4e1f3d52c0fe82560ec9f38b02a to your computer and use it in GitHub Desktop.
useViewport hook for React
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
// MODULE | |
import React, { | |
FC, | |
ReactNode, | |
useState, | |
useEffect, | |
createContext, | |
useContext, | |
useCallback, | |
} from 'react'; | |
const defaultValue = {}; | |
const ViewportContext = createContext(defaultValue); | |
enum Viewport { | |
PHONE = 'PHONE', | |
PHABLET = 'PHABLET', | |
TABLET = 'TABLET', | |
DESKTOP = 'DESKTOP', | |
WIDE = 'WIDE', | |
} | |
const getDeviceConfig = (width: number): Viewport => { | |
if (width < 400) { | |
return Viewport.PHONE; | |
} else if (width < 768) { | |
return Viewport.PHABLET; | |
} else if (width < 1024) { | |
return Viewport.TABLET; | |
} else if (width < 1536) { | |
return Viewport.DESKTOP; | |
} else { | |
return Viewport.WIDE; | |
} | |
}; | |
type ViewportProps = { | |
children: ReactNode; | |
// on the request (SSR) we detect using library 'mobile-detect', value is used for our initial state | |
reqViewport?: Viewport; | |
}; | |
const ViewportProvider: FC<ViewportProps> = ({ children, reqViewport }) => { | |
const initialViewport = reqViewport || Viewport.WIDE; | |
const [viewport, setViewport] = useState(initialViewport); | |
const setCurrentViewport = useCallback( | |
(currentViewport: Viewport) => { | |
if (viewport !== currentViewport) { | |
setViewport(currentViewport); | |
} | |
}, | |
[viewport], | |
); | |
useEffect(() => { | |
// initial state | |
const initialViewportCS = getDeviceConfig(window.innerWidth); | |
setCurrentViewport(initialViewportCS); | |
const calcInnerWidth = () => { | |
const newViewport = getDeviceConfig(window.innerWidth); | |
setCurrentViewport(newViewport); | |
}; | |
// add event listener | |
window.addEventListener('resize', calcInnerWidth); | |
// remove event listener | |
return () => { | |
window.removeEventListener('resize', calcInnerWidth); | |
}; | |
// if we add viewport the whole setup of setting the event listener only once is gone | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []); | |
return ( | |
<ViewportContext.Provider value={viewport}> | |
{children} | |
</ViewportContext.Provider> | |
); | |
}; | |
function useViewport() { | |
const context = useContext(ViewportContext); | |
if (context === defaultValue) { | |
throw new Error('useViewport is not used within a ViewportProvider'); | |
} | |
return context; | |
} | |
type MockedViewportProps = { | |
children: ReactNode; | |
viewport?: Viewport; | |
}; | |
const MockedViewportProvider: FC<MockedViewportProps> = ({ children, viewport = Viewport.DESKTOP }) => { | |
return ( | |
<ViewportContext.Provider value={viewport}> | |
{children} | |
</ViewportContext.Provider> | |
); | |
} | |
// for testing purposes | |
export { MockedViewportProvider } | |
// public api | |
export { useViewport, ViewportProvider, Viewport }; | |
// TEST | |
import React, { FC } from 'react'; | |
import { render, act } from '@testing-library/react'; | |
import { renderHook } from '@testing-library/react-hooks'; | |
import matchMediaPolyfill from 'mq-polyfill'; | |
import { useViewport, MockedViewportProvider, Viewport, ViewportProvider } from './viewport'; | |
describe('Viewport', () => { | |
beforeAll(() => { | |
matchMediaPolyfill(window) | |
window.resizeTo = function resizeTo(width, height) { | |
Object.assign(this, { | |
innerWidth: width, | |
innerHeight: height, | |
outerWidth: width, | |
outerHeight: height, | |
}).dispatchEvent(new this.Event('resize')) | |
} | |
}); | |
const DummyView: FC = () => { | |
const viewport = useViewport(); | |
return ( | |
<div data-testid="view"> | |
{viewport} | |
</div> | |
); | |
} | |
it("Provider works correctly", async () => { | |
const { queryByText } = render(<ViewportProvider reqViewport={Viewport.PHONE}><DummyView/></ViewportProvider>); | |
expect(queryByText(/PHONE/)).toBeDefined(); | |
act(() => { | |
window.resizeTo(767, 1024); | |
}); | |
expect(queryByText(/PHABLET/)).toBeDefined(); | |
act(() => { | |
window.resizeTo(768, 1024); | |
}); | |
expect(queryByText(/TABLET/)).toBeDefined(); | |
act(() => { | |
window.resizeTo(1400, 1024); | |
}); | |
expect(queryByText(/DESKTOP/)).toBeDefined(); | |
act(() => { | |
window.resizeTo(1800, 1024); | |
}); | |
expect(queryByText(/WIDE/)).toBeDefined(); | |
}); | |
it("useViewport hook can not be used without the ViewportProvider", async () => { | |
const { result } = renderHook(() => useViewport()) | |
expect(result.error.message).toBe('useViewport is not used within a ViewportProvider') | |
}); | |
it("MockedProvider works correctly", async () => { | |
const { rerender, queryByText } = render(<MockedViewportProvider viewport={Viewport.PHONE}><DummyView/></MockedViewportProvider>); | |
expect(queryByText(/PHONE/)).toBeDefined(); | |
rerender(<MockedViewportProvider viewport={Viewport.DESKTOP}><DummyView/></MockedViewportProvider>) | |
expect(queryByText(/DESKTOP/)).toBeDefined(); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment