initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,6 @@
export * from './use-container-queries';
export * from './use-local-storage-state';
export * from './use-position-relative-to-viewport';
export * from './use-previous';
export * from './use-shallow-equal';
export * from './use-throw-error';

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { render, screen, act } from '@testing-library/react';
/**
* Internal dependencies
*/
import { usePositionRelativeToViewport } from '../use-position-relative-to-viewport';
describe( 'usePositionRelativeToViewport', () => {
function setup() {
const TestComponent = () => {
const [
referenceElement,
positionRelativeToViewport,
] = usePositionRelativeToViewport();
return (
<>
{ referenceElement }
{ positionRelativeToViewport === 'below' && (
<p data-testid="below"></p>
) }
{ positionRelativeToViewport === 'visible' && (
<p data-testid="visible"></p>
) }
{ positionRelativeToViewport === 'above' && (
<p data-testid="above"></p>
) }
</>
);
};
return render( <TestComponent /> );
}
it( "calls IntersectionObserver's `observe` and `unobserve` events", async () => {
const observe = jest.fn();
const unobserve = jest.fn();
// @ts-ignore
IntersectionObserver = jest.fn( () => ( {
observe,
unobserve,
} ) );
const { unmount } = setup();
expect( observe ).toHaveBeenCalled();
unmount();
expect( unobserve ).toHaveBeenCalled();
} );
it.each`
position | isIntersecting | top
${ 'visible' } | ${ true } | ${ 0 }
${ 'below' } | ${ false } | ${ 10 }
${ 'above' } | ${ false } | ${ 0 }
${ 'above' } | ${ false } | ${ -10 }
`(
"position relative to viewport is '$position' with isIntersecting=$isIntersecting and top=$top",
( { position, isIntersecting, top } ) => {
let intersectionObserverCallback = ( entries ) => entries;
// @ts-ignore
IntersectionObserver = jest.fn( ( callback ) => {
// @ts-ignore
intersectionObserverCallback = callback;
return {
observe: () => void null,
unobserve: () => void null,
};
} );
setup();
act( () => {
intersectionObserverCallback( [
{ isIntersecting, boundingClientRect: { top } },
] );
} );
expect( screen.getAllByTestId( position ) ).toHaveLength( 1 );
}
);
} );

View File

@ -0,0 +1,89 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
/**
* Internal dependencies
*/
import { usePrevious } from '../use-previous';
describe( 'usePrevious', () => {
const TestComponent = ( { testValue, validation } ) => {
const previousValue = usePrevious( testValue, validation );
return <div testValue={ testValue } previousValue={ previousValue } />;
};
let renderer;
beforeEach( () => ( renderer = null ) );
it( 'should be undefined at first pass', () => {
act( () => {
renderer = TestRenderer.create( <TestComponent testValue={ 1 } /> );
} );
const testValue = renderer.root.findByType( 'div' ).props.testValue;
const testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 1 );
expect( testPreviousValue ).toBe( undefined );
} );
it( 'test new and previous value', () => {
let testValue;
let testPreviousValue;
act( () => {
renderer = TestRenderer.create( <TestComponent testValue={ 1 } /> );
} );
act( () => {
renderer.update( <TestComponent testValue={ 2 } /> );
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 2 );
expect( testPreviousValue ).toBe( 1 );
act( () => {
renderer.update( <TestComponent testValue={ 3 } /> );
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 3 );
expect( testPreviousValue ).toBe( 2 );
} );
it( 'should not update value if validation fails', () => {
let testValue;
let testPreviousValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ 1 } validation={ Number.isFinite } />
);
} );
act( () => {
renderer.update(
<TestComponent testValue="abc" validation={ Number.isFinite } />
);
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 'abc' );
expect( testPreviousValue ).toBe( 1 );
act( () => {
renderer.update(
<TestComponent testValue={ 3 } validation={ Number.isFinite } />
);
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 3 );
expect( testPreviousValue ).toBe( 1 );
} );
} );

View File

