Skip to content

Commit

Permalink
feat: new hook useMediaQuery (#116)
Browse files Browse the repository at this point in the history
* feat: new hook `useMediaQuery`

* fix: hooks grouping in index.ts

* test: clean tests code
  • Loading branch information
xobotyi authored Jun 11, 2021
1 parent f7d899d commit be6fff9
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 10 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Invokes a callback whenever ResizeObserver detects a change to target's size.
- [**`useMeasure`**](https://react-hookz.github.io/?path=/docs/sensor-usemeasure)
— Uses ResizeObserver to track element dimensions and re-render component when they change.
- [**`useMediaQuery`**](https://react-hookz.github.io/?path=/docs/sensor-usemediaquery)
— Tracks the state of CSS media query.

- #### Dom

Expand Down
17 changes: 9 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export { useMediatedState } from './useMediatedState/useMediatedState';
export { usePrevious } from './usePrevious/usePrevious';
export { useSafeState } from './useSafeState/useSafeState';
export { useToggle } from './useToggle/useToggle';
export {
useValidator,
IValidatorImmediate,
IValidatorDeferred,
IValidator,
IValidityState,
} from './useValidator/useValidator';

// Navigator
export { useNetworkState } from './useNetworkState/useNetworkState';
Expand All @@ -42,13 +49,7 @@ export {
} from './useResizeObserver/useResizeObserver';
export { useMeasure } from './useMeasure/useMeasure';

export { useMediaQuery } from './useMediaQuery/useMediaQuery';

// Dom
export { useDocumentTitle, IUseDocumentTitleOptions } from './useDocumentTitle/useDocumentTitle';

export {
useValidator,
IValidatorImmediate,
IValidatorDeferred,
IValidator,
IValidityState,
} from './useValidator/useValidator';
33 changes: 33 additions & 0 deletions src/useMediaQuery/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as React from 'react';
import { useMediaQuery } from '../..';

export const Example: React.FC = () => {
const isSmallDevise = useMediaQuery('only screen and (max-width : 768px)');
const isMediumDevice = useMediaQuery(
'only screen and (min-width : 769px) and (max-width : 992px)'
);
const isLargeDevice = useMediaQuery(
'only screen and (min-width : 993px) and (max-width : 1200px)'
);
const isExtraLargeDevice = useMediaQuery('only screen and (min-width : 1201px)');

return (
<div>
Resize your browser windows to see changes.
<br />
<br />
<div>
Small device (<code>max-width : 768px</code>): {isSmallDevise ? 'yes' : 'no'}
</div>
<div>
Medium device (<code>max-width : 992px</code>): {isMediumDevice ? 'yes' : 'no'}
</div>
<div>
Large device (<code>max-width : 1200px</code>): {isLargeDevice ? 'yes' : 'no'}
</div>
<div>
Extra large device (<code>min-width : 1201px</code>): {isExtraLargeDevice ? 'yes' : 'no'}
</div>
</div>
);
};
33 changes: 33 additions & 0 deletions src/useMediaQuery/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="Sensor/useMediaQuery" component={Example} />

# useMediaQuery

Tracks the state of CSS media query.

- Tracks the changes in media query.
- Automatically updates on media query status change.
- Performant, uses only single `matchMedia` entry to update all hooks using same key.
- SSR-friendly.

#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useMediaQuery(query: string): boolean | undefined;
```

#### Arguments

- **query** _`string`_ - CSS media query to track.

#### Return

`boolean` - `true` in case document matches media query, `false` otherwise.
137 changes: 137 additions & 0 deletions src/useMediaQuery/__tests__/dom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { useMediaQuery } from '../..';

describe('useMediaQuery', () => {
type IMutableMediaQueryList = {
matches: boolean;
media: string;
onchange: null;
addListener: jest.Mock; // Deprecated
removeListener: jest.Mock; // Deprecated
addEventListener: jest.Mock;
removeEventListener: jest.Mock;
dispatchEvent: jest.Mock;
};

const matchMediaMock = jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
let initialMatchMedia: typeof window.matchMedia;

beforeAll(() => {
initialMatchMedia = window.matchMedia;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: matchMediaMock,
});
});

afterAll(() => {
window.matchMedia = initialMatchMedia;
});

afterEach(() => {
matchMediaMock.mockClear();
});

it('should be defined', () => {
expect(useMediaQuery).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result.error).toBeUndefined();
});

it('should return undefined on first render', () => {
const { result } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result.all[0]).toBeUndefined();
});

it('should return match state', () => {
const { result } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result.current).toBe(false);
});

it('should update state if query state changed', () => {
const { result } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result.current).toBe(false);

const mql = matchMediaMock.mock.results[0].value as IMutableMediaQueryList;
mql.matches = true;

act(() => {
mql.addEventListener.mock.calls[0][1]();
});
expect(result.current).toBe(true);
});

it('several hooks tracking same rule must listen same mql', () => {
const { result: result1 } = renderHook(() => useMediaQuery('max-width : 768px'));
const { result: result2 } = renderHook(() => useMediaQuery('max-width : 768px'));
const { result: result3 } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result1.current).toBe(false);
expect(result2.current).toBe(false);
expect(result3.current).toBe(false);

const mql = matchMediaMock.mock.results[0].value as IMutableMediaQueryList;
mql.matches = true;

act(() => {
mql.addEventListener.mock.calls[0][1]();
});
expect(result1.current).toBe(true);
expect(result2.current).toBe(true);
expect(result3.current).toBe(true);
});

it('should unsubscribe from previous mql when query changed', () => {
const { result: result1 } = renderHook(() => useMediaQuery('max-width : 768px'));
const { result: result2 } = renderHook(() => useMediaQuery('max-width : 768px'));
const { result: result3, rerender: rerender3 } = renderHook(
({ query }) => useMediaQuery(query),
{
initialProps: { query: 'max-width : 768px' },
}
);
expect(result1.current).toBe(false);
expect(result2.current).toBe(false);
expect(result3.current).toBe(false);

rerender3({ query: 'max-width : 760px' });

expect(matchMediaMock).toBeCalledTimes(2);

const mql = matchMediaMock.mock.results[0].value as IMutableMediaQueryList;
mql.matches = true;

act(() => {
mql.addEventListener.mock.calls[0][1]();
});
expect(result1.current).toBe(true);
expect(result2.current).toBe(true);
expect(result3.current).toBe(false);
});

it('should unsubscribe from previous mql when query changed', () => {
const { unmount: unmount1 } = renderHook(() => useMediaQuery('max-width : 768px'));
const { unmount: unmount2 } = renderHook(() => useMediaQuery('max-width : 768px'));
const { unmount: unmount3 } = renderHook(() => useMediaQuery('max-width : 768px'));

const mql = matchMediaMock.mock.results[0].value as IMutableMediaQueryList;
expect(mql.removeEventListener).not.toHaveBeenCalled();
unmount3();
expect(mql.removeEventListener).not.toHaveBeenCalled();
unmount2();
expect(mql.removeEventListener).not.toHaveBeenCalled();
unmount1();
expect(mql.removeEventListener).toHaveBeenCalledTimes(1);
});
});
18 changes: 18 additions & 0 deletions src/useMediaQuery/__tests__/ssr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useMediaQuery } from '../..';

describe('useMediaQuery', () => {
it('should be defined', () => {
expect(useMediaQuery).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result.error).toBeUndefined();
});

it('should return undefined on first render', () => {
const { result } = renderHook(() => useMediaQuery('max-width : 768px'));
expect(result.current).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions src/useMediaQuery/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Dispatch, useEffect } from 'react';
import { useSafeState } from '../useSafeState/useSafeState';

const queriesMap = new Map<
string,
{ mql: MediaQueryList; dispatchers: Set<Dispatch<boolean>>; listener: () => void }
>();

const querySubscribe = (query: string, dispatch: Dispatch<boolean>) => {
let entry = queriesMap.get(query);

if (!entry) {
const mql = matchMedia(query);
const dispatchers = new Set<Dispatch<boolean>>();
const listener = () => {
dispatchers.forEach((d) => {
d(mql.matches);
});
};

mql.addEventListener('change', listener, { passive: true });

entry = {
mql,
dispatchers,
listener,
};
queriesMap.set(query, entry);
}

entry.dispatchers.add(dispatch);
dispatch(entry.mql.matches);
};

const queryUnsubscribe = (query: string, dispatch: Dispatch<boolean>): void => {
const entry = queriesMap.get(query);

// else path is impossible to test in normal situation
/* istanbul ignore else */
if (entry) {
const { mql, dispatchers, listener } = entry;
dispatchers.delete(dispatch);

if (!dispatchers.size) {
queriesMap.delete(query);
mql.removeEventListener('change', listener);
}
}
};

/**
* Tracks the state of CSS media query.
*
* @param query CSS media query to track.
*/
export function useMediaQuery(query: string): boolean | undefined {
const [state, setState] = useSafeState<boolean>();

useEffect(() => {
querySubscribe(query, setState);

return () => {
queryUnsubscribe(query, setState);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);

return state;
}
4 changes: 2 additions & 2 deletions src/useResizeObserver/__docs__/story.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ Invokes a callback whenever ResizeObserver detects a change to target's size.
most recent data.
<Story name="Example" story={Example} />
As `useResizeObserver` does not apply any debounce or throttle mechanisms to received callback -
it is up to developer to do so if needed passed callback. Below example is almost same as previous
but state is updated within 500ms debounce.
it is up to developer to do so if needed. Below example is almost same as previous but state is
updated within 500ms debounce.
<Story name="ExampleDebounced" story={ExampleDebounced} />
</Canvas>

Expand Down

0 comments on commit be6fff9

Please sign in to comment.