Skip to content

Commit

Permalink
feat: refactor /src folder
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich authored Jul 27, 2019
2 parents 32f5052 + a59a07b commit 8f72ba2
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 77 deletions.
152 changes: 148 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

`useMedia` React sensor hook that tracks state of a CSS media query.


## Usage

With `useEffect`

```jsx
import {useMedia} from 'use-media';
import useMedia from 'use-media';
// Alternatively, you can import as:
// import {useMedia} from 'use-media';

const Demo = () => {
// Accepts an object of features to test
const isWide = useMedia({ minWidth: 1000 });
const isWide = useMedia({minWidth: 1000});
// Or a regular media query string
const reduceMotion = useMedia('(prefers-reduced-motion: reduce)');

Expand All @@ -31,7 +32,7 @@ import {useMediaLayout} from 'use-media';

const Demo = () => {
// Accepts an object of features to test
const isWide = useMediaLayout({ minWidth: 1000 });
const isWide = useMediaLayout({minWidth: 1000});
// Or a regular media query string
const reduceMotion = useMediaLayout('(prefers-reduced-motion: reduce)');

Expand All @@ -42,3 +43,146 @@ const Demo = () => {
);
};
```

## Testing

Depending on your testing setup, you may need to mock `window.matchMedia` on components that utilize the `useMedia` hook. Below is an example of doing this in `jest`:

**`/test-utilities/index.ts`**

```jsx
import {mockMediaQueryList} from 'use-media/lib/useMedia';
// Types are also exported for convienence:
// import {Effect, MediaQueryObject} from 'use-media/lib/types';

export interface MockMatchMedia {
media: string;
matches?: boolean;
}

function getMockImplementation({media, matches = false}: MockMatchMedia) {
const mql: MediaQueryList = {
...mockMediaQueryList,
media,
matches,
};

return () => mql;
}

export function jestMockMatchMedia({media, matches = false}: MockMatchMedia) {
const mockedImplementation = getMockImplementation({media, matches});
window.matchMedia = jest.fn().mockImplementation(mockedImplementation);
}
```

**`/components/MyComponent/MyComponent.test.tsx`**

```jsx
const mediaQueries = {
mobile: '(max-width: 767px)',
prefersReducedMotion: '(prefers-reduced-motion: reduce)',
};

describe('<MyComponent />', () => {
const defaultProps: Props = {
duration: 100,
};

afterEach(() => {
jestMockMatchMedia({
media: mediaQueries.prefersReducedMotion,
matches: false,
});
});

it('sets `duration` to `0` when user-agent `prefers-reduced-motion`', () => {
jestMockMatchMedia({
media: mediaQueries.prefersReducedMotion,
matches: true,
});

const wrapper = mount(<MyComponent {...defaultProps} />);
const child = wrapper.find(TransitionComponent);

expect(child.prop('duration')).toBe(0);
});
});
```

## Storing in Context

Depending on your app, you may be using the `useMedia` hook to register many `matchMedia` listeners across multiple components. It may help to elevate these listeners to `Context`.

**`/components/MediaQueryProvider/MediaQueryProvider.tsx`**

```jsx
import React, {createContext, useContext, useMemo} from 'react';
import useMedia from 'use-media';

interface Props {
children: React.ReactNode;
}

export const MediaQueryContext = createContext(null);

const mediaQueries = {
mobile: '(max-width: 767px)',
prefersReducedMotion: '(prefers-reduced-motion: reduce)',
};

export default function MediaQueryProvider({children}: Props) {
const mobileView = useMedia(mediaQueries.mobile);
const prefersReducedMotion = useMedia(mediaQueries.prefersReducedMotion);
const value = useMemo(() => ({mobileView, prefersReducedMotion}), [
mobileView,
prefersReducedMotion,
]);

return (
<MediaQueryContext.Provider value={value}>
{children}
</MediaQueryContext.Provider>
);
}

export function useMediaQueryContext() {
return useContext(MediaQueryContext);
}
```

**`/components/App/App.tsx`**

```jsx
import React from 'react';
import MediaQueryProvider from '../MediaQueryProvider';
import MyComponent from '../MyComponent';

export default function App() {
return (
<MediaQueryProvider>
<div id="MyApp">
<MyComponent />
</div>
</MediaQueryProvider>
);
}
```

**`/components/MyComponent/MyComponent.tsx`**

```jsx
import React from 'react';
import {useMediaQueryContext} from '../MediaQueryProvider';

export default function MyComponent() {
const {mobileView, prefersReducedMotion} = useMediaQueryContext();

return (
<div>
<p>mobileView: {Boolean(mobileView).toString()}</p>
<p>prefersReducedMotion: {Boolean(prefersReducedMotion).toString()}</p>
</div>
);
}
```
74 changes: 1 addition & 73 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1 @@
import { DependencyList, EffectCallback } from 'react';
import * as React from 'react';