@ -0,0 +1,72 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
/**
* Internal dependencies
*/
import { useShallowEqual } from '../use-shallow-equal';
describe( 'useShallowEqual', () => {
const TestComponent = ( { testValue } ) => {
const newValue = useShallowEqual( testValue );
return <div newValue={ newValue } />;
};
let renderer;
beforeEach( () => ( renderer = null ) );
it.each`
testValueA | aType | testValueB | bType
${ { a: 'b', foo: 'bar' } } | ${ 'object' } | ${ { foo: 'bar', a: 'b' } } | ${ 'object' }
${ [ 'b', 'bar' ] } | ${ 'array' } | ${ [ 'b', 'bar' ] } | ${ 'array' }
${ 1 } | ${ 'number' } | ${ 1 } | ${ 'number' }
${ '1' } | ${ 'string' } | ${ '1' } | ${ 'string' }
${ true } | ${ 'bool' } | ${ true } | ${ 'bool' }
`(
'$testValueA ($aType) and $testValueB ($bType) are expected to be equal',
( { testValueA, testValueB } ) => {
let testPropValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ testValueA } />
);
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
expect( testPropValue ).toBe( testValueA );
// do update
act( () => {
renderer.update( <TestComponent testValue={ testValueB } /> );
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
expect( testPropValue ).toBe( testValueA );
}
);
it.each`
testValueA | aType | testValueB | bType
${ { a: 'b', foo: 'bar' } } | ${ 'object' } | ${ { foo: 'bar', a: 'c' } } | ${ 'object' }
${ [ 'b', 'bar' ] } | ${ 'array' } | ${ [ 'bar', 'b' ] } | ${ 'array' }
${ 1 } | ${ 'number' } | ${ '1' } | ${ 'string' }
${ 1 } | ${ 'number' } | ${ 2 } | ${ 'number' }
${ 1 } | ${ 'number' } | ${ true } | ${ 'bool' }
${ 0 } | ${ 'number' } | ${ false } | ${ 'bool' }
`(
'$testValueA ($aType) and $testValueB ($bType) are expected to not be equal',
( { testValueA, testValueB } ) => {
let testPropValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ testValueA } />
);
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
expect( testPropValue ).toBe( testValueA );
// do update
act( () => {
renderer.update( <TestComponent testValue={ testValueB } /> );
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
expect( testPropValue ).toBe( testValueB );
}
);
} );

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { useResizeObserver } from '@wordpress/compose';
/**
* Returns a resizeListener element and a class name based on its width.
* Class names are based on the smaller of the breakpoints:
* https://github.com/WordPress/gutenberg/tree/master/packages/viewport#usage
* Values are also based on those breakpoints minus ~80px which is approximately
* the left + right margin in Storefront with a font-size of 16px.
* _Note: `useContainerQueries` will return an empty class name `` until after
* first render_
*
* @return {Array} An array of {Element} `resizeListener` and {string} `className`.
*
* @example
*
* ```js
* const App = () => {
* const [ resizeListener, containerClassName ] = useContainerQueries();
*
* return (
* <div className={ containerClassName }>
* { resizeListener }
* Your content here
* </div>
* );
* };
* ```
*/
export const useContainerQueries = (): [ React.ReactElement, string ] => {
const [ resizeListener, { width } ] = useResizeObserver();
let className = '';
if ( width > 700 ) {
className = 'is-large';
} else if ( width > 520 ) {
className = 'is-medium';
} else if ( width > 400 ) {
className = 'is-small';
} else if ( width ) {
className = 'is-mobile';
}
return [ resizeListener, className ];
};

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
export const useLocalStorageState = < T >(
key: string,
initialValue: T
): [ T, ( arg0: T ) => void ] => {
const [ state, setState ] = useState< T >( () => {
const valueInLocalStorage = window.localStorage.getItem( key );
if ( valueInLocalStorage ) {
try {
return JSON.parse( valueInLocalStorage );
} catch {
// eslint-disable-next-line no-console
console.error(
`Value for key '${ key }' could not be retrieved from localStorage because it can't be parsed.`
);
}
}
return initialValue;
} );
useEffect( () => {
try {
window.localStorage.setItem( key, JSON.stringify( state ) );
} catch {
// eslint-disable-next-line no-console
console.error(
`Value for key '${ key }' could not be saved in localStorage because it can't be converted into a string.`
);
}
}, [ key, state ] );
return [ state, setState ];
};

