Skip to content

Instantly share code, notes, and snippets.

@maapteh
Last active March 23, 2024 03:40
Show Gist options
  • Save maapteh/8a44f4e1f3d52c0fe82560ec9f38b02a to your computer and use it in GitHub Desktop.
Save maapteh/8a44f4e1f3d52c0fe82560ec9f38b02a to your computer and use it in GitHub Desktop.
useViewport hook for React
// 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