const { useState, useEffect, useLayoutEffect } = React;

type MediaQueryObject = { [key: string]: string | number | boolean };

const camelToHyphen = (str: string) =>
str.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).toLowerCase();

const noWindowMatches: MediaQueryList = {
media: '',
addListener: noop,
removeListener: noop,
matches: false,
onchange: noop,
addEventListener: noop,
removeEventListener: noop,
dispatchEvent: (_: Event) => true
};

const objectToString = (query: string | MediaQueryObject) => {
if (typeof query === 'string') return query;
return Object.entries(query)
.map(([feature, value]) => {
feature = camelToHyphen(feature);
if (typeof value === 'boolean') {
return value ? feature : `not ${feature}`;
}
if (typeof value === 'number' && /[height|width]$/.test(feature)) {
value = `${value}px`;
}
return `(${feature}: ${value})`;
})
.join(' and ');
};

type Effect = (effect: EffectCallback, deps?: DependencyList) => void;
const createUseMedia = (effect: Effect) => (
rawQuery: string | MediaQueryObject,
defaultState: boolean = false
) => {
const [state, setState] = useState(defaultState);
const query = objectToString(rawQuery);
effect(() => {
let mounted = true;
const mql =
typeof window === 'undefined'
? noWindowMatches
: window.matchMedia(query);
const onChange = () => {
if (!mounted) return;
setState(!!mql.matches);
};

mql.addListener(onChange);
setState(mql.matches);

return () => {
mounted = false;
mql.removeListener(onChange);
};
}, [query]);

return state;
};

function noop() {}

export const useMedia = createUseMedia(useEffect);
export const useMediaLayout = createUseMedia(useLayoutEffect);

export default useMedia;
export {default, useMedia, useMediaLayout} from './useMedia';
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {DependencyList, EffectCallback} from 'react';

export type Effect = (effect: EffectCallback, deps?: DependencyList) => void;
export type MediaQueryObject = {[key: string]: string | number | boolean};
53 changes: 53 additions & 0 deletions src/useMedia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {useState, useEffect, useLayoutEffect} from 'react';
import {queryObjectToString, noop} from './utilities';
import {Effect, MediaQueryObject} from './types';

export const mockMediaQueryList: MediaQueryList = {
media: '',
matches: false,
onchange: noop,
addListener: noop,
removeListener: noop,
addEventListener: noop,
removeEventListener: noop,
dispatchEvent: (_: Event) => true,
};

const createUseMedia = (effect: Effect) => (
rawQuery: string | MediaQueryObject,
defaultState = false,
) => {
const [state, setState] = useState(defaultState);
const query = queryObjectToString(rawQuery);

effect(() => {
let mounted = true;
const mediaQueryList: MediaQueryList =
typeof window === 'undefined'
? mockMediaQueryList
: window.matchMedia(query);

const onChange = () => {
if (!mounted) {
return;
}

setState(Boolean(mediaQueryList.matches));
};

mediaQueryList.addListener(onChange);
setState(mediaQueryList.matches);

return () => {
mounted = false;
mediaQueryList.removeListener(onChange);
};
}, [query]);

return state;
};

export const useMedia = createUseMedia(useEffect);
export const useMediaLayout = createUseMedia(useLayoutEffect);

export default useMedia;
5 changes: 5 additions & 0 deletions src/utilities/camelToHyphen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function camelToHyphen(camelString: string) {
return camelString
.replace(/[A-Z]/g, (string) => `-${string.toLowerCase()}`)
.toLowerCase();
}
3 changes: 3 additions & 0 deletions src/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {default as camelToHyphen} from './camelToHyphen';
export {default as queryObjectToString} from './queryObjectToString';
export {default as noop} from './noop';
1 change: 1 addition & 0 deletions src/utilities/noop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function noop() {}
30 changes: 30 additions & 0 deletions src/utilities/queryObjectToString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {MediaQueryObject} from '../types';
import camelToHyphen from './camelToHyphen';

const QUERY_COMBINATOR = ' and ';

export default function queryObjectToString(query: string | MediaQueryObject) {
if (typeof query === 'string') {
return query;
}

return Object.entries(query)
.map(([feature, value]) => {
const convertedFeature = camelToHyphen(feature);
let convertedValue = value;

if (typeof convertedValue === 'boolean') {
return convertedValue ? convertedFeature : `not ${convertedFeature}`;
}

if (
typeof convertedValue === 'number' &&
/[height|width]$/.test(convertedFeature)
) {
convertedValue = `${convertedValue}px`;
}

return `(${convertedFeature}: ${convertedValue})`;
})
.join(QUERY_COMBINATOR);
}

0 comments on commit 8f72ba2

Please sign in to comment.