View File

@ -0,0 +1,86 @@
/**
* External dependencies
*/
import { useRef, useLayoutEffect, useState } from '@wordpress/element';
/** @typedef {import('react')} React */
/** @type {React.CSSProperties} */
const style = {
bottom: 0,
left: 0,
opacity: 0,
pointerEvents: 'none',
position: 'absolute',
right: 0,
top: 0,
zIndex: -1,
};
/**
* Returns an element and a string (`above`, `visible` or `below`) based on the
* element position relative to the viewport.
* _Note: `usePositionRelativeToViewport` will return an empty position (``)
* until after first render_
*
* @return {Array} An array of {Element} `referenceElement` and {string} `positionRelativeToViewport`.
*
* @example
*
* ```js
* const App = () => {
* const [ referenceElement, positionRelativeToViewport ] = useContainerQueries();
*
* return (
* <>
* { referenceElement }
* { positionRelativeToViewport === 'below' && <p>Reference element is below the viewport.</p> }
* { positionRelativeToViewport === 'visible' && <p>Reference element is visible in the viewport.</p> }
* { positionRelativeToViewport === 'above' && <p>Reference element is above the viewport.</p> }
* </>
* );
* };
* ```
*/
export const usePositionRelativeToViewport = () => {
const [
positionRelativeToViewport,
setPositionRelativeToViewport,
] = useState( '' );
const referenceElementRef = useRef( null );
const intersectionObserver = useRef(
new IntersectionObserver(
( entries ) => {
if ( entries[ 0 ].isIntersecting ) {
setPositionRelativeToViewport( 'visible' );
} else {
setPositionRelativeToViewport(
entries[ 0 ].boundingClientRect.top > 0
? 'below'
: 'above'
);
}
},
{ threshold: 1.0 }
)
);
useLayoutEffect( () => {
const referenceElementNode = referenceElementRef.current;
const observer = intersectionObserver.current;
if ( referenceElementNode ) {
observer.observe( referenceElementNode );
}
return () => {
observer.unobserve( referenceElementNode );
};
}, [] );
const referenceElement = (
<div aria-hidden={ true } ref={ referenceElementRef } style={ style } />
);
return [ referenceElement, positionRelativeToViewport ];
};

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { useRef, useEffect } from 'react';
interface Validation< T > {
( value: T, previousValue: T | undefined ): boolean;
}
/**
* Use Previous based on https://usehooks.com/usePrevious/.
*
* @param {*} value
* @param {Function} [validation] Function that needs to validate for the value
* to be updated.
*/
export function usePrevious< T >(
value: T,
validation?: Validation< T >
): T | undefined {
const ref = useRef< T >();
useEffect( () => {
if (
ref.current !== value &&
( ! validation || validation( value, ref.current ) )
) {
ref.current = value;
}
}, [ value, validation ] );
return ref.current;
}

View File

@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { useRef } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* A custom hook that compares the provided value across renders and returns the
* previous instance if shallow equality with previous instance exists.
*
* This is particularly useful when non-primitive types are used as
* dependencies for react hooks.
*
* @param {*} value Value to keep the same if satisfies shallow equality.
*
* @return {*} The previous cached instance of the value if the current has shallow equality with it.
*/
export function useShallowEqual< T >( value: T ): T {
const ref = useRef< T >( value );
if ( ! isShallowEqual( value, ref.current ) ) {
ref.current = value;
}
return ref.current;
}

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { useState, useCallback } from '@wordpress/element';
/**
* Helper method for throwing an error in a React Hook.
*
* @see https://github.com/facebook/react/issues/14981
*
* @return {function(Object)} A function receiving the error that will be thrown.
*/
export const useThrowError = (): ( ( error: Error ) => void ) => {
const [ , setState ] = useState();
return useCallback( ( error: Error ): void => {
setState( () => {
throw error;
} );
}, [] );
};