fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { basePipe } from './pipe';
/**
* Composes multiple higher-order components into a single higher-order component. Performs right-to-left function
* composition, where each successive invocation is supplied the return value of the previous.
*
* This is inspired by `lodash`'s `flowRight` function.
*
* @see https://docs-lodash.com/v4/flow-right/
*/
const compose = basePipe( true );
export default compose;

View File

@@ -0,0 +1,19 @@
# If Condition
`ifCondition` is a higher-order component creator, used for creating a new component which renders if the given condition is satisfied.
## Usage
`ifCondition`, passed with a predicate function, will render the underlying component only if the predicate returns a truthy value. The predicate is passed the component's own original props as an argument.
```jsx
function MyEvenNumber( { number } ) {
// This is only reached if the `number` prop is even. Otherwise, nothing
// will be rendered.
return <strong>{ number }</strong>;
}
MyEvenNumber = ifCondition( ( { number } ) => number % 2 === 0 )(
MyEvenNumber
);
```

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import type { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
/**
* Higher-order component creator, creating a new component which renders if
* the given condition is satisfied or with the given optional prop name.
*
* @example
* ```ts
* type Props = { foo: string };
* const Component = ( props: Props ) => <div>{ props.foo }</div>;
* const ConditionalComponent = ifCondition( ( props: Props ) => props.foo.length !== 0 )( Component );
* <ConditionalComponent foo="" />; // => null
* <ConditionalComponent foo="bar" />; // => <div>bar</div>;
* ```
*
* @param predicate Function to test condition.
*
* @return Higher-order component.
*/
function ifCondition< Props extends {} >(
predicate: ( props: Props ) => boolean
) {
return createHigherOrderComponent(
( WrappedComponent: ComponentType< Props > ) => ( props: Props ) => {
if ( ! predicate( props ) ) {
return null;
}
return <WrappedComponent { ...props } />;
},
'ifCondition'
);
}
export default ifCondition;

View File

@@ -0,0 +1,76 @@
/**
* Parts of this source were derived and modified from lodash,
* released under the MIT license.
*
* https://github.com/lodash/lodash
*
* Copyright JS Foundation and other contributors <https://js.foundation/>
*
* Based on Underscore.js, copyright Jeremy Ashkenas,
* DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
*
* This software consists of voluntary contributions made by many
* individuals. For exact contribution history, see the revision history
* available at https://github.com/lodash/lodash
*
* The following license applies to all parts of this software except as
* documented below:
*
* ====
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* Creates a pipe function.
*
* Allows to choose whether to perform left-to-right or right-to-left composition.
*
* @see https://docs-lodash.com/v4/flow/
*
* @param {boolean} reverse True if right-to-left, false for left-to-right composition.
*/
const basePipe =
( reverse: boolean = false ) =>
( ...funcs: Function[] ) =>
( ...args: unknown[] ) => {
const functions = funcs.flat();
if ( reverse ) {
functions.reverse();
}
return functions.reduce(
( prev, func ) => [ func( ...prev ) ],
args
)[ 0 ];
};
/**
* Composes multiple higher-order components into a single higher-order component. Performs left-to-right function
* composition, where each successive invocation is supplied the return value of the previous.
*
* This is inspired by `lodash`'s `flow` function.
*
* @see https://docs-lodash.com/v4/flow/
*/
const pipe = basePipe();
export { basePipe };
export default pipe;

View File

@@ -0,0 +1,48 @@
/**
* External dependencies
*/
import type { ComponentType, ComponentClass } from 'react';
/**
* WordPress dependencies
*/
import isShallowEqual from '@wordpress/is-shallow-equal';
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
/**
* Given a component returns the enhanced component augmented with a component
* only re-rendering when its props/state change
*
* @deprecated Use `memo` or `PureComponent` instead.
*/
const pure = createHigherOrderComponent( function < Props extends {} >(
WrappedComponent: ComponentType< Props >
): ComponentType< Props > {
if ( WrappedComponent.prototype instanceof Component ) {
return class extends ( WrappedComponent as ComponentClass< Props > ) {
shouldComponentUpdate( nextProps: Props, nextState: any ) {
return (
! isShallowEqual( nextProps, this.props ) ||
! isShallowEqual( nextState, this.state )
);
}
};
}
return class extends Component< Props > {
shouldComponentUpdate( nextProps: Props ) {
return ! isShallowEqual( nextProps, this.props );
}
render() {
return <WrappedComponent { ...this.props } />;
}
};
}, 'pure' );
export default pure;

View File

@@ -0,0 +1,102 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import pure from '../';
describe( 'pure', () => {
it( 'functional component should rerender only when props change', () => {
let i = 0;
const MyComp = pure( () => {
return <p data-testid="counter">{ ++i }</p>;
} );
const { rerender } = render( <MyComp /> );
// Updating with same props doesn't rerender.
rerender( <MyComp /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '1' );
// New prop should trigger a rerender.
rerender( <MyComp { ...{ prop: 'a' } } /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '2' );
// Keeping the same prop value should not rerender.
rerender( <MyComp { ...{ prop: 'a' } } /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '2' );
// Changing the prop value should rerender.
rerender( <MyComp { ...{ prop: 'b' } } /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '3' );
} );
it( 'class component should rerender if the props or state change', async () => {
const user = userEvent.setup();
let i = 0;
const MyComp = pure(
class extends Component {
constructor() {
super( ...arguments );
this.state = {
val: '',
};
}
render() {
return (
<>
<p data-testid="counter">{ ++i }</p>
<input
type="text"
value={ this.state.val }
onChange={ ( e ) =>
this.setState( { val: e.target.value } )
}
/>
<input
type="button"
onClick={ () =>
this.setState( { val: this.state.val } )
}
/>
</>
);
}
}
);
const { rerender } = render( <MyComp /> );
// Updating with same props doesn't rerender.
rerender( <MyComp /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '1' );
// New prop should trigger a rerender.
rerender( <MyComp { ...{ prop: 'a' } } /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '2' );
// Keeping the same prop value should not rerender.
rerender( <MyComp { ...{ prop: 'a' } } /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '2' );
// Changing the prop value should rerender.
rerender( <MyComp { ...{ prop: 'b' } } /> );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '3' );
// New state value should trigger a rerender.
await user.type( screen.getByRole( 'textbox' ), 'a' );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '4' );
// Keeping the same state value should not trigger a rerender.
await user.click( screen.getByRole( 'button' ) );
expect( screen.getByTestId( 'counter' ) ).toHaveTextContent( '4' );
} );
} );

View File

@@ -0,0 +1,39 @@
/**
* Internal dependencies
*/
import compose from '../compose';
describe( 'compose', () => {
it( 'returns the initial value if no functions are specified', () => {
expect( compose()( 'test' ) ).toBe( 'test' );
} );
it( 'executes functions right-to-left when passed as separate arguments', () => {
const a = ( value ) => ( value += 'a' );
const b = ( value ) => ( value += 'b' );
const c = ( value ) => ( value += 'c' );
expect( compose( a, b, c )( 'test' ) ).toBe( 'testcba' );
} );
it( 'executes functions right-to-left when passed as a single array', () => {
const a = ( value ) => ( value += 'a' );
const b = ( value ) => ( value += 'b' );
const c = ( value ) => ( value += 'c' );
expect( compose( [ a, b, c ] )( 'test' ) ).toBe( 'testcba' );
} );
it( 'executes functions right-to-left when passed as a mix of separate arguments and arrays', () => {
const a = ( value ) => ( value += 'a' );
const b = ( value ) => ( value += 'b' );
const c = ( value ) => ( value += 'c' );
const d = ( value ) => ( value += 'd' );
const e = ( value ) => ( value += 'e' );
const f = ( value ) => ( value += 'f' );
expect( compose( [ a, b ], c, [ d ], e )( 'test' ) ).toBe(
'testedcba'
);
} );
} );

View File

@@ -0,0 +1,37 @@
/**
* Internal dependencies
*/
import pipe from '../pipe';
describe( 'pipe', () => {
it( 'returns the initial value if no functions are specified', () => {
expect( pipe()( 'test' ) ).toBe( 'test' );
} );
it( 'executes functions left-to-right when passed as separate arguments', () => {
const a = ( value ) => ( value += 'a' );
const b = ( value ) => ( value += 'b' );
const c = ( value ) => ( value += 'c' );
expect( pipe( a, b, c )( 'test' ) ).toBe( 'testabc' );
} );
it( 'executes functions left-to-right when passed as a single array', () => {
const a = ( value ) => ( value += 'a' );
const b = ( value ) => ( value += 'b' );
const c = ( value ) => ( value += 'c' );
expect( pipe( [ a, b, c ] )( 'test' ) ).toBe( 'testabc' );
} );
it( 'executes functions left-to-right when passed as a mix of separate arguments and arrays', () => {
const a = ( value ) => ( value += 'a' );
const b = ( value ) => ( value += 'b' );
const c = ( value ) => ( value += 'c' );
const d = ( value ) => ( value += 'd' );
const e = ( value ) => ( value += 'e' );
const f = ( value ) => ( value += 'f' );
expect( pipe( [ a, b ], c, [ d ], e )( 'test' ) ).toBe( 'testabcde' );
} );
} );

View File

@@ -0,0 +1,32 @@
# withGlobalEvents
**Deprecated**
`withGlobalEvents` is a higher-order component used to facilitate responding to global events, where one would otherwise use `window.addEventListener`.
On behalf of the consuming developer, the higher-order component manages:
- Unbinding when the component unmounts.
- Binding at most a single event handler for the entire application.
## Usage
Pass an object where keys correspond to the DOM event type, the value the name of the method on the original component's instance which handles the event.
```js
import { withGlobalEvents } from '@wordpress/components';
class ResizingComponent extends Component {
handleResize() {
// ...
}
render() {
// ...
}
}
export default withGlobalEvents( {
resize: 'handleResize',
} )( ResizingComponent );
```

View File

@@ -0,0 +1,103 @@
/**
* WordPress dependencies
*/
import { Component, forwardRef } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
import Listener from './listener';
/**
* Listener instance responsible for managing document event handling.
*/
const listener = new Listener();
/* eslint-disable jsdoc/no-undefined-types */
/**
* Higher-order component creator which, given an object of DOM event types and
* values corresponding to a callback function name on the component, will
* create or update a window event handler to invoke the callback when an event
* occurs. On behalf of the consuming developer, the higher-order component
* manages unbinding when the component unmounts, and binding at most a single
* event handler for the entire application.
*
* @deprecated
*
* @param {Record<keyof GlobalEventHandlersEventMap, string>} eventTypesToHandlers Object with keys of DOM
* event type, the value a
* name of the function on
* the original component's
* instance which handles
* the event.
*
* @return {any} Higher-order component.
*/
export default function withGlobalEvents( eventTypesToHandlers ) {
deprecated( 'wp.compose.withGlobalEvents', {
since: '5.7',
alternative: 'useEffect',
} );
// @ts-ignore We don't need to fix the type-related issues because this is deprecated.
return createHigherOrderComponent( ( WrappedComponent ) => {
class Wrapper extends Component {
constructor( /** @type {any} */ props ) {
super( props );
this.handleEvent = this.handleEvent.bind( this );
this.handleRef = this.handleRef.bind( this );
}
componentDidMount() {
Object.keys( eventTypesToHandlers ).forEach( ( eventType ) => {
listener.add( eventType, this );
} );
}
componentWillUnmount() {
Object.keys( eventTypesToHandlers ).forEach( ( eventType ) => {
listener.remove( eventType, this );
} );
}
handleEvent( /** @type {any} */ event ) {
const handler =
eventTypesToHandlers[
/** @type {keyof GlobalEventHandlersEventMap} */ (
event.type
)
/* eslint-enable jsdoc/no-undefined-types */
];
if ( typeof this.wrappedRef[ handler ] === 'function' ) {
this.wrappedRef[ handler ]( event );
}
}
handleRef( /** @type {any} */ el ) {
this.wrappedRef = el;
// Any component using `withGlobalEvents` that is not setting a `ref`
// will cause `this.props.forwardedRef` to be `null`, so we need this
// check.
if ( this.props.forwardedRef ) {
this.props.forwardedRef( el );
}
}
render() {
return (
<WrappedComponent
{ ...this.props.ownProps }
ref={ this.handleRef }
/>
);
}
}
return forwardRef( ( props, ref ) => {
return <Wrapper ownProps={ props } forwardedRef={ ref } />;
} );
}, 'withGlobalEvents' );
}

View File

@@ -0,0 +1,49 @@
/**
* Class responsible for orchestrating event handling on the global window,
* binding a single event to be shared across all handling instances, and
* removing the handler when no instances are listening for the event.
*/
class Listener {
constructor() {
/** @type {any} */
this.listeners = {};
this.handleEvent = this.handleEvent.bind( this );
}
add( /** @type {any} */ eventType, /** @type {any} */ instance ) {
if ( ! this.listeners[ eventType ] ) {
// Adding first listener for this type, so bind event.
window.addEventListener( eventType, this.handleEvent );
this.listeners[ eventType ] = [];
}
this.listeners[ eventType ].push( instance );
}
remove( /** @type {any} */ eventType, /** @type {any} */ instance ) {
if ( ! this.listeners[ eventType ] ) {
return;
}
this.listeners[ eventType ] = this.listeners[ eventType ].filter(
( /** @type {any} */ listener ) => listener !== instance
);
if ( ! this.listeners[ eventType ].length ) {
// Removing last listener for this type, so unbind event.
window.removeEventListener( eventType, this.handleEvent );
delete this.listeners[ eventType ];
}
}
handleEvent( /** @type {any} */ event ) {
this.listeners[ event.type ]?.forEach(
( /** @type {any} */ instance ) => {
instance.handleEvent( event );
}
);
}
}
export default Listener;

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import withGlobalEvents from '../';
import Listener from '../listener';
jest.mock( '../listener', () => {
const ActualListener = jest.requireActual( '../listener' ).default;
return class extends ActualListener {
constructor() {
super( ...arguments );
this.constructor._instance = this;
jest.spyOn( this, 'add' );
jest.spyOn( this, 'remove' );
}
};
} );
describe( 'withGlobalEvents', () => {
class OriginalComponent extends Component {
handleResize( event ) {
this.props.onResize( event );
}
render() {
const { children } = this.props;
return <div>{ children }</div>;
}
}
beforeAll( () => {
jest.spyOn( OriginalComponent.prototype, 'handleResize' );
} );
beforeEach( () => {
jest.clearAllMocks();
} );
it( 'renders with original component', () => {
const EnhancedComponent = withGlobalEvents( {
resize: 'handleResize',
} )( OriginalComponent );
render( <EnhancedComponent ref={ () => {} }>Hello</EnhancedComponent> );
expect( console ).toHaveWarned();
expect( screen.getByText( 'Hello' ) ).toBeVisible();
} );
it( 'binds events from passed object', () => {
const EnhancedComponent = withGlobalEvents( {
resize: 'handleResize',
} )( OriginalComponent );
render( <EnhancedComponent ref={ () => {} }>Hello</EnhancedComponent> );
expect( Listener._instance.add ).toHaveBeenCalledWith(
'resize',
// If not `undefined`, then we consider handlers were properly bound to the wrapper component.
expect.any( Object )
);
} );
it( 'handles events', () => {
const EnhancedComponent = withGlobalEvents( {
resize: 'handleResize',
} )( OriginalComponent );
const onResize = jest.fn();
render(
<EnhancedComponent ref={ () => {} } onResize={ onResize }>
Hello
</EnhancedComponent>
);
const event = { type: 'resize' };
Listener._instance.handleEvent( event );
expect( OriginalComponent.prototype.handleResize ).toHaveBeenCalledWith(
event
);
expect( onResize ).toHaveBeenCalledWith( event );
} );
} );

View File

@@ -0,0 +1,93 @@
/**
* Internal dependencies
*/
import Listener from '../listener';
describe( 'Listener', () => {
const createHandler = () => ( { handleEvent: jest.fn() } );
let listener, _addEventListener, _removeEventListener;
beforeAll( () => {
_addEventListener = global.window.addEventListener;
_removeEventListener = global.window.removeEventListener;
global.window.addEventListener = jest.fn();
global.window.removeEventListener = jest.fn();
} );
beforeEach( () => {
listener = new Listener();
jest.clearAllMocks();
} );
afterAll( () => {
global.window.addEventListener = _addEventListener;
global.window.removeEventListener = _removeEventListener;
} );
describe( '#add()', () => {
it( 'adds an event listener on first listener', () => {
listener.add( 'resize', createHandler() );
expect( window.addEventListener ).toHaveBeenCalledWith(
'resize',
expect.any( Function )
);
} );
it( 'does not add event listener on subsequent listeners', () => {
listener.add( 'resize', createHandler() );
listener.add( 'resize', createHandler() );
expect( window.addEventListener ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( '#remove()', () => {
it( 'removes an event listener on last listener', () => {
const handler = createHandler();
listener.add( 'resize', handler );
listener.remove( 'resize', handler );
expect( window.removeEventListener ).toHaveBeenCalledWith(
'resize',
expect.any( Function )
);
} );
it( 'does not remove event listener on remaining listeners', () => {
const firstHandler = createHandler();
const secondHandler = createHandler();
listener.add( 'resize', firstHandler );
listener.add( 'resize', secondHandler );
listener.remove( 'resize', firstHandler );
expect( window.removeEventListener ).not.toHaveBeenCalled();
} );
} );
describe( '#handleEvent()', () => {
it( 'calls concerned listeners', () => {
const handler = createHandler();
listener.add( 'resize', handler );
const event = { type: 'resize' };
listener.handleEvent( event );
expect( handler.handleEvent ).toHaveBeenCalledWith( event );
} );
it( 'calls all added handlers', () => {
const handler = createHandler();
listener.add( 'resize', handler );
listener.add( 'resize', handler );
listener.add( 'resize', handler );
const event = { type: 'resize' };
listener.handleEvent( event );
expect( handler.handleEvent ).toHaveBeenCalledTimes( 3 );
} );
} );
} );

View File

@@ -0,0 +1,19 @@
# withInstanceId
Some components need to generate a unique id for each instance. This could serve as suffixes to element ID's for example.
Wrapping a component with `withInstanceId` provides a unique `instanceId` to serve this purpose.
## Usage
```jsx
/**
* WordPress dependencies
*/
import { withInstanceId } from '@wordpress/compose';
function MyCustomElement( { instanceId } ) {
return <div id={ `my-custom-element-${ instanceId }` }>content</div>;
}
export default withInstanceId( MyCustomElement );
```

View File

@@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import type {
WithInjectedProps,
WithoutInjectedProps,
} from '../../utils/create-higher-order-component';
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
import useInstanceId from '../../hooks/use-instance-id';
type InstanceIdProps = { instanceId: string | number };
/**
* A Higher Order Component used to be provide a unique instance ID by
* component.
*/
const withInstanceId = createHigherOrderComponent(
< C extends WithInjectedProps< C, InstanceIdProps > >(
WrappedComponent: C
) => {
return ( props: WithoutInjectedProps< C, InstanceIdProps > ) => {
const instanceId = useInstanceId( WrappedComponent );
// @ts-ignore
return <WrappedComponent { ...props } instanceId={ instanceId } />;
};
},
'instanceId'
);
export default withInstanceId;

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* Internal dependencies
*/
import withInstanceId from '../';
describe( 'withInstanceId', () => {
const DumpComponent = withInstanceId( ( { instanceId } ) => {
return <div data-testid="wrapper">{ instanceId }</div>;
} );
it( 'should generate a new instanceId for each instance', () => {
render( <DumpComponent /> );
render( <DumpComponent /> );
const elements = screen.getAllByTestId( 'wrapper' );
expect( elements[ 0 ] ).not.toHaveTextContent(
elements[ 1 ].textContent
);
} );
} );

View File

@@ -0,0 +1,20 @@
# withNetworkConnectivity
`withNetworkConnectivity` provides a true/false mobile connectivity status based on the `useNetworkConnectivity` hook.
## Usage
```jsx
/**
* WordPress dependencies
*/
import { withNetworkConnectivity } from '@wordpress/compose';
export class MyComponent extends Component {
if ( this.props.isConnected !== true ) {
console.log( 'You are currently offline.' )
}
}
export default withNetworkConnectivity( MyComponent )
```

View File

@@ -0,0 +1,19 @@
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
import useNetworkConnectivity from '../../hooks/use-network-connectivity';
const withNetworkConnectivity = createHigherOrderComponent(
( WrappedComponent ) => {
return ( props ) => {
const { isConnected } = useNetworkConnectivity();
return (
<WrappedComponent { ...props } isConnected={ isConnected } />
);
};
},
'withNetworkConnectivity'
);
export default withNetworkConnectivity;

View File

@@ -0,0 +1,40 @@
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
import usePreferredColorScheme from '../../hooks/use-preferred-color-scheme';
/**
* WordPress dependencies
*/
import { useCallback } from '@wordpress/element';
const withPreferredColorScheme = createHigherOrderComponent(
( WrappedComponent ) => ( props ) => {
const colorScheme = usePreferredColorScheme();
const isDarkMode = colorScheme === 'dark';
const getStyles = useCallback(
( lightStyles, darkStyles ) => {
const finalDarkStyles = {
...lightStyles,
...darkStyles,
};
return isDarkMode ? finalDarkStyles : lightStyles;
},
[ isDarkMode ]
);
return (
<WrappedComponent
preferredColorScheme={ colorScheme }
getStylesFromColorScheme={ getStyles }
{ ...props }
/>
);
},
'withPreferredColorScheme'
);
export default withPreferredColorScheme;

View File

@@ -0,0 +1,24 @@
# withSafeTimeout
`withSafeTimeout` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) which provides a special version of `window.setTimeout` which respects the original component's lifecycle. Simply put, a function set to be called in the future via `setTimeout` will never be called if the original component instance ceases to exist in the meantime.
## Usage
```jsx
/**
* WordPress dependencies
*/
import { withSafeTimeout } from '@wordpress/compose';
function MyEffectfulComponent( { setTimeout } ) {
return (
<TextField
onBlur={ () => {
setTimeout( delayedAction, 0 );
} }
/>
);
}
export default withSafeTimeout( MyEffectfulComponent );
```

View File

@@ -0,0 +1,82 @@
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import type {
WithInjectedProps,
WithoutInjectedProps,
} from '../../utils/create-higher-order-component';
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
/**
* We cannot use the `Window['setTimeout']` and `Window['clearTimeout']`
* types here because those functions include functionality that is not handled
* by this component, like the ability to pass extra arguments.
*
* In the case of this component, we only handle the simplest case where
* `setTimeout` only accepts a function (not a string) and an optional delay.
*/
interface TimeoutProps {
setTimeout: ( fn: () => void, delay: number ) => number;
clearTimeout: ( id: number ) => void;
}
/**
* A higher-order component used to provide and manage delayed function calls
* that ought to be bound to a component's lifecycle.
*/
const withSafeTimeout = createHigherOrderComponent(
< C extends WithInjectedProps< C, TimeoutProps > >(
OriginalComponent: C
) => {
type WrappedProps = WithoutInjectedProps< C, TimeoutProps >;
return class WrappedComponent extends Component< WrappedProps > {
timeouts: number[];
constructor( props: WrappedProps ) {
super( props );
this.timeouts = [];
this.setTimeout = this.setTimeout.bind( this );
this.clearTimeout = this.clearTimeout.bind( this );
}
componentWillUnmount() {
this.timeouts.forEach( clearTimeout );
}
setTimeout( fn: () => void, delay: number ) {
const id = setTimeout( () => {
fn();
this.clearTimeout( id );
}, delay );
this.timeouts.push( id );
return id;
}
clearTimeout( id: number ) {
clearTimeout( id );
this.timeouts = this.timeouts.filter(
( timeoutId ) => timeoutId !== id
);
}
render() {
return (
// @ts-ignore
<OriginalComponent
{ ...this.props }
setTimeout={ this.setTimeout }
clearTimeout={ this.clearTimeout }
/>
);
}
};
},
'withSafeTimeout'
);
export default withSafeTimeout;

View File

@@ -0,0 +1,37 @@
# withState
**Deprecated**
`withState` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) which enables a function component to have internal state.
Wrapping a component with `withState` provides state as props to the wrapped component, along with a `setState` updater function.
## Usage
```jsx
/**
* WordPress dependencies
*/
import { withState } from '@wordpress/compose';
function MyCounter( { count, setState } ) {
return (
<>
Count: { count }
<button
onClick={ () =>
setState( ( state ) => ( { count: state.count + 1 } ) )
}
>
Increment
</button>
</>
);
}
export default withState( {
count: 0,
} )( MyCounter );
```
`withState` optionally accepts an object argument to define the initial state. It returns a function which can then be used in composing your component.

View File

@@ -0,0 +1,49 @@
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../../utils/create-higher-order-component';
/**
* A Higher Order Component used to provide and manage internal component state
* via props.
*
* @deprecated Use `useState` instead.
*
* @param {any} initialState Optional initial state of the component.
*
* @return {any} A higher order component wrapper accepting a component that takes the state props + its own props + `setState` and returning a component that only accepts the own props.
*/
export default function withState( initialState = {} ) {
deprecated( 'wp.compose.withState', {
since: '5.8',
alternative: 'wp.element.useState',
} );
return createHigherOrderComponent( ( OriginalComponent ) => {
return class WrappedComponent extends Component {
constructor( /** @type {any} */ props ) {
super( props );
this.setState = this.setState.bind( this );
this.state = initialState;
}
render() {
return (
<OriginalComponent
{ ...this.props }
{ ...this.state }
setState={ this.setState }
/>
);
}
};
}, 'withState' );
}

View File

@@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import withState from '../';
describe( 'withState', () => {
it( 'should pass initial state and allow updates', async () => {
const user = userEvent.setup();
const EnhancedComponent = withState( {
count: 0,
} )( ( { count, setState } ) => (
<button
onClick={ () =>
setState( ( state ) => ( { count: state.count + 1 } ) )
}
>
{ count }
</button>
) );
render( <EnhancedComponent /> );
const button = screen.getByRole( 'button' );
expect( console ).toHaveWarned();
expect( button ).toHaveTextContent( '0' );
await user.click( button );
expect( button ).toHaveTextContent( '1' );
} );
} );

View File

@@ -0,0 +1,77 @@
/**
* WordPress dependencies
*/
import { flushSync, useEffect, useState } from '@wordpress/element';
import { createQueue } from '@wordpress/priority-queue';
type AsyncListConfig = {
step: number;
};
/**
* Returns the first items from list that are present on state.
*
* @param list New array.
* @param state Current state.
* @return First items present iin state.
*/
function getFirstItemsPresentInState< T >( list: T[], state: T[] ): T[] {
const firstItems = [];
for ( let i = 0; i < list.length; i++ ) {
const item = list[ i ];
if ( ! state.includes( item ) ) {
break;
}
firstItems.push( item );
}
return firstItems;
}
/**
* React hook returns an array which items get asynchronously appended from a source array.
* This behavior is useful if we want to render a list of items asynchronously for performance reasons.
*
* @param list Source array.
* @param config Configuration object.
*
* @return Async array.
*/
function useAsyncList< T >(
list: T[],
config: AsyncListConfig = { step: 1 }
): T[] {
const { step = 1 } = config;
const [ current, setCurrent ] = useState< T[] >( [] );
useEffect( () => {
// On reset, we keep the first items that were previously rendered.
let firstItems = getFirstItemsPresentInState( list, current );
if ( firstItems.length < step ) {
firstItems = firstItems.concat(
list.slice( firstItems.length, step )
);
}
setCurrent( firstItems );
const asyncQueue = createQueue();
for ( let i = firstItems.length; i < list.length; i += step ) {
asyncQueue.add( {}, () => {
flushSync( () => {
setCurrent( ( state ) => [
...state,
...list.slice( i, i + step ),
] );
} );
} );
}
return () => asyncQueue.reset();
}, [ list ] );
return current;
}
export default useAsyncList;

View File

@@ -0,0 +1,27 @@
# `useConstrainedTabbing`
In Dialogs/modals, the tabbing must be constrained to the content of the wrapper element. To achieve this behavior you can use the `useConstrainedTabbing` hook.
## Return Object Properties
### `ref`
- Type: `Object|Function`
A function reference that must be passed to the DOM element where constrained tabbing should be enabled.
## Usage
```jsx
import { useConstrainedTabbing } from '@wordpress/compose';
const ConstrainedTabbingExample = () => {
const ref = useConstrainedTabbing();
return (
<div ref={ ref }>
<Button />
<Button />
</div>
);
};
```

View File

@@ -0,0 +1,92 @@
/**
* WordPress dependencies
*/
import { focus } from '@wordpress/dom';
/**
* Internal dependencies
*/
import useRefEffect from '../use-ref-effect';
/**
* In Dialogs/modals, the tabbing must be constrained to the content of
* the wrapper element. This hook adds the behavior to the returned ref.
*
* @return {import('react').RefCallback<Element>} Element Ref.
*
* @example
* ```js
* import { useConstrainedTabbing } from '@wordpress/compose';
*
* const ConstrainedTabbingExample = () => {
* const constrainedTabbingRef = useConstrainedTabbing()
* return (
* <div ref={ constrainedTabbingRef }>
* <Button />
* <Button />
* </div>
* );
* }
* ```
*/
function useConstrainedTabbing() {
return useRefEffect( ( /** @type {HTMLElement} */ node ) => {
function onKeyDown( /** @type {KeyboardEvent} */ event ) {
const { key, shiftKey, target } = event;
if ( key !== 'Tab' ) {
return;
}
const action = shiftKey ? 'findPrevious' : 'findNext';
const nextElement =
focus.tabbable[ action ](
/** @type {HTMLElement} */ ( target )
) || null;
// When the target element contains the element that is about to
// receive focus, for example when the target is a tabbable
// container, browsers may disagree on where to move focus next.
// In this case we can't rely on native browsers behavior. We need
// to manage focus instead.
// See https://github.com/WordPress/gutenberg/issues/46041.
if (
/** @type {HTMLElement} */ ( target ).contains( nextElement )
) {
event.preventDefault();
nextElement?.focus();
return;
}
// If the element that is about to receive focus is inside the
// area, rely on native browsers behavior and let tabbing follow
// the native tab sequence.
if ( node.contains( nextElement ) ) {
return;
}
// If the element that is about to receive focus is outside the
// area, move focus to a div and insert it at the start or end of
// the area, depending on the direction. Without preventing default
// behaviour, the browser will then move focus to the next element.
const domAction = shiftKey ? 'append' : 'prepend';
const { ownerDocument } = node;
const trap = ownerDocument.createElement( 'div' );
trap.tabIndex = -1;
node[ domAction ]( trap );
// Remove itself when the trap loses focus.
trap.addEventListener( 'blur', () => node.removeChild( trap ) );
trap.focus();
}
node.addEventListener( 'keydown', onKeyDown );
return () => {
node.removeEventListener( 'keydown', onKeyDown );
};
}, [] );
}
export default useConstrainedTabbing;

View File

@@ -0,0 +1,14 @@
/**
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
function useConstrainedTabbing() {
const ref = useRef();
// Do nothing on mobile as tabbing is not a mobile behavior.
return ref;
}
export default useConstrainedTabbing;

View File

@@ -0,0 +1,77 @@
/**
* External dependencies
*/
import Clipboard from 'clipboard';
/**
* WordPress dependencies
*/
import { useRef, useEffect, useState } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';
/* eslint-disable jsdoc/no-undefined-types */
/**
* Copies the text to the clipboard when the element is clicked.
*
* @deprecated
*
* @param {import('react').RefObject<string | Element | NodeListOf<Element>>} ref Reference with the element.
* @param {string|Function} text The text to copy.
* @param {number} [timeout] Optional timeout to reset the returned
* state. 4 seconds by default.
*
* @return {boolean} Whether or not the text has been copied. Resets after the
* timeout.
*/
export default function useCopyOnClick( ref, text, timeout = 4000 ) {
/* eslint-enable jsdoc/no-undefined-types */
deprecated( 'wp.compose.useCopyOnClick', {
since: '5.8',
alternative: 'wp.compose.useCopyToClipboard',
} );
/** @type {import('react').MutableRefObject<Clipboard | undefined>} */
const clipboard = useRef();
const [ hasCopied, setHasCopied ] = useState( false );
useEffect( () => {
/** @type {number | undefined} */
let timeoutId;
if ( ! ref.current ) {
return;
}
// Clipboard listens to click events.
clipboard.current = new Clipboard( ref.current, {
text: () => ( typeof text === 'function' ? text() : text ),
} );
clipboard.current.on( 'success', ( { clearSelection, trigger } ) => {
// Clearing selection will move focus back to the triggering button,
// ensuring that it is not reset to the body, and further that it is
// kept within the rendered node.
clearSelection();
// Handle ClipboardJS focus bug, see https://github.com/zenorocha/clipboard.js/issues/680
if ( trigger ) {
/** @type {HTMLElement} */ ( trigger ).focus();
}
if ( timeout ) {
setHasCopied( true );
clearTimeout( timeoutId );
timeoutId = setTimeout( () => setHasCopied( false ), timeout );
}
} );
return () => {
if ( clipboard.current ) {
clipboard.current.destroy();
}
clearTimeout( timeoutId );
};
}, [ text, timeout, setHasCopied ] );
return hasCopied;
}

View File

@@ -0,0 +1,67 @@
/**
* External dependencies
*/
import Clipboard from 'clipboard';
/**
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import useRefEffect from '../use-ref-effect';
/**
* @template T
* @param {T} value
* @return {import('react').RefObject<T>} The updated ref
*/
function useUpdatedRef( value ) {
const ref = useRef( value );
ref.current = value;
return ref;
}
/**
* Copies the given text to the clipboard when the element is clicked.
*
* @template {HTMLElement} TElementType
* @param {string | (() => string)} text The text to copy. Use a function if not
* already available and expensive to compute.
* @param {Function} onSuccess Called when to text is copied.
*
* @return {import('react').Ref<TElementType>} A ref to assign to the target element.
*/
export default function useCopyToClipboard( text, onSuccess ) {
// Store the dependencies as refs and continuously update them so they're
// fresh when the callback is called.
const textRef = useUpdatedRef( text );
const onSuccessRef = useUpdatedRef( onSuccess );
return useRefEffect( ( node ) => {
// Clipboard listens to click events.
const clipboard = new Clipboard( node, {
text() {
return typeof textRef.current === 'function'
? textRef.current()
: textRef.current || '';
},
} );
clipboard.on( 'success', ( { clearSelection } ) => {
// Clearing selection will move focus back to the triggering
// button, ensuring that it is not reset to the body, and
// further that it is kept within the rendered node.
clearSelection();
if ( onSuccessRef.current ) {
onSuccessRef.current();
}
} );
return () => {
clipboard.destroy();
};
}, [] );
}

View File

@@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { useMemoOne } from 'use-memo-one';
/**
* WordPress dependencies
*/
import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { debounce } from '../../utils/debounce';
/**
* Debounces a function similar to Lodash's `debounce`. A new debounced function will
* be returned and any scheduled calls cancelled if any of the arguments change,
* including the function to debounce, so please wrap functions created on
* render in components in `useCallback`.
*
* @see https://docs-lodash.com/v4/debounce/
*
* @template {(...args: any[]) => void} TFunc
*
* @param {TFunc} fn The function to debounce.
* @param {number} [wait] The number of milliseconds to delay.
* @param {import('../../utils/debounce').DebounceOptions} [options] The options object.
* @return {import('../../utils/debounce').DebouncedFunc<TFunc>} Debounced function.
*/
export default function useDebounce( fn, wait, options ) {
const debounced = useMemoOne(
() => debounce( fn, wait ?? 0, options ),
[ fn, wait, options ]
);
useEffect( () => () => debounced.cancel(), [ debounced ] );
return debounced;
}

View File

@@ -0,0 +1,28 @@
/**
* WordPress dependencies
*/
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import useDebounce from '../use-debounce';
/**
* Helper hook for input fields that need to debounce the value before using it.
*
* @param {any} defaultValue The default value to use.
* @return {[string, Function, string]} The input value, the setter and the debounced input value.
*/
export default function useDebouncedInput( defaultValue = '' ) {
const [ input, setInput ] = useState( defaultValue );
const [ debouncedInput, setDebouncedState ] = useState( defaultValue );
const setDebouncedInput = useDebounce( setDebouncedState, 250 );
useEffect( () => {
setDebouncedInput( input );
}, [ input ] );
return [ input, setInput, debouncedInput ];
}

View File

@@ -0,0 +1,43 @@
# `useDialog`
React hook to be used on a dialog wrapper to enable the following behaviors:
- constrained tabbing.
- focus on mount.
- return focus on unmount.
- focus outside.
## Returned value
The hooks returns an array composed of the two following values:
### `ref`
- Type: `Object|Function`
A React ref that must be passed to the DOM element where the behavior should be attached.
### `props`
- Type: `Object`
Extra props to apply to the wrapper.
## Usage
```jsx
import { __experimentalUseDialog as useDialog } from '@wordpress/compose';
const MyDialog = () => {
const [ ref, extraProps ] = useDialog( {
onClose: () => console.log( 'do something to close the dialog' ),
} );
return (
<div ref={ ref } { ...extraProps }>
<Button />
<Button />
</div>
);
};
```

View File

@@ -0,0 +1,118 @@
/**
* External dependencies
*/
import type { RefCallback, SyntheticEvent } from 'react';
/**
* WordPress dependencies
*/
import { useRef, useEffect, useCallback } from '@wordpress/element';
import { ESCAPE } from '@wordpress/keycodes';
/**
* Internal dependencies
*/
import useConstrainedTabbing from '../use-constrained-tabbing';
import useFocusOnMount from '../use-focus-on-mount';
import useFocusReturn from '../use-focus-return';
import useFocusOutside from '../use-focus-outside';
import useMergeRefs from '../use-merge-refs';
type DialogOptions = {
/**
* Determines whether focus should be automatically moved to the popover
* when it mounts. `false` causes no focus shift, `true` causes the popover
* itself to gain focus, and `firstElement` focuses the first focusable
* element within the popover.
*
* @default 'firstElement'
*/
focusOnMount?: Parameters< typeof useFocusOnMount >[ 0 ];
/**
* Determines whether tabbing is constrained to within the popover,
* preventing keyboard focus from leaving the popover content without
* explicit focus elsewhere, or whether the popover remains part of the
* wider tab order.
* If no value is passed, it will be derived from `focusOnMount`.
*
* @see focusOnMount
* @default `focusOnMount` !== false
*/
constrainTabbing?: boolean;
onClose?: () => void;
/**
* Use the `onClose` prop instead.
*
* @deprecated
*/
__unstableOnClose?: (
type: string | undefined,
event: SyntheticEvent
) => void;
};
type useDialogReturn = [
RefCallback< HTMLElement >,
ReturnType< typeof useFocusOutside > & Pick< HTMLElement, 'tabIndex' >,
];
/**
* Returns a ref and props to apply to a dialog wrapper to enable the following behaviors:
* - constrained tabbing.
* - focus on mount.
* - return focus on unmount.
* - focus outside.
*
* @param options Dialog Options.
*/
function useDialog( options: DialogOptions ): useDialogReturn {
const currentOptions = useRef< DialogOptions | undefined >();
const { constrainTabbing = options.focusOnMount !== false } = options;
useEffect( () => {
currentOptions.current = options;
}, Object.values( options ) );
const constrainedTabbingRef = useConstrainedTabbing();
const focusOnMountRef = useFocusOnMount( options.focusOnMount );
const focusReturnRef = useFocusReturn();
const focusOutsideProps = useFocusOutside( ( event ) => {
// This unstable prop is here only to manage backward compatibility
// for the Popover component otherwise, the onClose should be enough.
if ( currentOptions.current?.__unstableOnClose ) {
currentOptions.current.__unstableOnClose( 'focus-outside', event );
} else if ( currentOptions.current?.onClose ) {
currentOptions.current.onClose();
}
} );
const closeOnEscapeRef = useCallback( ( node: HTMLElement ) => {
if ( ! node ) {
return;
}
node.addEventListener( 'keydown', ( event: KeyboardEvent ) => {
// Close on escape.
if (
event.keyCode === ESCAPE &&
! event.defaultPrevented &&
currentOptions.current?.onClose
) {
event.preventDefault();
currentOptions.current.onClose();
}
} );
}, [] );
return [
useMergeRefs( [
constrainTabbing ? constrainedTabbingRef : null,
options.focusOnMount !== false ? focusReturnRef : null,
options.focusOnMount !== false ? focusOnMountRef : null,
closeOnEscapeRef,
] ),
{
...focusOutsideProps,
tabIndex: -1,
},
];
}
export default useDialog;

View File

@@ -0,0 +1,86 @@
/**
* Internal dependencies
*/
import { debounce } from '../../utils/debounce';
import useRefEffect from '../use-ref-effect';
/**
* In some circumstances, such as block previews, all focusable DOM elements
* (input fields, links, buttons, etc.) need to be disabled. This hook adds the
* behavior to disable nested DOM elements to the returned ref.
*
* If you can, prefer the use of the inert HTML attribute.
*
* @param {Object} config Configuration object.
* @param {boolean=} config.isDisabled Whether the element should be disabled.
* @return {import('react').RefCallback<HTMLElement>} Element Ref.
*
* @example
* ```js
* import { useDisabled } from '@wordpress/compose';
*
* const DisabledExample = () => {
* const disabledRef = useDisabled();
* return (
* <div ref={ disabledRef }>
* <a href="#">This link will have tabindex set to -1</a>
* <input placeholder="This input will have the disabled attribute added to it." type="text" />
* </div>
* );
* };
* ```
*/
export default function useDisabled( {
isDisabled: isDisabledProp = false,
} = {} ) {
return useRefEffect(
( node ) => {
if ( isDisabledProp ) {
return;
}
const defaultView = node?.ownerDocument?.defaultView;
if ( ! defaultView ) {
return;
}
/** A variable keeping track of the previous updates in order to restore them. */
const updates: Function[] = [];
const disable = () => {
node.childNodes.forEach( ( child ) => {
if ( ! ( child instanceof defaultView.HTMLElement ) ) {
return;
}
if ( ! child.getAttribute( 'inert' ) ) {
child.setAttribute( 'inert', 'true' );
updates.push( () => {
child.removeAttribute( 'inert' );
} );
}
} );
};
// Debounce re-disable since disabling process itself will incur
// additional mutations which should be ignored.
const debouncedDisable = debounce( disable, 0, {
leading: true,
} );
disable();
/** @type {MutationObserver | undefined} */
const observer = new window.MutationObserver( debouncedDisable );
observer.observe( node, {
childList: true,
} );
return () => {
if ( observer ) {
observer.disconnect();
}
debouncedDisable.cancel();
updates.forEach( ( update ) => update() );
};
},
[ isDisabledProp ]
);
}

View File

@@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import useDisabled from '../';
describe( 'useDisabled', () => {
const Form = forwardRef( ( { showButton }, ref ) => {
return (
<form ref={ ref }>
<input />
<a href="https://wordpress.org/">A link</a>
<p role="document" contentEditable tabIndex="0"></p>
{ showButton && <button>Button</button> }
</form>
);
} );
function DisabledComponent( props ) {
const disabledRef = useDisabled();
return <Form ref={ disabledRef } { ...props } />;
}
it( 'will disable all fields', () => {
render( <DisabledComponent /> );
const input = screen.getByRole( 'textbox' );
const link = screen.getByRole( 'link' );
const p = screen.getByRole( 'document' );
expect( input ).toHaveAttribute( 'inert', 'true' );
expect( link ).toHaveAttribute( 'inert', 'true' );
expect( p ).toHaveAttribute( 'inert', 'true' );
} );
it( 'will disable an element rendered in an update to the component', async () => {
const { rerender } = render(
<DisabledComponent showButton={ false } />
);
expect( screen.queryByText( 'Button' ) ).not.toBeInTheDocument();
rerender( <DisabledComponent showButton /> );
const button = screen.getByText( 'Button' );
await waitFor( () => {
expect( button ).toHaveAttribute( 'inert', 'true' );
} );
} );
} );

View File

@@ -0,0 +1,85 @@
# `useDragging`
In some situations, we want to have simple drag & drop behaviors.
Typically drag & drop behaviors follow a common pattern: We have an element that we want to drag or where we want dragging to start; the dragging starts when the `onMouseDown` event happens on the target element. When the dragging starts, global event listeners for mouse movement (`mousemove`) and the mouse up event (`mouseup`) are added. When the global mouse movement event triggers, the dragging behavior happens (e.g., a position is updated), when the mouse up event triggers, dragging stops, and both global event listeners are removed.
`useDragging` makes the implementation of the described common pattern simpler because it handles the addition and removal of global events.
## Input Object Properties
### `onDragStart`
- Type: `Function`
The hook calls `onDragStart` when the dragging starts. The function receives as parameters the same parameters passed to `startDrag` whose documentation is available below.
If `startDrag` is passed directly as an `onMouseDown` event handler, `onDragStart` will receive the `onMouseDown` event.
### `onDragMove`
- Type: `Function`
The hook calls `onDragMove ` after the dragging starts and when a mouse movement happens.
It receives the `mousemove` event.
### `onDragEnd`
- Type: `Function`
The hook calls `onDragEnd` when the dragging ends. When dragging is explicitly stopped, the function receives as parameters, the same parameters passed to `endDrag` whose documentation is available below.
When dragging stops because the user releases the mouse, the function receives the `mouseup` event.
## Return Object Properties
### `startDrag`
- Type: `Function`
A function that, when called, starts the dragging behavior. Parameters passed to this function will be passed to `onDragStart` when the dragging starts.
It is possible to directly pass `startDrag` as the `onMouseDown` event handler of some element.
### `endDrag`
- Type: `Function`
A function that, when called, stops the dragging behavior. Parameters passed to this function will be passed to `onDragEnd` when the dragging ends.
In most cases, there is no need to call this function directly. Dragging behavior automatically stops when the mouse is released.
### `isDragging`
- Type: `Boolean`
A boolean value, when true it means dragging is currently taking place; when false, it means dragging is not taking place.
## Usage
The following example allows us to drag & drop a red square around the entire viewport.
```jsx
/**
* WordPress dependencies
*/
import { useState, useCallback } from 'react';
import { __experimentalUseDragging as useDragging } from '@wordpress/compose';
const UseDraggingExample = () => {
const [ position, setPosition ] = useState( null );
const changePosition = useCallback( ( event ) => {
setPosition( { x: event.clientX, y: event.clientY } );
} );
const { startDrag } = useDragging( {
onDragMove: changePosition,
} );
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
onMouseDown={ startDrag }
style={ {
position: 'fixed',
width: 10,
height: 10,
backgroundColor: 'red',
...( position ? { top: position.y, left: position.x } : {} ),
} }
/>
);
};
```

View File

@@ -0,0 +1,74 @@
/**
* WordPress dependencies
*/
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import useIsomorphicLayoutEffect from '../use-isomorphic-layout-effect';
// Event handlers that are triggered from `document` listeners accept a MouseEvent,
// while those triggered from React listeners accept a React.MouseEvent.
/**
* @param {Object} props
* @param {(e: import('react').MouseEvent) => void} props.onDragStart
* @param {(e: MouseEvent) => void} props.onDragMove
* @param {(e?: MouseEvent) => void} props.onDragEnd
*/
export default function useDragging( { onDragStart, onDragMove, onDragEnd } ) {
const [ isDragging, setIsDragging ] = useState( false );
const eventsRef = useRef( {
onDragStart,
onDragMove,
onDragEnd,
} );
useIsomorphicLayoutEffect( () => {
eventsRef.current.onDragStart = onDragStart;
eventsRef.current.onDragMove = onDragMove;
eventsRef.current.onDragEnd = onDragEnd;
}, [ onDragStart, onDragMove, onDragEnd ] );
/** @type {(e: MouseEvent) => void} */
const onMouseMove = useCallback(
( event ) =>
eventsRef.current.onDragMove &&
eventsRef.current.onDragMove( event ),
[]
);
/** @type {(e?: MouseEvent) => void} */
const endDrag = useCallback( ( event ) => {
if ( eventsRef.current.onDragEnd ) {
eventsRef.current.onDragEnd( event );
}
document.removeEventListener( 'mousemove', onMouseMove );
document.removeEventListener( 'mouseup', endDrag );
setIsDragging( false );
}, [] );
/** @type {(e: import('react').MouseEvent) => void} */
const startDrag = useCallback( ( event ) => {
if ( eventsRef.current.onDragStart ) {
eventsRef.current.onDragStart( event );
}
document.addEventListener( 'mousemove', onMouseMove );
document.addEventListener( 'mouseup', endDrag );
setIsDragging( true );
}, [] );
// Remove the global events when unmounting if needed.
useEffect( () => {
return () => {
if ( isDragging ) {
document.removeEventListener( 'mousemove', onMouseMove );
document.removeEventListener( 'mouseup', endDrag );
}
};
}, [ isDragging ] );
return {
startDrag,
endDrag,
isDragging,
};
}

View File

@@ -0,0 +1,71 @@
# useDropZone (experimental)
A hook to facilitate drag and drop handling within a designated drop zone area. An optional `dropZoneElement` can be provided, however by default the drop zone is bound by the area where the returned `ref` is assigned.
When using a `dropZoneElement`, it is expected that the `ref` will be attached to a node that is a descendent of the `dropZoneElement`. Additionally, the element passed to `dropZoneElement` should be stored in state rather than a plain ref to ensure reactive updating when it changes.
## Usage
```js
import { useState } from 'react';
import { useDropZone } from '@wordpress/compose';
const WithWrapperDropZoneElement = () => {
const [ dropZoneElement, setDropZoneElement ] = useState( null );
const dropZoneRef = useDropZone(
{
dropZoneElement,
onDrop() => {
console.log( 'Dropped within the drop zone.' );
},
onDragEnter() => {
console.log( 'Dragging within the drop zone' );
}
}
)
return (
<div className="outer-wrapper" ref={ setDropZoneElement }>
<div ref={ dropZoneRef }>
<p>Drop Zone</p>
</div>
</div>
);
};
const WithoutWrapperDropZoneElement = () => {
const dropZoneRef = useDropZone(
{
onDrop() => {
console.log( 'Dropped within the drop zone.' );
},
onDragEnter() => {
console.log( 'Dragging within the drop zone' );
}
}
)
return (
<div ref={ dropZoneRef }>
<p>Drop Zone</p>
</div>
);
};
```
## Parameters
- _props_ `Object`: Named parameters.
- _props.dropZoneElement_ `HTMLElement`: Optional element to be used as the drop zone.
- _props.isDisabled_ `boolean`: Whether or not to disable the drop zone.
- _props.onDragStart_ `( e: DragEvent ) => void`: Called when dragging has started.
- _props.onDragEnter_ `( e: DragEvent ) => void`: Called when the zone is entered.
- _props.onDragOver_ `( e: DragEvent ) => void`: Called when the zone is moved within.
- _props.onDragLeave_ `( e: DragEvent ) => void`: Called when the zone is left.
- _props.onDragEnd_ `( e: MouseEvent ) => void`: Called when dragging has ended.
- _props.onDrop_ `( e: DragEvent ) => void`: Called when dropping in the zone.
_Returns_
- `RefCallback< HTMLElement >`: Ref callback to be passed to the drop zone element.

View File

@@ -0,0 +1,240 @@
/**
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import useRefEffect from '../use-ref-effect';
/* eslint-disable jsdoc/valid-types */
/**
* @template T
* @param {T} value
* @return {import('react').MutableRefObject<T|null>} A ref with the value.
*/
function useFreshRef( value ) {
/* eslint-enable jsdoc/valid-types */
/* eslint-disable jsdoc/no-undefined-types */
/** @type {import('react').MutableRefObject<T>} */
/* eslint-enable jsdoc/no-undefined-types */
// Disable reason: We're doing something pretty JavaScript-y here where the
// ref will always have a current value that is not null or undefined but it
// needs to start as undefined. We don't want to change the return type so
// it's easier to just ts-ignore this specific line that's complaining about
// undefined not being part of T.
// @ts-ignore
const ref = useRef();
ref.current = value;
return ref;
}
/**
* A hook to facilitate drag and drop handling.
*
* @param {Object} props Named parameters.
* @param {?HTMLElement} [props.dropZoneElement] Optional element to be used as the drop zone.
* @param {boolean} [props.isDisabled] Whether or not to disable the drop zone.
* @param {(e: DragEvent) => void} [props.onDragStart] Called when dragging has started.
* @param {(e: DragEvent) => void} [props.onDragEnter] Called when the zone is entered.
* @param {(e: DragEvent) => void} [props.onDragOver] Called when the zone is moved within.
* @param {(e: DragEvent) => void} [props.onDragLeave] Called when the zone is left.
* @param {(e: MouseEvent) => void} [props.onDragEnd] Called when dragging has ended.
* @param {(e: DragEvent) => void} [props.onDrop] Called when dropping in the zone.
*
* @return {import('react').RefCallback<HTMLElement>} Ref callback to be passed to the drop zone element.
*/
export default function useDropZone( {
dropZoneElement,
isDisabled,
onDrop: _onDrop,
onDragStart: _onDragStart,
onDragEnter: _onDragEnter,
onDragLeave: _onDragLeave,
onDragEnd: _onDragEnd,
onDragOver: _onDragOver,
} ) {
const onDropRef = useFreshRef( _onDrop );
const onDragStartRef = useFreshRef( _onDragStart );
const onDragEnterRef = useFreshRef( _onDragEnter );
const onDragLeaveRef = useFreshRef( _onDragLeave );
const onDragEndRef = useFreshRef( _onDragEnd );
const onDragOverRef = useFreshRef( _onDragOver );
return useRefEffect(
( elem ) => {
if ( isDisabled ) {
return;
}
// If a custom dropZoneRef is passed, use that instead of the element.
// This allows the dropzone to cover an expanded area, rather than
// be restricted to the area of the ref returned by this hook.
const element = dropZoneElement ?? elem;
let isDragging = false;
const { ownerDocument } = element;
/**
* Checks if an element is in the drop zone.
*
* @param {EventTarget|null} targetToCheck
*
* @return {boolean} True if in drop zone, false if not.
*/
function isElementInZone( targetToCheck ) {
const { defaultView } = ownerDocument;
if (
! targetToCheck ||
! defaultView ||
! ( targetToCheck instanceof defaultView.HTMLElement ) ||
! element.contains( targetToCheck )
) {
return false;
}
/** @type {HTMLElement|null} */
let elementToCheck = targetToCheck;
do {
if ( elementToCheck.dataset.isDropZone ) {
return elementToCheck === element;
}
} while ( ( elementToCheck = elementToCheck.parentElement ) );
return false;
}
function maybeDragStart( /** @type {DragEvent} */ event ) {
if ( isDragging ) {
return;
}
isDragging = true;
// Note that `dragend` doesn't fire consistently for file and
// HTML drag events where the drag origin is outside the browser
// window. In Firefox it may also not fire if the originating
// node is removed.
ownerDocument.addEventListener( 'dragend', maybeDragEnd );
ownerDocument.addEventListener( 'mousemove', maybeDragEnd );
if ( onDragStartRef.current ) {
onDragStartRef.current( event );
}
}
function onDragEnter( /** @type {DragEvent} */ event ) {
event.preventDefault();
// The `dragenter` event will also fire when entering child
// elements, but we only want to call `onDragEnter` when
// entering the drop zone, which means the `relatedTarget`
// (element that has been left) should be outside the drop zone.
if (
element.contains(
/** @type {Node} */ ( event.relatedTarget )
)
) {
return;
}
if ( onDragEnterRef.current ) {
onDragEnterRef.current( event );
}
}
function onDragOver( /** @type {DragEvent} */ event ) {
// Only call onDragOver for the innermost hovered drop zones.
if ( ! event.defaultPrevented && onDragOverRef.current ) {
onDragOverRef.current( event );
}
// Prevent the browser default while also signalling to parent
// drop zones that `onDragOver` is already handled.
event.preventDefault();
}
function onDragLeave( /** @type {DragEvent} */ event ) {
// The `dragleave` event will also fire when leaving child
// elements, but we only want to call `onDragLeave` when
// leaving the drop zone, which means the `relatedTarget`
// (element that has been entered) should be outside the drop
// zone.
// Note: This is not entirely reliable in Safari due to this bug
// https://bugs.webkit.org/show_bug.cgi?id=66547
if ( isElementInZone( event.relatedTarget ) ) {
return;
}
if ( onDragLeaveRef.current ) {
onDragLeaveRef.current( event );
}
}
function onDrop( /** @type {DragEvent} */ event ) {
// Don't handle drop if an inner drop zone already handled it.
if ( event.defaultPrevented ) {
return;
}
// Prevent the browser default while also signalling to parent
// drop zones that `onDrop` is already handled.
event.preventDefault();
// This seemingly useless line has been shown to resolve a
// Safari issue where files dragged directly from the dock are
// not recognized.
// eslint-disable-next-line no-unused-expressions
event.dataTransfer && event.dataTransfer.files.length;
if ( onDropRef.current ) {
onDropRef.current( event );
}
maybeDragEnd( event );
}
function maybeDragEnd( /** @type {MouseEvent} */ event ) {
if ( ! isDragging ) {
return;
}
isDragging = false;
ownerDocument.removeEventListener( 'dragend', maybeDragEnd );
ownerDocument.removeEventListener( 'mousemove', maybeDragEnd );
if ( onDragEndRef.current ) {
onDragEndRef.current( event );
}
}
element.dataset.isDropZone = 'true';
element.addEventListener( 'drop', onDrop );
element.addEventListener( 'dragenter', onDragEnter );
element.addEventListener( 'dragover', onDragOver );
element.addEventListener( 'dragleave', onDragLeave );
// The `dragstart` event doesn't fire if the drag started outside
// the document.
ownerDocument.addEventListener( 'dragenter', maybeDragStart );
return () => {
delete element.dataset.isDropZone;
element.removeEventListener( 'drop', onDrop );
element.removeEventListener( 'dragenter', onDragEnter );
element.removeEventListener( 'dragover', onDragOver );
element.removeEventListener( 'dragleave', onDragLeave );
ownerDocument.removeEventListener( 'dragend', maybeDragEnd );
ownerDocument.removeEventListener( 'mousemove', maybeDragEnd );
ownerDocument.removeEventListener(
'dragenter',
maybeDragStart
);
};
},
[ isDisabled, dropZoneElement ] // Refresh when the passed in dropZoneElement changes.
);
}

View File

@@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import useDropZone from '../';
describe( 'useDropZone', () => {
const ComponentWithWrapperDropZone = () => {
const [ dropZoneElement, setDropZoneElement ] = useState( null );
const dropZoneRef = useDropZone( {
dropZoneElement,
} );
return (
<div role="main" ref={ setDropZoneElement }>
<div role="region" ref={ dropZoneRef }>
<div>Drop Zone</div>
</div>
</div>
);
};
const ComponentWithoutWrapperDropZone = () => {
const dropZoneRef = useDropZone( {} );
return (
<div role="main">
<div role="region" ref={ dropZoneRef }>
<div>Drop Zone</div>
</div>
</div>
);
};
it( 'will attach dropzone to outer wrapper', () => {
const { rerender } = render( <ComponentWithWrapperDropZone /> );
// Ensure `useEffect` has run.
rerender( <ComponentWithWrapperDropZone /> );
expect( screen.getByRole( 'main' ) ).toHaveAttribute(
'data-is-drop-zone'
);
} );
it( 'will attach dropzone to element with dropZoneRef attached', () => {
const { rerender } = render( <ComponentWithoutWrapperDropZone /> );
// Ensure `useEffect` has run.
rerender( <ComponentWithoutWrapperDropZone /> );
expect( screen.getByRole( 'region' ) ).toHaveAttribute(
'data-is-drop-zone'
);
} );
} );

View File

@@ -0,0 +1,189 @@
/**
* WordPress dependencies
*/
import { useState, useLayoutEffect } from '@wordpress/element';
import { getScrollContainer } from '@wordpress/dom';
import { PAGEUP, PAGEDOWN, HOME, END } from '@wordpress/keycodes';
/**
* Internal dependencies
*/
import { debounce } from '../../utils/debounce';
const DEFAULT_INIT_WINDOW_SIZE = 30;
/**
* @typedef {Object} WPFixedWindowList
*
* @property {number} visibleItems Items visible in the current viewport
* @property {number} start Start index of the window
* @property {number} end End index of the window
* @property {(index:number)=>boolean} itemInView Returns true if item is in the window
*/
/**
* @typedef {Object} WPFixedWindowListOptions
*
* @property {number} [windowOverscan] Renders windowOverscan number of items before and after the calculated visible window.
* @property {boolean} [useWindowing] When false avoids calculating the window size
* @property {number} [initWindowSize] Initial window size to use on first render before we can calculate the window size.
* @property {any} [expandedState] Used to recalculate the window size when the expanded state of a list changes.
*/
/**
*
* @param {import('react').RefObject<HTMLElement>} elementRef Used to find the closest scroll container that contains element.
* @param { number } itemHeight Fixed item height in pixels
* @param { number } totalItems Total items in list
* @param { WPFixedWindowListOptions } [options] Options object
* @return {[ WPFixedWindowList, setFixedListWindow:(nextWindow:WPFixedWindowList)=>void]} Array with the fixed window list and setter
*/
export default function useFixedWindowList(
elementRef,
itemHeight,
totalItems,
options
) {
const initWindowSize = options?.initWindowSize ?? DEFAULT_INIT_WINDOW_SIZE;
const useWindowing = options?.useWindowing ?? true;
const [ fixedListWindow, setFixedListWindow ] = useState( {
visibleItems: initWindowSize,
start: 0,
end: initWindowSize,
itemInView: ( /** @type {number} */ index ) => {
return index >= 0 && index <= initWindowSize;
},
} );
useLayoutEffect( () => {
if ( ! useWindowing ) {
return;
}
const scrollContainer = getScrollContainer( elementRef.current );
const measureWindow = (
/** @type {boolean | undefined} */ initRender
) => {
if ( ! scrollContainer ) {
return;
}
const visibleItems = Math.ceil(
scrollContainer.clientHeight / itemHeight
);
// Aim to keep opening list view fast, afterward we can optimize for scrolling.
const windowOverscan = initRender
? visibleItems
: options?.windowOverscan ?? visibleItems;
const firstViewableIndex = Math.floor(
scrollContainer.scrollTop / itemHeight
);
const start = Math.max( 0, firstViewableIndex - windowOverscan );
const end = Math.min(
totalItems - 1,
firstViewableIndex + visibleItems + windowOverscan
);
setFixedListWindow( ( lastWindow ) => {
const nextWindow = {
visibleItems,
start,
end,
itemInView: ( /** @type {number} */ index ) => {
return start <= index && index <= end;
},
};
if (
lastWindow.start !== nextWindow.start ||
lastWindow.end !== nextWindow.end ||
lastWindow.visibleItems !== nextWindow.visibleItems
) {
return nextWindow;
}
return lastWindow;
} );
};
measureWindow( true );
const debounceMeasureList = debounce( () => {
measureWindow();
}, 16 );
scrollContainer?.addEventListener( 'scroll', debounceMeasureList );
scrollContainer?.ownerDocument?.defaultView?.addEventListener(
'resize',
debounceMeasureList
);
scrollContainer?.ownerDocument?.defaultView?.addEventListener(
'resize',
debounceMeasureList
);
return () => {
scrollContainer?.removeEventListener(
'scroll',
debounceMeasureList
);
scrollContainer?.ownerDocument?.defaultView?.removeEventListener(
'resize',
debounceMeasureList
);
};
}, [
itemHeight,
elementRef,
totalItems,
options?.expandedState,
options?.windowOverscan,
useWindowing,
] );
useLayoutEffect( () => {
if ( ! useWindowing ) {
return;
}
const scrollContainer = getScrollContainer( elementRef.current );
const handleKeyDown = ( /** @type {KeyboardEvent} */ event ) => {
switch ( event.keyCode ) {
case HOME: {
return scrollContainer?.scrollTo( { top: 0 } );
}
case END: {
return scrollContainer?.scrollTo( {
top: totalItems * itemHeight,
} );
}
case PAGEUP: {
return scrollContainer?.scrollTo( {
top:
scrollContainer.scrollTop -
fixedListWindow.visibleItems * itemHeight,
} );
}
case PAGEDOWN: {
return scrollContainer?.scrollTo( {
top:
scrollContainer.scrollTop +
fixedListWindow.visibleItems * itemHeight,
} );
}
}
};
scrollContainer?.ownerDocument?.defaultView?.addEventListener(
'keydown',
handleKeyDown
);
return () => {
scrollContainer?.ownerDocument?.defaultView?.removeEventListener(
'keydown',
handleKeyDown
);
};
}, [
totalItems,
itemHeight,
elementRef,
fixedListWindow.visibleItems,
useWindowing,
options?.expandedState,
] );
return [ fixedListWindow, setFixedListWindow ];
}

View File

@@ -0,0 +1,27 @@
# `useFocusOnMount`
Hook used to focus the first tabbable element on mount.
## Return Object Properties
### `ref`
- Type: `Object|Function`
A React ref that must be passed to the DOM element where the behavior should be attached.
## Usage
```jsx
import { useFocusOnMount } from '@wordpress/compose';
const WithFocusOnMount = () => {
const ref = useFocusOnMount();
return (
<div ref={ ref }>
<Button />
<Button />
</div>
);
};
```

View File

@@ -0,0 +1,84 @@
/**
* WordPress dependencies
*/
import { useRef, useEffect, useCallback } from '@wordpress/element';
import { focus } from '@wordpress/dom';
/**
* Hook used to focus the first tabbable element on mount.
*
* @param {boolean | 'firstElement'} focusOnMount Focus on mount mode.
* @return {import('react').RefCallback<HTMLElement>} Ref callback.
*
* @example
* ```js
* import { useFocusOnMount } from '@wordpress/compose';
*
* const WithFocusOnMount = () => {
* const ref = useFocusOnMount()
* return (
* <div ref={ ref }>
* <Button />
* <Button />
* </div>
* );
* }
* ```
*/
export default function useFocusOnMount( focusOnMount = 'firstElement' ) {
const focusOnMountRef = useRef( focusOnMount );
/**
* Sets focus on a DOM element.
*
* @param {HTMLElement} target The DOM element to set focus to.
* @return {void}
*/
const setFocus = ( target ) => {
target.focus( {
// When focusing newly mounted dialogs,
// the position of the popover is often not right on the first render
// This prevents the layout shifts when focusing the dialogs.
preventScroll: true,
} );
};
/** @type {import('react').MutableRefObject<ReturnType<setTimeout> | undefined>} */
const timerId = useRef();
useEffect( () => {
focusOnMountRef.current = focusOnMount;
}, [ focusOnMount ] );
useEffect( () => {
return () => {
if ( timerId.current ) {
clearTimeout( timerId.current );
}
};
}, [] );
return useCallback( ( node ) => {
if ( ! node || focusOnMountRef.current === false ) {
return;
}
if ( node.contains( node.ownerDocument?.activeElement ?? null ) ) {
return;
}
if ( focusOnMountRef.current === 'firstElement' ) {
timerId.current = setTimeout( () => {
const firstTabbable = focus.tabbable.find( node )[ 0 ];
if ( firstTabbable ) {
setFocus( firstTabbable );
}
}, 0 );
return;
}
setFocus( node );
}, [] );
}

View File

@@ -0,0 +1,173 @@
/**
* WordPress dependencies
*/
import { useCallback, useEffect, useRef } from '@wordpress/element';
/**
* Input types which are classified as button types, for use in considering
* whether element is a (focus-normalized) button.
*
* @type {string[]}
*/
const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
/**
* @typedef {HTMLButtonElement | HTMLLinkElement | HTMLInputElement} FocusNormalizedButton
*/
// Disable reason: Rule doesn't support predicate return types.
/* eslint-disable jsdoc/valid-types */
/**
* Returns true if the given element is a button element subject to focus
* normalization, or false otherwise.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*
* @param {EventTarget} eventTarget The target from a mouse or touch event.
*
* @return {eventTarget is FocusNormalizedButton} Whether element is a button.
*/
function isFocusNormalizedButton( eventTarget ) {
switch ( eventTarget.nodeName ) {
case 'A':
case 'BUTTON':
return true;
case 'INPUT':
return INPUT_BUTTON_TYPES.includes(
/** @type {HTMLInputElement} */ ( eventTarget ).type
);
}
return false;
}
/* eslint-enable jsdoc/valid-types */
/**
* @typedef {import('react').SyntheticEvent} SyntheticEvent
*/
/**
* @callback EventCallback
* @param {SyntheticEvent} event input related event.
*/
/**
* @typedef FocusOutsideReactElement
* @property {EventCallback} handleFocusOutside callback for a focus outside event.
*/
/**
* @typedef {import('react').MutableRefObject<FocusOutsideReactElement | undefined>} FocusOutsideRef
*/
/**
* @typedef {Object} FocusOutsideReturnValue
* @property {EventCallback} onFocus An event handler for focus events.
* @property {EventCallback} onBlur An event handler for blur events.
* @property {EventCallback} onMouseDown An event handler for mouse down events.
* @property {EventCallback} onMouseUp An event handler for mouse up events.
* @property {EventCallback} onTouchStart An event handler for touch start events.
* @property {EventCallback} onTouchEnd An event handler for touch end events.
*/
/**
* A react hook that can be used to check whether focus has moved outside the
* element the event handlers are bound to.
*
* @param {EventCallback} onFocusOutside A callback triggered when focus moves outside
* the element the event handlers are bound to.
*
* @return {FocusOutsideReturnValue} An object containing event handlers. Bind the event handlers
* to a wrapping element element to capture when focus moves
* outside that element.
*/
export default function useFocusOutside( onFocusOutside ) {
const currentOnFocusOutside = useRef( onFocusOutside );
useEffect( () => {
currentOnFocusOutside.current = onFocusOutside;
}, [ onFocusOutside ] );
const preventBlurCheck = useRef( false );
/**
* @type {import('react').MutableRefObject<number | undefined>}
*/
const blurCheckTimeoutId = useRef();
/**
* Cancel a blur check timeout.
*/
const cancelBlurCheck = useCallback( () => {
clearTimeout( blurCheckTimeoutId.current );
}, [] );
// Cancel blur checks on unmount.
useEffect( () => {
return () => cancelBlurCheck();
}, [] );
// Cancel a blur check if the callback or ref is no longer provided.
useEffect( () => {
if ( ! onFocusOutside ) {
cancelBlurCheck();
}
}, [ onFocusOutside, cancelBlurCheck ] );
/**
* Handles a mousedown or mouseup event to respectively assign and
* unassign a flag for preventing blur check on button elements. Some
* browsers, namely Firefox and Safari, do not emit a focus event on
* button elements when clicked, while others do. The logic here
* intends to normalize this as treating click on buttons as focus.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*
* @param {SyntheticEvent} event Event for mousedown or mouseup.
*/
const normalizeButtonFocus = useCallback( ( event ) => {
const { type, target } = event;
const isInteractionEnd = [ 'mouseup', 'touchend' ].includes( type );
if ( isInteractionEnd ) {
preventBlurCheck.current = false;
} else if ( isFocusNormalizedButton( target ) ) {
preventBlurCheck.current = true;
}
}, [] );
/**
* A callback triggered when a blur event occurs on the element the handler
* is bound to.
*
* Calls the `onFocusOutside` callback in an immediate timeout if focus has
* move outside the bound element and is still within the document.
*
* @param {SyntheticEvent} event Blur event.
*/
const queueBlurCheck = useCallback( ( event ) => {
// React does not allow using an event reference asynchronously
// due to recycling behavior, except when explicitly persisted.
event.persist();
// Skip blur check if clicking button. See `normalizeButtonFocus`.
if ( preventBlurCheck.current ) {
return;
}
blurCheckTimeoutId.current = setTimeout( () => {
if ( 'function' === typeof currentOnFocusOutside.current ) {
currentOnFocusOutside.current( event );
}
}, 0 );
}, [] );
return {
onFocus: cancelBlurCheck,
onMouseDown: normalizeButtonFocus,
onMouseUp: normalizeButtonFocus,
onTouchStart: normalizeButtonFocus,
onTouchEnd: normalizeButtonFocus,
onBlur: queueBlurCheck,
};
}

View File

@@ -0,0 +1,183 @@
/**
* WordPress dependencies
*/
import { useCallback, useEffect, useRef } from '@wordpress/element';
/**
* Input types which are classified as button types, for use in considering
* whether element is a (focus-normalized) button.
*/
const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
/**
* List of HTML button elements subject to focus normalization
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*/
type FocusNormalizedButton =
| HTMLButtonElement
| HTMLLinkElement
| HTMLInputElement;
/**
* Returns true if the given element is a button element subject to focus
* normalization, or false otherwise.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*
* @param eventTarget The target from a mouse or touch event.
*
* @return Whether the element is a button element subject to focus normalization.
*/
function isFocusNormalizedButton(
eventTarget: EventTarget
): eventTarget is FocusNormalizedButton {
if ( ! ( eventTarget instanceof window.HTMLElement ) ) {
return false;
}
switch ( eventTarget.nodeName ) {
case 'A':
case 'BUTTON':
return true;
case 'INPUT':
return INPUT_BUTTON_TYPES.includes(
( eventTarget as HTMLInputElement ).type
);
}
return false;
}
type UseFocusOutsideReturn = {
onFocus: React.FocusEventHandler;
onMouseDown: React.MouseEventHandler;
onMouseUp: React.MouseEventHandler;
onTouchStart: React.TouchEventHandler;
onTouchEnd: React.TouchEventHandler;
onBlur: React.FocusEventHandler;
};
/**
* A react hook that can be used to check whether focus has moved outside the
* element the event handlers are bound to.
*
* @param onFocusOutside A callback triggered when focus moves outside
* the element the event handlers are bound to.
*
* @return An object containing event handlers. Bind the event handlers to a
* wrapping element element to capture when focus moves outside that element.
*/
export default function useFocusOutside(
onFocusOutside: ( ( event: React.FocusEvent ) => void ) | undefined
): UseFocusOutsideReturn {
const currentOnFocusOutside = useRef( onFocusOutside );
useEffect( () => {
currentOnFocusOutside.current = onFocusOutside;
}, [ onFocusOutside ] );
const preventBlurCheck = useRef( false );
const blurCheckTimeoutId = useRef< number | undefined >();
/**
* Cancel a blur check timeout.
*/
const cancelBlurCheck = useCallback( () => {
clearTimeout( blurCheckTimeoutId.current );
}, [] );
// Cancel blur checks on unmount.
useEffect( () => {
return () => cancelBlurCheck();
}, [] );
// Cancel a blur check if the callback or ref is no longer provided.
useEffect( () => {
if ( ! onFocusOutside ) {
cancelBlurCheck();
}
}, [ onFocusOutside, cancelBlurCheck ] );
/**
* Handles a mousedown or mouseup event to respectively assign and
* unassign a flag for preventing blur check on button elements. Some
* browsers, namely Firefox and Safari, do not emit a focus event on
* button elements when clicked, while others do. The logic here
* intends to normalize this as treating click on buttons as focus.
*
* @param event
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*/
const normalizeButtonFocus: React.EventHandler<
React.MouseEvent | React.TouchEvent
> = useCallback( ( event ) => {
const { type, target } = event;
const isInteractionEnd = [ 'mouseup', 'touchend' ].includes( type );
if ( isInteractionEnd ) {
preventBlurCheck.current = false;
} else if ( isFocusNormalizedButton( target ) ) {
preventBlurCheck.current = true;
}
}, [] );
/**
* A callback triggered when a blur event occurs on the element the handler
* is bound to.
*
* Calls the `onFocusOutside` callback in an immediate timeout if focus has
* move outside the bound element and is still within the document.
*/
const queueBlurCheck: React.FocusEventHandler = useCallback( ( event ) => {
// React does not allow using an event reference asynchronously
// due to recycling behavior, except when explicitly persisted.
event.persist();
// Skip blur check if clicking button. See `normalizeButtonFocus`.
if ( preventBlurCheck.current ) {
return;
}
// The usage of this attribute should be avoided. The only use case
// would be when we load modals that are not React components and
// therefore don't exist in the React tree. An example is opening
// the Media Library modal from another dialog.
// This attribute should contain a selector of the related target
// we want to ignore, because we still need to trigger the blur event
// on all other cases.
const ignoreForRelatedTarget = event.target.getAttribute(
'data-unstable-ignore-focus-outside-for-relatedtarget'
);
if (
ignoreForRelatedTarget &&
event.relatedTarget?.closest( ignoreForRelatedTarget )
) {
return;
}
blurCheckTimeoutId.current = setTimeout( () => {
// If document is not focused then focus should remain
// inside the wrapped component and therefore we cancel
// this blur event thereby leaving focus in place.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
if ( ! document.hasFocus() ) {
event.preventDefault();
return;
}
if ( 'function' === typeof currentOnFocusOutside.current ) {
currentOnFocusOutside.current( event );
}
}, 0 );
}, [] );
return {
onFocus: cancelBlurCheck,
onMouseDown: normalizeButtonFocus,
onMouseUp: normalizeButtonFocus,
onTouchStart: normalizeButtonFocus,
onTouchEnd: normalizeButtonFocus,
onBlur: queueBlurCheck,
};
}

View File

@@ -0,0 +1,140 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import useFocusOutside from '../';
const FocusOutsideComponent = ( { onFocusOutside: callback } ) => (
<div>
{ /* Wrapper */ }
<div { ...useFocusOutside( callback ) }>
<input type="text" />
<button>Button inside the wrapper</button>
<iframe title="test-iframe">
<button>Inside the iframe</button>
</iframe>
</div>
<button>Button outside the wrapper</button>
</div>
);
describe( 'useFocusOutside', () => {
it( 'should not call handler if focus shifts to element within component', async () => {
const mockOnFocusOutside = jest.fn();
const user = userEvent.setup();
render(
<FocusOutsideComponent onFocusOutside={ mockOnFocusOutside } />
);
// Tab through the interactive elements inside the wrapper,
// causing multiple focus/blur events.
await user.tab();
expect( screen.getByRole( 'textbox' ) ).toHaveFocus();
await user.tab();
expect(
screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
).toHaveFocus();
expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
it( 'should not call handler if focus transitions via click to button', async () => {
const mockOnFocusOutside = jest.fn();
const user = userEvent.setup();
render(
<FocusOutsideComponent onFocusOutside={ mockOnFocusOutside } />
);
// Click the input and the button, causing multiple focus/blur events.
await user.click( screen.getByRole( 'textbox' ) );
await user.click(
screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
);
expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
it( 'should call handler if focus shifts to element outside component', async () => {
const mockOnFocusOutside = jest.fn();
const user = userEvent.setup();
render(
<FocusOutsideComponent onFocusOutside={ mockOnFocusOutside } />
);
// Click and focus button inside the wrapper
await user.click(
screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
);
expect( mockOnFocusOutside ).not.toHaveBeenCalled();
// Click and focus button outside the wrapper
await user.click(
screen.getByRole( 'button', { name: 'Button outside the wrapper' } )
);
expect( mockOnFocusOutside ).toHaveBeenCalled();
} );
it( 'should not call handler if focus shifts outside the component when the document does not have focus', async () => {
// Force document.hasFocus() to return false to simulate the window/document losing focus
// See https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
const mockedDocumentHasFocus = jest
.spyOn( document, 'hasFocus' )
.mockImplementation( () => false );
const mockOnFocusOutside = jest.fn();
const user = userEvent.setup();
render(
<FocusOutsideComponent onFocusOutside={ mockOnFocusOutside } />
);
// Click and focus button inside the wrapper, then click and focus
// a button outside the wrapper.
await user.click(
screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
);
await user.click(
screen.getByRole( 'button', { name: 'Button outside the wrapper' } )
);
// The handler is not called thanks to the mocked return value of
// `document.hasFocus()`
expect( mockOnFocusOutside ).not.toHaveBeenCalled();
// Restore the `document.hasFocus()` function to its original implementation.
mockedDocumentHasFocus.mockRestore();
} );
it( 'should cancel check when unmounting while queued', async () => {
const mockOnFocusOutside = jest.fn();
const user = userEvent.setup();
const { unmount } = render(
<FocusOutsideComponent onFocusOutside={ mockOnFocusOutside } />
);
// Click and focus button inside the wrapper.
const button = screen.getByRole( 'button', {
name: 'Button inside the wrapper',
} );
await user.click( button );
// Simulate a blur event and the wrapper unmounting while the blur event
// handler is queued
button.blur();
unmount();
expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
} );

View File

@@ -0,0 +1,27 @@
# `useFocusReturn`
When opening modals/sidebars/dialogs, the focus must move to the opened area and return to the previously focused element when closed. The current hook implements the returning behavior.
## Return Object Properties
### `ref`
- Type: `Function`
A function reference that must be passed to the DOM element being mounted and which needs to return the focus to its original position when unmounted.
## Usage
```jsx
import { useFocusReturn } from '@wordpress/compose';
const WithFocusReturn = () => {
const ref = useFocusReturn();
return (
<div ref={ ref }>
<Button />
<Button />
</div>
);
};
```

View File

@@ -0,0 +1,80 @@
/**
* WordPress dependencies
*/
import { useRef, useEffect, useCallback } from '@wordpress/element';
/** @type {Element|null} */
let origin = null;
/**
* Adds the unmount behavior of returning focus to the element which had it
* previously as is expected for roles like menus or dialogs.
*
* @param {() => void} [onFocusReturn] Overrides the default return behavior.
* @return {import('react').RefCallback<HTMLElement>} Element Ref.
*
* @example
* ```js
* import { useFocusReturn } from '@wordpress/compose';
*
* const WithFocusReturn = () => {
* const ref = useFocusReturn()
* return (
* <div ref={ ref }>
* <Button />
* <Button />
* </div>
* );
* }
* ```
*/
function useFocusReturn( onFocusReturn ) {
/** @type {import('react').MutableRefObject<null | HTMLElement>} */
const ref = useRef( null );
/** @type {import('react').MutableRefObject<null | Element>} */
const focusedBeforeMount = useRef( null );
const onFocusReturnRef = useRef( onFocusReturn );
useEffect( () => {
onFocusReturnRef.current = onFocusReturn;
}, [ onFocusReturn ] );
return useCallback( ( node ) => {
if ( node ) {
// Set ref to be used when unmounting.
ref.current = node;
// Only set when the node mounts.
if ( focusedBeforeMount.current ) {
return;
}
focusedBeforeMount.current = node.ownerDocument.activeElement;
} else if ( focusedBeforeMount.current ) {
const isFocused = ref.current?.contains(
ref.current?.ownerDocument.activeElement
);
if ( ref.current?.isConnected && ! isFocused ) {
origin ??= focusedBeforeMount.current;
return;
}
// Defer to the component's own explicit focus return behavior, if
// specified. This allows for support that the `onFocusReturn`
// decides to allow the default behavior to occur under some
// conditions.
if ( onFocusReturnRef.current ) {
onFocusReturnRef.current();
} else {
/** @type {null|HTMLElement} */ (
! focusedBeforeMount.current.isConnected
? origin
: focusedBeforeMount.current
)?.focus();
}
origin = null;
}
}, [] );
}
export default useFocusReturn;

View File

@@ -0,0 +1,29 @@
# useFocusableIframe
By default, it is not possible to detect when an iframe is focused or clicked
within. This hook uses a technique which checks whether the target of a window
`blur` event is the iframe, inferring that this has resulted in the focus of the
iframe. It dispatches an emulated
[`FocusEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent) on
the iframe element with event bubbling, so a parent component binding its own
`onFocus` event will account for focus transitioning within the iframe.
## Usage
Use with an `iframe`. You may pass `onFocus` directly as the callback to be
invoked when the iframe receives focus, or on an ancestor component since the
event will bubble.
```jsx
import { useFocusableIframe } from '@wordpress/compose';
const MyFocusableIframe = () => {
return(
<iframe
ref={ useFocusableIframe() }
src="/my-iframe-url"
onFocus={ () => console.log( 'iframe is focused' ) }
/>
);
};
```

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import type { RefCallback } from 'react';
/**
* Internal dependencies
*/
import useRefEffect from '../use-ref-effect';
/**
* Dispatches a bubbling focus event when the iframe receives focus. Use
* `onFocus` as usual on the iframe or a parent element.
*
* @return Ref to pass to the iframe.
*/
export default function useFocusableIframe(): RefCallback< HTMLIFrameElement > {
return useRefEffect( ( element ) => {
const { ownerDocument } = element;
if ( ! ownerDocument ) {
return;
}
const { defaultView } = ownerDocument;
if ( ! defaultView ) {
return;
}
/**
* Checks whether the iframe is the activeElement, inferring that it has
* then received focus, and dispatches a focus event.
*/
function checkFocus() {
if ( ownerDocument && ownerDocument.activeElement === element ) {
( element as HTMLElement ).focus();
}
}
defaultView.addEventListener( 'blur', checkFocus );
return () => {
defaultView.removeEventListener( 'blur', checkFocus );
};
}, [] );
}

View File

@@ -0,0 +1,17 @@
# useInstanceId
Some components need to generate a unique id for each instance. This could serve as suffixes to element ID's for example. `useInstanceId` provides a unique `instanceId` to serve this purpose.
## Usage
```jsx
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
function MyCustomElement() {
const instanceId = useInstanceId( MyCustomElement );
return <div id={ `my-custom-element-${ instanceId }` }>content</div>;
}
```

View File

@@ -0,0 +1,63 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
const instanceMap = new WeakMap< object, number >();
/**
* Creates a new id for a given object.
*
* @param object Object reference to create an id for.
* @return The instance id (index).
*/
function createId( object: object ): number {
const instances = instanceMap.get( object ) || 0;
instanceMap.set( object, instances + 1 );
return instances;
}
/**
* Specify the useInstanceId *function* signatures.
*
* More accurately, useInstanceId distinguishes between three different
* signatures:
*
* 1. When only object is given, the returned value is a number
* 2. When object and prefix is given, the returned value is a string
* 3. When preferredId is given, the returned value is the type of preferredId
*
* @param object Object reference to create an id for.
*/
function useInstanceId( object: object ): number;
function useInstanceId( object: object, prefix: string ): string;
function useInstanceId< T extends string | number >(
object: object,
prefix: string,
preferredId?: T
): T;
/**
* Provides a unique instance ID.
*
* @param object Object reference to create an id for.
* @param [prefix] Prefix for the unique id.
* @param [preferredId] Default ID to use.
* @return The unique instance id.
*/
function useInstanceId(
object: object,
prefix?: string,
preferredId?: string | number
): string | number {
return useMemo( () => {
if ( preferredId ) {
return preferredId;
}
const id = createId( object );
return prefix ? `${ prefix }-${ id }` : id;
}, [ object, preferredId, prefix ] );
}
export default useInstanceId;

View File

@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import useInstanceId from '../';
describe( 'useInstanceId', () => {
const TestComponent = () => {
return useInstanceId( TestComponent );
};
it( 'should manage ids', async () => {
const { container, rerender } = render( <TestComponent /> );
expect( container ).toHaveTextContent( '0' );
rerender(
<div>
<TestComponent />
</div>
);
expect( container ).toHaveTextContent( '1' );
} );
} );

View File

@@ -0,0 +1,14 @@
/**
* WordPress dependencies
*/
import { useEffect, useLayoutEffect } from '@wordpress/element';
/**
* Preferred over direct usage of `useLayoutEffect` when supporting
* server rendered components (SSR) because currently React
* throws a warning when using useLayoutEffect in that environment.
*/
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export default useIsomorphicLayoutEffect;

View File

@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import Mousetrap from 'mousetrap';
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
/**
* WordPress dependencies
*/
import { useEffect, useRef } from '@wordpress/element';
import { isAppleOS } from '@wordpress/keycodes';
/**
* A block selection object.
*
* @typedef {Object} WPKeyboardShortcutConfig
*
* @property {boolean} [bindGlobal] Handle keyboard events anywhere including inside textarea/input fields.
* @property {string} [eventName] Event name used to trigger the handler, defaults to keydown.
* @property {boolean} [isDisabled] Disables the keyboard handler if the value is true.
* @property {import('react').RefObject<HTMLElement>} [target] React reference to the DOM element used to catch the keyboard event.
*/
/* eslint-disable jsdoc/valid-types */
/**
* Attach a keyboard shortcut handler.
*
* @see https://craig.is/killing/mice#api.bind for information about the `callback` parameter.
*
* @param {string[]|string} shortcuts Keyboard Shortcuts.
* @param {(e: import('mousetrap').ExtendedKeyboardEvent, combo: string) => void} callback Shortcut callback.
* @param {WPKeyboardShortcutConfig} options Shortcut options.
*/
function useKeyboardShortcut(
/* eslint-enable jsdoc/valid-types */
shortcuts,
callback,
{
bindGlobal = false,
eventName = 'keydown',
isDisabled = false, // This is important for performance considerations.
target,
} = {}
) {
const currentCallback = useRef( callback );
useEffect( () => {
currentCallback.current = callback;
}, [ callback ] );
useEffect( () => {
if ( isDisabled ) {
return;
}
const mousetrap = new Mousetrap(
target && target.current
? target.current
: // We were passing `document` here previously, so to successfully cast it to Element we must cast it first to `unknown`.
// Not sure if this is a mistake but it was the behavior previous to the addition of types so we're just doing what's
// necessary to maintain the existing behavior.
/** @type {Element} */ ( /** @type {unknown} */ ( document ) )
);
const shortcutsArray = Array.isArray( shortcuts )
? shortcuts
: [ shortcuts ];
shortcutsArray.forEach( ( shortcut ) => {
const keys = shortcut.split( '+' );
// Determines whether a key is a modifier by the length of the string.
// E.g. if I add a pass a shortcut Shift+Cmd+M, it'll determine that
// the modifiers are Shift and Cmd because they're not a single character.
const modifiers = new Set(
keys.filter( ( value ) => value.length > 1 )
);
const hasAlt = modifiers.has( 'alt' );
const hasShift = modifiers.has( 'shift' );
// This should be better moved to the shortcut registration instead.
if (
isAppleOS() &&
( ( modifiers.size === 1 && hasAlt ) ||
( modifiers.size === 2 && hasAlt && hasShift ) )
) {
throw new Error(
`Cannot bind ${ shortcut }. Alt and Shift+Alt modifiers are reserved for character input.`
);
}
const bindFn = bindGlobal ? 'bindGlobal' : 'bind';
// @ts-ignore `bindGlobal` is an undocumented property
mousetrap[ bindFn ](
shortcut,
(
/* eslint-disable jsdoc/valid-types */
/** @type {[e: import('mousetrap').ExtendedKeyboardEvent, combo: string]} */ ...args
) =>
/* eslint-enable jsdoc/valid-types */
currentCallback.current( ...args ),
eventName
);
} );
return () => {
mousetrap.reset();
};
}, [ shortcuts, bindGlobal, eventName, target, isDisabled ] );
}
export default useKeyboardShortcut;

View File

@@ -0,0 +1,2 @@
const useKeyboardShortcut = () => null;
export default useKeyboardShortcut;

View File

@@ -0,0 +1,74 @@
/**
* WordPress dependencies
*/
import { useMemo, useSyncExternalStore } from '@wordpress/element';
const matchMediaCache = new Map();
/**
* A new MediaQueryList object for the media query
*
* @param {string} [query] Media Query.
* @return {MediaQueryList|null} A new object for the media query
*/
function getMediaQueryList( query ) {
if ( ! query ) {
return null;
}
let match = matchMediaCache.get( query );
if ( match ) {
return match;
}
if (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function'
) {
match = window.matchMedia( query );
matchMediaCache.set( query, match );
return match;
}
return null;
}
/**
* Runs a media query and returns its value when it changes.
*
* @param {string} [query] Media Query.
* @return {boolean} return value of the media query.
*/
export default function useMediaQuery( query ) {
const source = useMemo( () => {
const mediaQueryList = getMediaQueryList( query );
return {
/** @type {(onStoreChange: () => void) => () => void} */
subscribe( onStoreChange ) {
if ( ! mediaQueryList ) {
return () => {};
}
// Avoid a fatal error when browsers don't support `addEventListener` on MediaQueryList.
mediaQueryList.addEventListener?.( 'change', onStoreChange );
return () => {
mediaQueryList.removeEventListener?.(
'change',
onStoreChange
);
};
},
getValue() {
return mediaQueryList?.matches ?? false;
},
};
}, [ query ] );
return useSyncExternalStore(
source.subscribe,
source.getValue,
() => false
);
}

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { act, render } from '@testing-library/react';
import { matchMedia, setMedia } from 'mock-match-media';
/**
* Internal dependencies
*/
import useMediaQuery from '../';
const TestComponent = ( { query } ) => {
const queryResult = useMediaQuery( query );
return `useMediaQuery: ${ queryResult }`;
};
describe( 'useMediaQuery', () => {
beforeAll( () => {
window.matchMedia = matchMedia;
} );
beforeEach( () => {
setMedia( {
width: '960px',
} );
} );
afterEach( () => {
// Do not clean up, this will break our cache. Browsers also do not
// reset media queries.
} );
it( 'should return true when the query matches', async () => {
const { container } = render(
<TestComponent query="(min-width: 782px)" />
);
expect( container ).toHaveTextContent( 'useMediaQuery: true' );
} );
it( 'should correctly update the value when the query evaluation matches', async () => {
const { container } = render(
<TestComponent query="(min-width: 782px)" />
);
expect( container ).toHaveTextContent( 'useMediaQuery: true' );
act( () => {
setMedia( {
width: '600px',
} );
} );
expect( container ).toHaveTextContent( 'useMediaQuery: false' );
} );
it( 'should return false when the query does not match', async () => {
const { container } = render(
<TestComponent query="(max-width: 782px)" />
);
expect( container ).toHaveTextContent( 'useMediaQuery: false' );
} );
it( 'should return false when a query is not passed', async () => {
const { container, rerender } = render( <TestComponent /> );
// Query will be case to a boolean to simplify the return type.
expect( container ).toHaveTextContent( 'useMediaQuery: false' );
rerender( <TestComponent query={ false } /> );
expect( container ).toHaveTextContent( 'useMediaQuery: false' );
} );
} );

View File

@@ -0,0 +1,131 @@
/**
* WordPress dependencies
*/
import { useRef, useCallback, useLayoutEffect } from '@wordpress/element';
/* eslint-disable jsdoc/valid-types */
/**
* @template T
* @typedef {T extends import('react').Ref<infer R> ? R : never} TypeFromRef
*/
/* eslint-enable jsdoc/valid-types */
/**
* @template T
* @param {import('react').Ref<T>} ref
* @param {T} value
*/
function assignRef( ref, value ) {
if ( typeof ref === 'function' ) {
ref( value );
} else if ( ref && ref.hasOwnProperty( 'current' ) ) {
/* eslint-disable jsdoc/no-undefined-types */
/** @type {import('react').MutableRefObject<T>} */ ( ref ).current =
value;
/* eslint-enable jsdoc/no-undefined-types */
}
}
/**
* Merges refs into one ref callback.
*
* It also ensures that the merged ref callbacks are only called when they
* change (as a result of a `useCallback` dependency update) OR when the ref
* value changes, just as React does when passing a single ref callback to the
* component.
*
* As expected, if you pass a new function on every render, the ref callback
* will be called after every render.
*
* If you don't wish a ref callback to be called after every render, wrap it
* with `useCallback( callback, dependencies )`. When a dependency changes, the
* old ref callback will be called with `null` and the new ref callback will be
* called with the same value.
*
* To make ref callbacks easier to use, you can also pass the result of
* `useRefEffect`, which makes cleanup easier by allowing you to return a
* cleanup function instead of handling `null`.
*
* It's also possible to _disable_ a ref (and its behaviour) by simply not
* passing the ref.
*
* ```jsx
* const ref = useRefEffect( ( node ) => {
* node.addEventListener( ... );
* return () => {
* node.removeEventListener( ... );
* };
* }, [ ...dependencies ] );
* const otherRef = useRef();
* const mergedRefs useMergeRefs( [
* enabled && ref,
* otherRef,
* ] );
* return <div ref={ mergedRefs } />;
* ```
*
* @template {import('react').Ref<any>} TRef
* @param {Array<TRef>} refs The refs to be merged.
*
* @return {import('react').RefCallback<TypeFromRef<TRef>>} The merged ref callback.
*/
export default function useMergeRefs( refs ) {
const element = useRef();
const isAttached = useRef( false );
const didElementChange = useRef( false );
/* eslint-disable jsdoc/no-undefined-types */
/** @type {import('react').MutableRefObject<TRef[]>} */
/* eslint-enable jsdoc/no-undefined-types */
const previousRefs = useRef( [] );
const currentRefs = useRef( refs );
// Update on render before the ref callback is called, so the ref callback
// always has access to the current refs.
currentRefs.current = refs;
// If any of the refs change, call the previous ref with `null` and the new
// ref with the node, except when the element changes in the same cycle, in
// which case the ref callbacks will already have been called.
useLayoutEffect( () => {
if (
didElementChange.current === false &&
isAttached.current === true
) {
refs.forEach( ( ref, index ) => {
const previousRef = previousRefs.current[ index ];
if ( ref !== previousRef ) {
assignRef( previousRef, null );
assignRef( ref, element.current );
}
} );
}
previousRefs.current = refs;
}, refs );
// No dependencies, must be reset after every render so ref callbacks are
// correctly called after a ref change.
useLayoutEffect( () => {
didElementChange.current = false;
} );
// There should be no dependencies so that `callback` is only called when
// the node changes.
return useCallback( ( value ) => {
// Update the element so it can be used when calling ref callbacks on a
// dependency change.
assignRef( element, value );
didElementChange.current = true;
isAttached.current = value !== null;
// When an element changes, the current ref callback should be called
// with the new element and the previous one with `null`.
const refsToAssign = value ? currentRefs.current : previousRefs.current;
// Update the latest refs.
for ( const ref of refsToAssign ) {
assignRef( ref, value );
}
}, [] );
}

View File

@@ -0,0 +1,347 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import useMergeRefs from '../';
describe( 'useMergeRefs', () => {
// Setup
// =====
//
// A component with two merged ref callbacks. The second has a dependency,
// the first does not. We expect the one with the dependency to be called
// with null in the old function and the node in the new function. We don't
// expect the first ref callback to be called **unless** the node changes.
// There's a prop controlling the tag name, which can be used to trigger a
// node change. In that case we expect both ref callbacks to be called with
// null in the old function and the **new** node in the new function.
//
// The history of all functions is recorded. Note that a new function is
// created on every render, which will all be tracked. Some functions are
// never expected to be called on subsequent renders if no callback
// dependency updates!
function renderCallback( args ) {
renderCallback.history.push( args );
}
renderCallback.history = [];
function MergedRefs( {
count,
tagName: TagName = 'ul',
disable1,
disable2,
unused,
} ) {
function refCallback1( value ) {
refCallback1.history.push( value );
}
refCallback1.history = [];
function refCallback2( value ) {
refCallback2.history.push( value );
}
refCallback2.history = [];
renderCallback( [ refCallback1.history, refCallback2.history ] );
const ref1 = useCallback( refCallback1, [] );
const ref2 = useCallback( refCallback2, [ count ] );
const mergedRefs = useMergeRefs( [
! disable1 && ref1,
! disable2 && ref2,
] );
if ( unused ) {
return <TagName ref={ ref1 } />;
}
return <TagName ref={ mergedRefs } />;
}
afterEach( () => {
// Reset all history.
renderCallback.history = [];
} );
it( 'should work', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
// Render 1: both initial callback functions should be called with node.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs /> );
// Render 2: the new callback functions should not be called! There has
// been no dependency change.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
[ [], [] ],
] );
unmount();
// Unmount: the initial callback functions should receive null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [] ],
] );
} );
it( 'should work for node change', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// After a render with the original element and a second render with the
// new element, expect the initial callback functions to be called with
// the original element, then null, then the new element.
// Again, the new callback functions should not be called! There has
// been no dependency change.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null, newElement ],
],
[ [], [] ],
] );
unmount();
// Unmount: the initial callback functions should receive null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement, null ],
[ originalElement, null, newElement, null ],
],
[ [], [] ],
] );
} );
it( 'should work with dependencies', () => {
const { rerender, unmount } = render( <MergedRefs count={ 1 } /> );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs count={ 2 } /> );
// After a second render with a dependency change, expect the inital
// callback function to be called with null and the new callback
// function to be called with the original node. Note that for callback
// one no dependencies have changed.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement, null ] ],
[ [], [ originalElement ] ],
] );
unmount();
// Unmount: current callback functions should be called with null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [ originalElement, null ] ],
] );
} );
it( 'should simultaneously update node and dependencies', () => {
const { rerender, unmount } = render( <MergedRefs count={ 1 } /> );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs count={ 2 } tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// Both the node changes and the dependencies update for the second
// callback, so expect the old callback function to be called with null
// and the new callback function to be called with the **new** node.
// For the first callback, we expect the initial function to be called
// with null and then the new node since no dependencies have changed.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null ],
],
[ [], [ newElement ] ],
] );
unmount();
// Unmount: current callback functions should be called with null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement, null ],
[ originalElement, null ],
],
[ [], [ newElement, null ] ],
] );
} );
it( 'should work for dependency change after node change', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// After a render with the original element and a second render with the
// new element, expect the initial callback functions to be called with
// the original element, then null, then the new element.
// Again, the new callback functions should not be called! There has
// been no dependency change.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null, newElement ],
],
[ [], [] ],
] );
rerender( <MergedRefs tagName="button" count={ 1 } /> );
// After a third render with a dependency change, expect the inital
// callback function to be called with null and the new callback
// function to be called with the new element. Note that for callback
// one no dependencies have changed.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null, newElement, null ],
],
[ [], [] ],
[ [], [ newElement ] ],
] );
unmount();
// Unmount: current callback functions should be called with null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement, null ],
[ originalElement, null, newElement, null ],
],
[ [], [] ],
[ [], [ newElement, null ] ],
] );
} );
it( 'should allow disabling a ref', () => {
const { rerender, unmount } = render( <MergedRefs disable1 /> );
const originalElement = screen.getByRole( 'list' );
// Render 1: ref 1 should be disabled.
expect( renderCallback.history ).toEqual( [
[ [], [ originalElement ] ],
] );
rerender( <MergedRefs disable2 /> );
// Render 2: ref 1 should be enabled and receive the ref. Note that the
// callback hasn't changed, so the original callback function will be
// called. Ref 2 should be disabled, so called with null.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement, null ] ],
[ [], [] ],
] );
rerender( <MergedRefs disable1 count={ 1 } /> );
// Render 3: ref 1 should again be disabled. Ref 2 to should receive a
// ref with the new callback function because the count has been
// changed.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [] ],
[ [], [ originalElement ] ],
] );
unmount();
// Unmount: current callback functions should receive null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [] ],
[ [], [ originalElement, null ] ],
] );
} );
it( 'should allow the hook being unused', () => {
const { rerender } = render( <MergedRefs unused /> );
const originalElement = screen.getByRole( 'list' );
// Render 1: ref 1 should updated, ref 2 should not.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [] ],
] );
rerender( <MergedRefs /> );
// Render 2: ref 2 should be updated as well.
expect( renderCallback.history ).toEqual( [
[ [ originalElement, null, originalElement ], [ originalElement ] ],
[ [], [] ],
] );
rerender( <MergedRefs unused /> );
// Render 3: ref 2 should be updated with null
expect( renderCallback.history ).toEqual( [
[
[
originalElement,
null,
originalElement,
null,
originalElement,
],
[ originalElement, null ],
],
[ [], [] ],
[ [], [] ],
] );
} );
} );

View File

@@ -0,0 +1,59 @@
/**
* WordPress dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import {
requestConnectionStatus,
subscribeConnectionStatus,
} from '@wordpress/react-native-bridge';
/**
* @typedef {Object} NetworkInformation
*
* @property {boolean} [isConnected] Whether the device is connected to a network.
*/
/**
* Returns the current network connectivity status provided by the native bridge.
*
* @example
*
* ```jsx
* const { isConnected } = useNetworkConnectivity();
* ```
*
* @return {NetworkInformation} Network information.
*/
export default function useNetworkConnectivity() {
const [ isConnected, setIsConnected ] = useState( true );
useEffect( () => {
let isCurrent = true;
requestConnectionStatus( ( isBridgeConnected ) => {
if ( ! isCurrent ) {
return;
}
setIsConnected( isBridgeConnected );
} );
return () => {
isCurrent = false;
};
}, [] );
useEffect( () => {
const subscription = subscribeConnectionStatus(
( { isConnected: isBridgeConnected } ) => {
setIsConnected( isBridgeConnected );
}
);
return () => {
subscription.remove();
};
}, [] );
return { isConnected };
}

View File

@@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { act, renderHook } from 'test/helpers';
/**
* WordPress dependencies
*/
import {
requestConnectionStatus,
subscribeConnectionStatus,
} from '@wordpress/react-native-bridge';
/**
* Internal dependencies
*/
import useNetworkConnectivity from '../index';
describe( 'useNetworkConnectivity', () => {
it( 'should optimisitically presume network connectivity', () => {
const { result } = renderHook( () => useNetworkConnectivity() );
expect( result.current.isConnected ).toBe( true );
} );
describe( 'when network connectivity is available', () => {
beforeAll( () => {
requestConnectionStatus.mockImplementation( ( callback ) => {
callback( true );
return { remove: jest.fn() };
} );
} );
it( 'should return true', () => {
const { result } = renderHook( () => useNetworkConnectivity() );
expect( result.current.isConnected ).toBe( true );
} );
it( 'should update the status when network connectivity changes', () => {
let subscriptionCallback;
subscribeConnectionStatus.mockImplementation( ( callback ) => {
subscriptionCallback = callback;
return { remove: jest.fn() };
} );
const { result } = renderHook( () => useNetworkConnectivity() );
expect( result.current.isConnected ).toBe( true );
act( () => subscriptionCallback( { isConnected: false } ) );
expect( result.current.isConnected ).toBe( false );
} );
} );
describe( 'when network connectivity is unavailable', () => {
beforeAll( () => {
requestConnectionStatus.mockImplementation( ( callback ) => {
callback( false );
return { remove: jest.fn() };
} );
} );
it( 'should return false', () => {
const { result } = renderHook( () => useNetworkConnectivity() );
expect( result.current.isConnected ).toBe( false );
} );
it( 'should update the status when network connectivity changes', () => {
let subscriptionCallback;
subscribeConnectionStatus.mockImplementation( ( callback ) => {
subscriptionCallback = callback;
return { remove: jest.fn() };
} );
const { result } = renderHook( () => useNetworkConnectivity() );
expect( result.current.isConnected ).toBe( false );
act( () => subscriptionCallback( { isConnected: true } ) );
expect( result.current.isConnected ).toBe( true );
} );
} );
} );

View File

@@ -0,0 +1,35 @@
/**
* WordPress dependencies
*/
import { useMemo, useSyncExternalStore } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { ObservableMap } from '../../utils/observable-map';
/**
* React hook that lets you observe an entry in an `ObservableMap`. The hook returns the
* current value corresponding to the key, or `undefined` when there is no value stored.
* It also observes changes to the value and triggers an update of the calling component
* in case the value changes.
*
* @template K The type of the keys in the map.
* @template V The type of the values in the map.
* @param map The `ObservableMap` to observe.
* @param name The map key to observe.
* @return The value corresponding to the map key requested.
*/
export default function useObservableValue< K, V >(
map: ObservableMap< K, V >,
name: K
): V | undefined {
const [ subscribe, getValue ] = useMemo(
() => [
( listener: () => void ) => map.subscribe( name, listener ),
() => map.get( name ),
],
[ map, name ]
);
return useSyncExternalStore( subscribe, getValue, getValue );
}

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { render, screen, act } from '@testing-library/react';
/**
* Internal dependencies
*/
import { observableMap } from '../../../utils/observable-map';
import useObservableValue from '..';
describe( 'useObservableValue', () => {
test( 'reacts only to the specified key', () => {
const map = observableMap();
map.set( 'a', 1 );
const MapUI = jest.fn( () => {
const value = useObservableValue( map, 'a' );
return <div>value is { value }</div>;
} );
render( <MapUI /> );
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
'value is 1'
);
expect( MapUI ).toHaveBeenCalledTimes( 1 );
act( () => {
map.set( 'a', 2 );
} );
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
'value is 2'
);
expect( MapUI ).toHaveBeenCalledTimes( 2 );
// check that setting unobserved map key doesn't trigger a render at all
act( () => {
map.set( 'b', 1 );
} );
expect( MapUI ).toHaveBeenCalledTimes( 2 );
} );
} );

View File

@@ -0,0 +1,33 @@
/**
* Internal dependencies
*/
import usePreferredColorScheme from '../use-preferred-color-scheme';
/**
* Selects which of the passed style objects should be applied depending on the
* user's preferred color scheme.
*
* The "light" color schemed is assumed to be the default, and its styles are
* always applied. The "dark" styles will always extend those defined for the
* light case.
*
* @example
* const light = { padding: 10, backgroundColor: 'white' };
* const dark = { backgroundColor: 'black' };
* usePreferredColorSchemeStyle( light, dark);
* // On light mode:
* // => { padding: 10, backgroundColor: 'white' }
* // On dark mode:
* // => { padding: 10, backgroundColor: 'black' }
* @param {Object} lightStyle
* @param {Object} darkStyle
* @return {Object} the combined styles depending on the current color scheme
*/
const usePreferredColorSchemeStyle = ( lightStyle, darkStyle ) => {
const colorScheme = usePreferredColorScheme();
const isDarkMode = colorScheme === 'dark';
return isDarkMode ? { ...lightStyle, ...darkStyle } : lightStyle;
};
export default usePreferredColorSchemeStyle;

View File

@@ -0,0 +1,30 @@
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import {
subscribePreferredColorScheme,
isInitialColorSchemeDark,
} from '@wordpress/react-native-bridge';
/**
* Returns the color scheme value when it changes. Possible values: [ 'light', 'dark' ]
*
* @return {string} return current color scheme.
*/
function usePreferredColorScheme() {
const [ currentColorScheme, setCurrentColorScheme ] = useState(
isInitialColorSchemeDark ? 'dark' : 'light'
);
useEffect( () => {
subscribePreferredColorScheme( ( { isPreferredColorSchemeDark } ) => {
const colorScheme = isPreferredColorSchemeDark ? 'dark' : 'light';
if ( colorScheme !== currentColorScheme ) {
setCurrentColorScheme( colorScheme );
}
} );
} );
return currentColorScheme;
}
export default usePreferredColorScheme;

View File

@@ -0,0 +1,13 @@
/**
* External dependencies
*/
import { useColorScheme } from 'react-native';
/**
* Returns the color scheme value when it changes. Possible values: [ 'light', 'dark' ]
*
* @return {string} return current color scheme.
*/
const usePreferredColorScheme = useColorScheme;
export default usePreferredColorScheme;

View File

@@ -0,0 +1,36 @@
# usePrevious
Sometimes you need to get the value something had on the previous render. `usePrevious` tracks the value you pass to it using a ref, and returns the previous render's value.
## Usage
```jsx
/**
* WordPress dependencies
*/
import { useEffect, useState } from 'react';
import { usePrevious } from '@wordpress/compose';
function MyCustomElement() {
const [ myNumber, setMyNumber ] = useState( 5 );
const [ lastChange, setLastChange ] = useState( 'none' );
const prevNumber = usePrevious( myNumber );
useEffect( () => {
// On the first render, prevNumber will be undefined.
if ( prevNumber !== undefined ) {
if ( myNumber > prevNumber ) {
setLastChange( 'up' );
} else if ( myNumber < prevNumber ) {
setLastChange( 'down' );
}
}
}, [ myNumber ] );
return (
<p>
My number is { myNumber }. Last change: { lastChange }
</p>
);
}
```

View File

@@ -0,0 +1,24 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef } from '@wordpress/element';
/**
* Use something's value from the previous render.
* Based on https://usehooks.com/usePrevious/.
*
* @param value The value to track.
*
* @return The value from the previous render.
*/
export default function usePrevious< T >( value: T ): T | undefined {
const ref = useRef< T >();
// Store current value in ref.
useEffect( () => {
ref.current = value;
}, [ value ] ); // Re-run when value changes.
// Return previous value (happens before update in useEffect above).
return ref.current;
}

View File

@@ -0,0 +1,14 @@
/**
* Internal dependencies
*/
import useMediaQuery from '../use-media-query';
/**
* Hook returning whether the user has a preference for reduced motion.
*
* @return {boolean} Reduced motion preference value.
*/
const useReducedMotion = () =>
useMediaQuery( '(prefers-reduced-motion: reduce)' );
export default useReducedMotion;

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import type { DependencyList, RefCallback } from 'react';
/**
* WordPress dependencies
*/
import { useCallback, useRef } from '@wordpress/element';
/**
* Effect-like ref callback. Just like with `useEffect`, this allows you to
* return a cleanup function to be run if the ref changes or one of the
* dependencies changes. The ref is provided as an argument to the callback
* functions. The main difference between this and `useEffect` is that
* the `useEffect` callback is not called when the ref changes, but this is.
* Pass the returned ref callback as the component's ref and merge multiple refs
* with `useMergeRefs`.
*
* It's worth noting that if the dependencies array is empty, there's not
* strictly a need to clean up event handlers for example, because the node is
* to be removed. It *is* necessary if you add dependencies because the ref
* callback will be called multiple times for the same node.
*
* @param callback Callback with ref as argument.
* @param dependencies Dependencies of the callback.
*
* @return Ref callback.
*/
export default function useRefEffect< TElement = Node >(
callback: ( node: TElement ) => ( () => void ) | void,
dependencies: DependencyList
): RefCallback< TElement | null > {
const cleanup = useRef< ( () => void ) | void >();
return useCallback( ( node: TElement | null ) => {
if ( node ) {
cleanup.current = callback( node );
} else if ( cleanup.current ) {
cleanup.current();
}
}, dependencies );
}

View File

@@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { View, StyleSheet } from 'react-native';
/**
* WordPress dependencies
*/
import { useState, useCallback } from '@wordpress/element';
/**
* Hook which allows to listen the resize event of any target element when it changes sizes.
*
* @example
*
* ```js
* const App = () => {
* const [ resizeListener, sizes ] = useResizeObserver();
*
* return (
* <View>
* { resizeListener }
* Your content here
* </View>
* );
* };
* ```
*/
const useResizeObserver = () => {
const [ measurements, setMeasurements ] = useState( null );
const onLayout = useCallback( ( { nativeEvent } ) => {
const { width, height } = nativeEvent.layout;
setMeasurements( ( prevState ) => {
if (
! prevState ||
prevState.width !== width ||
prevState.height !== height
) {
return {
width: Math.floor( width ),
height: Math.floor( height ),
};
}
return prevState;
} );
}, [] );
const observer = (
<View
testID="resize-observer"
style={ StyleSheet.absoluteFill }
onLayout={ onLayout }
/>
);
return [ observer, measurements ];
};
export default useResizeObserver;

View File

@@ -0,0 +1,362 @@
/**
* External dependencies
*/
import type { ReactElement, RefCallback, RefObject } from 'react';
/**
* WordPress dependencies
*/
import {
useMemo,
useRef,
useCallback,
useEffect,
useState,
} from '@wordpress/element';
type SubscriberCleanup = () => void;
type SubscriberResponse = SubscriberCleanup | void;
// This of course could've been more streamlined with internal state instead of
// refs, but then host hooks / components could not opt out of renders.
// This could've been exported to its own module, but the current build doesn't
// seem to work with module imports and I had no more time to spend on this...
function useResolvedElement< T extends HTMLElement >(
subscriber: ( element: T ) => SubscriberResponse,
refOrElement?: T | RefObject< T > | null
): RefCallback< T > {
const callbackRefElement = useRef< T | null >( null );
const lastReportRef = useRef< {
reporter: () => void;
element: T | null;
} | null >( null );
const cleanupRef = useRef< SubscriberResponse | null >();
const callSubscriber = useCallback( () => {
let element = null;
if ( callbackRefElement.current ) {
element = callbackRefElement.current;
} else if ( refOrElement ) {
if ( refOrElement instanceof HTMLElement ) {
element = refOrElement;
} else {
element = refOrElement.current;
}
}
if (
lastReportRef.current &&
lastReportRef.current.element === element &&
lastReportRef.current.reporter === callSubscriber
) {
return;
}
if ( cleanupRef.current ) {
cleanupRef.current();
// Making sure the cleanup is not called accidentally multiple times.
cleanupRef.current = null;
}
lastReportRef.current = {
reporter: callSubscriber,
element,
};
// Only calling the subscriber, if there's an actual element to report.
if ( element ) {
cleanupRef.current = subscriber( element );
}
}, [ refOrElement, subscriber ] );
// On each render, we check whether a ref changed, or if we got a new raw
// element.
useEffect( () => {
// With this we're *technically* supporting cases where ref objects' current value changes, but only if there's a
// render accompanying that change as well.
// To guarantee we always have the right element, one must use the ref callback provided instead, but we support
// RefObjects to make the hook API more convenient in certain cases.
callSubscriber();
}, [ callSubscriber ] );
return useCallback< RefCallback< T > >(
( element ) => {
callbackRefElement.current = element;
callSubscriber();
},
[ callSubscriber ]
);
}
type ObservedSize = {
width: number | undefined;
height: number | undefined;
};
type ResizeHandler = ( size: ObservedSize ) => void;
type HookResponse< T extends HTMLElement > = {
ref: RefCallback< T >;
} & ObservedSize;
// Declaring my own type here instead of using the one provided by TS (available since 4.2.2), because this way I'm not
// forcing consumers to use a specific TS version.
type ResizeObserverBoxOptions =
| 'border-box'
| 'content-box'
| 'device-pixel-content-box';
declare global {
interface ResizeObserverEntry {
readonly devicePixelContentBoxSize: ReadonlyArray< ResizeObserverSize >;
}
}
// We're only using the first element of the size sequences, until future versions of the spec solidify on how
// exactly it'll be used for fragments in multi-column scenarios:
// From the spec:
// > The box size properties are exposed as FrozenArray in order to support elements that have multiple fragments,
// > which occur in multi-column scenarios. However the current definitions of content rect and border box do not
// > mention how those boxes are affected by multi-column layout. In this spec, there will only be a single
// > ResizeObserverSize returned in the FrozenArray, which will correspond to the dimensions of the first column.
// > A future version of this spec will extend the returned FrozenArray to contain the per-fragment size information.
// (https://drafts.csswg.org/resize-observer/#resize-observer-entry-interface)
//
// Also, testing these new box options revealed that in both Chrome and FF everything is returned in the callback,
// regardless of the "box" option.
// The spec states the following on this:
// > This does not have any impact on which box dimensions are returned to the defined callback when the event
// > is fired, it solely defines which box the author wishes to observe layout changes on.
// (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
// I'm not exactly clear on what this means, especially when you consider a later section stating the following:
// > This section is non-normative. An author may desire to observe more than one CSS box.
// > In this case, author will need to use multiple ResizeObservers.
// (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
// Which is clearly not how current browser implementations behave, and seems to contradict the previous quote.
// For this reason I decided to only return the requested size,
// even though it seems we have access to results for all box types.
// This also means that we get to keep the current api, being able to return a simple { width, height } pair,
// regardless of box option.
const extractSize = (
entry: ResizeObserverEntry,
boxProp: 'borderBoxSize' | 'contentBoxSize' | 'devicePixelContentBoxSize',
sizeType: keyof ResizeObserverSize
): number | undefined => {
if ( ! entry[ boxProp ] ) {
if ( boxProp === 'contentBoxSize' ) {
// The dimensions in `contentBoxSize` and `contentRect` are equivalent according to the spec.
// See the 6th step in the description for the RO algorithm:
// https://drafts.csswg.org/resize-observer/#create-and-populate-resizeobserverentry-h
// > Set this.contentRect to logical this.contentBoxSize given target and observedBox of "content-box".
// In real browser implementations of course these objects differ, but the width/height values should be equivalent.
return entry.contentRect[
sizeType === 'inlineSize' ? 'width' : 'height'
];
}
return undefined;
}
// A couple bytes smaller than calling Array.isArray() and just as effective here.
return entry[ boxProp ][ 0 ]
? entry[ boxProp ][ 0 ][ sizeType ]
: // TS complains about this, because the RO entry type follows the spec and does not reflect Firefox's current
// behaviour of returning objects instead of arrays for `borderBoxSize` and `contentBoxSize`.
// @ts-ignore
entry[ boxProp ][ sizeType ];
};
type RoundingFunction = ( n: number ) => number;
function useResizeObserver< T extends HTMLElement >(
opts: {
ref?: RefObject< T > | T | null | undefined;
onResize?: ResizeHandler;
box?: ResizeObserverBoxOptions;
round?: RoundingFunction;
} = {}
): HookResponse< T > {
// Saving the callback as a ref. With this, I don't need to put onResize in the
// effect dep array, and just passing in an anonymous function without memoising
// will not reinstantiate the hook's ResizeObserver.
const onResize = opts.onResize;
const onResizeRef = useRef< ResizeHandler | undefined >( undefined );
onResizeRef.current = onResize;
const round = opts.round || Math.round;
// Using a single instance throughout the hook's lifetime
const resizeObserverRef = useRef< {
box?: ResizeObserverBoxOptions;
round?: RoundingFunction;
instance: ResizeObserver;
} >();
const [ size, setSize ] = useState< {
width?: number;
height?: number;
} >( {
width: undefined,
height: undefined,
} );
// In certain edge cases the RO might want to report a size change just after
// the component unmounted.
const didUnmount = useRef( false );
useEffect( () => {
didUnmount.current = false;
return () => {
didUnmount.current = true;
};
}, [] );
// Using a ref to track the previous width / height to avoid unnecessary renders.
const previous: {
current: {
width?: number;
height?: number;
};
} = useRef( {
width: undefined,
height: undefined,
} );
// This block is kinda like a useEffect, only it's called whenever a new
// element could be resolved based on the ref option. It also has a cleanup
// function.
const refCallback = useResolvedElement< T >(
useCallback(
( element ) => {
// We only use a single Resize Observer instance, and we're instantiating it on demand, only once there's something to observe.
// This instance is also recreated when the `box` option changes, so that a new observation is fired if there was a previously observed element with a different box option.
if (
! resizeObserverRef.current ||
resizeObserverRef.current.box !== opts.box ||
resizeObserverRef.current.round !== round
) {
resizeObserverRef.current = {
box: opts.box,
round,
instance: new ResizeObserver( ( entries ) => {
const entry = entries[ 0 ];
let boxProp:
| 'borderBoxSize'
| 'contentBoxSize'
| 'devicePixelContentBoxSize' = 'borderBoxSize';
if ( opts.box === 'border-box' ) {
boxProp = 'borderBoxSize';
} else {
boxProp =
opts.box === 'device-pixel-content-box'
? 'devicePixelContentBoxSize'
: 'contentBoxSize';
}
const reportedWidth = extractSize(
entry,
boxProp,
'inlineSize'
);
const reportedHeight = extractSize(
entry,
boxProp,
'blockSize'
);
const newWidth = reportedWidth
? round( reportedWidth )
: undefined;
const newHeight = reportedHeight
? round( reportedHeight )
: undefined;
if (
previous.current.width !== newWidth ||
previous.current.height !== newHeight
) {
const newSize = {
width: newWidth,
height: newHeight,
};
previous.current.width = newWidth;
previous.current.height = newHeight;
if ( onResizeRef.current ) {
onResizeRef.current( newSize );
} else if ( ! didUnmount.current ) {
setSize( newSize );
}
}
} ),
};
}
resizeObserverRef.current.instance.observe( element, {
box: opts.box,
} );
return () => {
if ( resizeObserverRef.current ) {
resizeObserverRef.current.instance.unobserve( element );
}
};
},
[ opts.box, round ]
),
opts.ref
);
return useMemo(
() => ( {
ref: refCallback,
width: size.width,
height: size.height,
} ),
[ refCallback, size ? size.width : null, size ? size.height : null ]
);
}
/**
* Hook which allows to listen the resize event of any target element when it changes sizes.
* _Note: `useResizeObserver` will report `null` until after first render.
*
* @example
*
* ```js
* const App = () => {
* const [ resizeListener, sizes ] = useResizeObserver();
*
* return (
* <div>
* { resizeListener }
* Your content here
* </div>
* );
* };
* ```
*/
export default function useResizeAware(): [
ReactElement,
{ width: number | null; height: number | null },
] {
const { ref, width, height } = useResizeObserver();
const sizes = useMemo( () => {
return { width: width ?? null, height: height ?? null };
}, [ width, height ] );
const resizeListener = (
<div
style={ {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
opacity: 0,
overflow: 'hidden',
zIndex: -1,
} }
aria-hidden="true"
ref={ ref }
/>
);
return [ resizeListener, sizes ];
}

View File

@@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { render, fireEvent } from 'test/helpers';
import { View } from 'react-native';
/**
* Internal dependencies
*/
import useResizeObserver from '../';
const TestComponent = ( { onLayout } ) => {
const [ resizeObserver, sizes ] = useResizeObserver();
return (
<View testID="test-component" sizes={ sizes } onLayout={ onLayout }>
{ resizeObserver }
</View>
);
};
describe( 'useResizeObserver()', () => {
it( 'should return "{ width: 300, height: 500 }"', () => {
const mockNativeEvent = {
nativeEvent: {
layout: {
width: 300,
height: 500,
},
},
};
const { getByTestId } = render(
<TestComponent onLayout={ mockNativeEvent } />
);
const resizeObserver = getByTestId( 'resize-observer' );
fireEvent( resizeObserver, 'layout', mockNativeEvent );
const testComponent = getByTestId( 'test-component' );
expect( testComponent.props.sizes ).toMatchObject( {
width: 300,
height: 500,
} );
} );
} );

View File

@@ -0,0 +1,101 @@
/**
* WordPress dependencies
*/
import { createUndoManager } from '@wordpress/undo-manager';
import { useCallback, useReducer } from '@wordpress/element';
import type { UndoManager } from '@wordpress/undo-manager';
type UndoRedoState< T > = {
manager: UndoManager;
value: T;
};
function undoRedoReducer< T >(
state: UndoRedoState< T >,
action:
| { type: 'UNDO' }
| { type: 'REDO' }
| { type: 'RECORD'; value: T; isStaged: boolean }
): UndoRedoState< T > {
switch ( action.type ) {
case 'UNDO': {
const undoRecord = state.manager.undo();
if ( undoRecord ) {
return {
...state,
value: undoRecord[ 0 ].changes.prop.from,
};
}
return state;
}
case 'REDO': {
const redoRecord = state.manager.redo();
if ( redoRecord ) {
return {
...state,
value: redoRecord[ 0 ].changes.prop.to,
};
}
return state;
}
case 'RECORD': {
state.manager.addRecord(
[
{
id: 'object',
changes: {
prop: { from: state.value, to: action.value },
},
},
],
action.isStaged
);
return {
...state,
value: action.value,
};
}
}
return state;
}
function initReducer< T >( value: T ) {
return {
manager: createUndoManager(),
value,
};
}
/**
* useState with undo/redo history.
*
* @param initialValue Initial value.
* @return Value, setValue, hasUndo, hasRedo, undo, redo.
*/
export default function useStateWithHistory< T >( initialValue: T ) {
const [ state, dispatch ] = useReducer(
undoRedoReducer,
initialValue,
initReducer
);
return {
value: state.value,
setValue: useCallback( ( newValue: T, isStaged: boolean ) => {
dispatch( {
type: 'RECORD',
value: newValue,
isStaged,
} );
}, [] ),
hasUndo: state.manager.hasUndo(),
hasRedo: state.manager.hasRedo(),
undo: useCallback( () => {
dispatch( { type: 'UNDO' } );
}, [] ),
redo: useCallback( () => {
dispatch( { type: 'REDO' } );
}, [] ),
};
}

View File

@@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { screen, render, fireEvent } from '@testing-library/react';
/**
* Internal dependencies
*/
import useStateWithHistory from '../';
const TestComponent = () => {
const { value, setValue, hasUndo, hasRedo, undo, redo } =
useStateWithHistory( 'foo' );
return (
<div>
<input
value={ value }
onChange={ ( event ) => setValue( event.target.value ) }
/>
<button className="undo" onClick={ undo } disabled={ ! hasUndo }>
Undo
</button>
<button className="redo" onClick={ redo } disabled={ ! hasRedo }>
Redo
</button>
</div>
);
};
describe( 'useStateWithHistory', () => {
it( 'should allow undo/redo', async () => {
render( <TestComponent /> );
const input = screen.getByRole( 'textbox' );
expect( input ).toHaveValue( 'foo' );
const buttonUndo = screen.getByRole( 'button', { name: 'Undo' } );
const buttonRedo = screen.getByRole( 'button', { name: 'Redo' } );
expect( buttonUndo ).toBeDisabled();
expect( buttonRedo ).toBeDisabled();
// Make a change
fireEvent.change( input, { target: { value: 'bar' } } );
expect( input ).toHaveValue( 'bar' );
expect( buttonUndo ).toBeEnabled();
expect( buttonRedo ).toBeDisabled();
// Undo the change
fireEvent.click( buttonUndo );
expect( input ).toHaveValue( 'foo' );
expect( buttonUndo ).toBeDisabled();
expect( buttonRedo ).toBeEnabled();
// Redo the change
fireEvent.click( buttonRedo );
expect( input ).toHaveValue( 'bar' );
expect( buttonUndo ).toBeEnabled();
expect( buttonRedo ).toBeDisabled();
} );
} );

View File

@@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { useMemoOne } from 'use-memo-one';
/**
* WordPress dependencies
*/
import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { throttle } from '../../utils/throttle';
/**
* Throttles a function similar to Lodash's `throttle`. A new throttled function will
* be returned and any scheduled calls cancelled if any of the arguments change,
* including the function to throttle, so please wrap functions created on
* render in components in `useCallback`.
*
* @see https://docs-lodash.com/v4/throttle/
*
* @template {(...args: any[]) => void} TFunc
*
* @param {TFunc} fn The function to throttle.
* @param {number} [wait] The number of milliseconds to throttle invocations to.
* @param {import('../../utils/throttle').ThrottleOptions} [options] The options object. See linked documentation for details.
* @return {import('../../utils/debounce').DebouncedFunc<TFunc>} Throttled function.
*/
export default function useThrottle( fn, wait, options ) {
const throttled = useMemoOne(
() => throttle( fn, wait ?? 0, options ),
[ fn, wait, options ]
);
useEffect( () => () => throttled.cancel(), [ throttled ] );
return throttled;
}

View File

@@ -0,0 +1,92 @@
/**
* WordPress dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import useMediaQuery from '../use-media-query';
/**
* @typedef {"huge" | "wide" | "large" | "medium" | "small" | "mobile"} WPBreakpoint
*/
/**
* Hash of breakpoint names with pixel width at which it becomes effective.
*
* @see _breakpoints.scss
*
* @type {Record<WPBreakpoint, number>}
*/
const BREAKPOINTS = {
huge: 1440,
wide: 1280,
large: 960,
medium: 782,
small: 600,
mobile: 480,
};
/**
* @typedef {">=" | "<"} WPViewportOperator
*/
/**
* Object mapping media query operators to the condition to be used.
*
* @type {Record<WPViewportOperator, string>}
*/
const CONDITIONS = {
'>=': 'min-width',
'<': 'max-width',
};
/**
* Object mapping media query operators to a function that given a breakpointValue and a width evaluates if the operator matches the values.
*
* @type {Record<WPViewportOperator, (breakpointValue: number, width: number) => boolean>}
*/
const OPERATOR_EVALUATORS = {
'>=': ( breakpointValue, width ) => width >= breakpointValue,
'<': ( breakpointValue, width ) => width < breakpointValue,
};
const ViewportMatchWidthContext = createContext(
/** @type {null | number} */ ( null )
);
/**
* Returns true if the viewport matches the given query, or false otherwise.
*
* @param {WPBreakpoint} breakpoint Breakpoint size name.
* @param {WPViewportOperator} [operator=">="] Viewport operator.
*
* @example
*
* ```js
* useViewportMatch( 'huge', '<' );
* useViewportMatch( 'medium' );
* ```
*
* @return {boolean} Whether viewport matches query.
*/
const useViewportMatch = ( breakpoint, operator = '>=' ) => {
const simulatedWidth = useContext( ViewportMatchWidthContext );
const mediaQuery =
! simulatedWidth &&
`(${ CONDITIONS[ operator ] }: ${ BREAKPOINTS[ breakpoint ] }px)`;
const mediaQueryResult = useMediaQuery( mediaQuery || undefined );
if ( simulatedWidth ) {
return OPERATOR_EVALUATORS[ operator ](
BREAKPOINTS[ breakpoint ],
simulatedWidth
);
}
return mediaQueryResult;
};
useViewportMatch.__experimentalWidthProvider =
ViewportMatchWidthContext.Provider;
export default useViewportMatch;

View File

@@ -0,0 +1,129 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import useViewportMatch from '../';
jest.mock( '../../use-media-query', () => {
return jest.fn();
} );
import useMediaQueryMock from '../../use-media-query';
describe( 'useViewportMatch', () => {
afterEach( () => {
useMediaQueryMock.mockClear();
} );
const TestComponent = ( { breakpoint, operator } ) => {
const result = useViewportMatch( breakpoint, operator );
return `useViewportMatch: ${ result }`;
};
it( 'should return true when the viewport matches', async () => {
useMediaQueryMock.mockReturnValue( true );
const { container, rerender } = render(
<TestComponent breakpoint="wide" operator="<" />
);
expect( container ).toHaveTextContent( 'useViewportMatch: true' );
rerender( <TestComponent breakpoint="medium" operator=">=" /> );
expect( container ).toHaveTextContent( 'useViewportMatch: true' );
rerender( <TestComponent breakpoint="small" operator=">=" /> );
expect( container ).toHaveTextContent( 'useViewportMatch: true' );
expect( useMediaQueryMock ).toHaveBeenCalledTimes( 3 );
expect( useMediaQueryMock ).toHaveBeenNthCalledWith(
1,
'(max-width: 1280px)'
);
expect( useMediaQueryMock ).toHaveBeenNthCalledWith(
2,
'(min-width: 782px)'
);
expect( useMediaQueryMock ).toHaveBeenNthCalledWith(
3,
'(min-width: 600px)'
);
} );
it( 'should return false when the viewport matches', async () => {
useMediaQueryMock.mockReturnValue( false );
const { container, rerender } = render(
<TestComponent breakpoint="huge" operator=">=" />
);
expect( container ).toHaveTextContent( 'useViewportMatch: false' );
rerender( <TestComponent breakpoint="large" operator="<" /> );
expect( container ).toHaveTextContent( 'useViewportMatch: false' );
rerender( <TestComponent breakpoint="mobile" operator="<" /> );
expect( container ).toHaveTextContent( 'useViewportMatch: false' );
expect( useMediaQueryMock ).toHaveBeenCalledTimes( 3 );
expect( useMediaQueryMock ).toHaveBeenNthCalledWith(
1,
'(min-width: 1440px)'
);
expect( useMediaQueryMock ).toHaveBeenNthCalledWith(
2,
'(max-width: 960px)'
);
expect( useMediaQueryMock ).toHaveBeenNthCalledWith(
3,
'(max-width: 480px)'
);
} );
it( 'should correctly simulate a value', async () => {
useMediaQueryMock.mockReturnValue( true );
const innerElement = <TestComponent breakpoint="wide" operator=">=" />;
const WidthProvider = useViewportMatch.__experimentalWidthProvider;
const { container, rerender } = render(
<WidthProvider value={ 300 }>{ innerElement }</WidthProvider>
);
expect( container ).toHaveTextContent( 'useViewportMatch: false' );
rerender(
<WidthProvider value={ 1200 }>{ innerElement }</WidthProvider>
);
expect( container ).toHaveTextContent( 'useViewportMatch: false' );
rerender(
<WidthProvider value={ 1300 }>{ innerElement }</WidthProvider>
);
expect( container ).toHaveTextContent( 'useViewportMatch: true' );
rerender(
<WidthProvider value={ 1300 }>
<TestComponent breakpoint="wide" operator="<" />
</WidthProvider>
);
expect( container ).toHaveTextContent( 'useViewportMatch: false' );
expect( useMediaQueryMock ).toHaveBeenCalledTimes( 4 );
// `useMediaQuery` is expected to receive `undefined` when simulating width.
expect( useMediaQueryMock ).toHaveBeenNthCalledWith( 1, undefined );
expect( useMediaQueryMock ).toHaveBeenNthCalledWith( 2, undefined );
expect( useMediaQueryMock ).toHaveBeenNthCalledWith( 3, undefined );
expect( useMediaQueryMock ).toHaveBeenNthCalledWith( 4, undefined );
} );
} );

View File

@@ -0,0 +1,43 @@
/**
* Internal dependencies
*/
import usePrevious from '../use-previous';
// Disable reason: Object and object are distinctly different types in TypeScript and we mean the lowercase object in thise case
// but eslint wants to force us to use `Object`. See https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript
/* eslint-disable jsdoc/check-types */
/**
* Hook that performs a shallow comparison between the preview value of an object
* and the new one, if there's a difference, it prints it to the console.
* this is useful in performance related work, to check why a component re-renders.
*
* @example
*
* ```jsx
* function MyComponent(props) {
* useWarnOnChange(props);
*
* return "Something";
* }
* ```
*
* @param {object} object Object which changes to compare.
* @param {string} prefix Just a prefix to show when console logging.
*/
function useWarnOnChange( object, prefix = 'Change detection' ) {
const previousValues = usePrevious( object );
Object.entries( previousValues ?? [] ).forEach( ( [ key, value ] ) => {
if ( value !== object[ /** @type {keyof typeof object} */ ( key ) ] ) {
// eslint-disable-next-line no-console
console.warn(
`${ prefix }: ${ key } key changed:`,
value,
object[ /** @type {keyof typeof object} */ ( key ) ]
/* eslint-enable jsdoc/check-types */
);
}
} );
}
export default useWarnOnChange;

51
node_modules/@wordpress/compose/src/index.js generated vendored Normal file
View File

@@ -0,0 +1,51 @@
// The `createHigherOrderComponent` helper and helper types.
export * from './utils/create-higher-order-component';
// The `debounce` helper and its types.
export * from './utils/debounce';
// The `throttle` helper and its types.
export * from './utils/throttle';
// The `ObservableMap` data structure
export * from './utils/observable-map';
// The `compose` and `pipe` helpers (inspired by `flowRight` and `flow` from Lodash).
export { default as compose } from './higher-order/compose';
export { default as pipe } from './higher-order/pipe';
// Higher-order components.
export { default as ifCondition } from './higher-order/if-condition';
export { default as pure } from './higher-order/pure';
export { default as withGlobalEvents } from './higher-order/with-global-events';
export { default as withInstanceId } from './higher-order/with-instance-id';
export { default as withSafeTimeout } from './higher-order/with-safe-timeout';
export { default as withState } from './higher-order/with-state';
// Hooks.
export { default as useConstrainedTabbing } from './hooks/use-constrained-tabbing';
export { default as useCopyOnClick } from './hooks/use-copy-on-click';
export { default as useCopyToClipboard } from './hooks/use-copy-to-clipboard';
export { default as __experimentalUseDialog } from './hooks/use-dialog';
export { default as useDisabled } from './hooks/use-disabled';
export { default as __experimentalUseDragging } from './hooks/use-dragging';
export { default as useFocusOnMount } from './hooks/use-focus-on-mount';
export { default as __experimentalUseFocusOutside } from './hooks/use-focus-outside';
export { default as useFocusReturn } from './hooks/use-focus-return';
export { default as useInstanceId } from './hooks/use-instance-id';
export { default as useIsomorphicLayoutEffect } from './hooks/use-isomorphic-layout-effect';
export { default as useKeyboardShortcut } from './hooks/use-keyboard-shortcut';
export { default as useMediaQuery } from './hooks/use-media-query';
export { default as usePrevious } from './hooks/use-previous';
export { default as useReducedMotion } from './hooks/use-reduced-motion';
export { default as useStateWithHistory } from './hooks/use-state-with-history';
export { default as useViewportMatch } from './hooks/use-viewport-match';
export { default as useResizeObserver } from './hooks/use-resize-observer';
export { default as useAsyncList } from './hooks/use-async-list';
export { default as useWarnOnChange } from './hooks/use-warn-on-change';
export { default as useDebounce } from './hooks/use-debounce';
export { default as useDebouncedInput } from './hooks/use-debounced-input';
export { default as useThrottle } from './hooks/use-throttle';
export { default as useMergeRefs } from './hooks/use-merge-refs';
export { default as useRefEffect } from './hooks/use-ref-effect';
export { default as __experimentalUseDropZone } from './hooks/use-drop-zone';
export { default as useFocusableIframe } from './hooks/use-focusable-iframe';
export { default as __experimentalUseFixedWindowList } from './hooks/use-fixed-window-list';
export { default as useObservableValue } from './hooks/use-observable-value';

44
node_modules/@wordpress/compose/src/index.native.js generated vendored Normal file
View File

@@ -0,0 +1,44 @@
// The `createHigherOrderComponent` helper and helper types.
export * from './utils/create-higher-order-component';
// The `debounce` helper and its types.
export * from './utils/debounce';
// The `throttle` helper and its types.
export * from './utils/throttle';
// The `ObservableMap` data structure
export * from './utils/observable-map';
// The `compose` and `pipe` helpers (inspired by `flowRight` and `flow` from Lodash).
export { default as compose } from './higher-order/compose';
export { default as pipe } from './higher-order/pipe';
// Higher-order components.
export { default as ifCondition } from './higher-order/if-condition';
export { default as pure } from './higher-order/pure';
export { default as withGlobalEvents } from './higher-order/with-global-events';
export { default as withInstanceId } from './higher-order/with-instance-id';
export { default as withSafeTimeout } from './higher-order/with-safe-timeout';
export { default as withState } from './higher-order/with-state';
export { default as withPreferredColorScheme } from './higher-order/with-preferred-color-scheme';
export { default as withNetworkConnectivity } from './higher-order/with-network-connectivity';
// Hooks.
export { default as useConstrainedTabbing } from './hooks/use-constrained-tabbing';
export { default as __experimentalUseDragging } from './hooks/use-dragging';
export { default as __experimentalUseFocusOutside } from './hooks/use-focus-outside';
export { default as useInstanceId } from './hooks/use-instance-id';
export { default as useIsomorphicLayoutEffect } from './hooks/use-isomorphic-layout-effect';
export { default as useKeyboardShortcut } from './hooks/use-keyboard-shortcut';
export { default as useMediaQuery } from './hooks/use-media-query';
export { default as usePrevious } from './hooks/use-previous';
export { default as useReducedMotion } from './hooks/use-reduced-motion';
export { default as useViewportMatch } from './hooks/use-viewport-match';
export { default as usePreferredColorScheme } from './hooks/use-preferred-color-scheme';
export { default as usePreferredColorSchemeStyle } from './hooks/use-preferred-color-scheme-style';
export { default as useResizeObserver } from './hooks/use-resize-observer';
export { default as useDebounce } from './hooks/use-debounce';
export { default as useDebouncedInput } from './hooks/use-debounced-input';
export { default as useThrottle } from './hooks/use-throttle';
export { default as useMergeRefs } from './hooks/use-merge-refs';
export { default as useRefEffect } from './hooks/use-ref-effect';
export { default as useNetworkConnectivity } from './hooks/use-network-connectivity';
export { default as useObservableValue } from './hooks/use-observable-value';

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { pascalCase } from 'change-case';
import type { ComponentType } from 'react';
type GetProps< C > = C extends ComponentType< infer P > ? P : never;
export type WithoutInjectedProps< C, I > = Omit< GetProps< C >, keyof I >;
export type WithInjectedProps< C, I > = ComponentType<
WithoutInjectedProps< C, I > & I
>;
/**
* Given a function mapping a component to an enhanced component and modifier
* name, returns the enhanced component augmented with a generated displayName.
*
* @param mapComponent Function mapping component to enhanced component.
* @param modifierName Seed name from which to generated display name.
*
* @return Component class with generated display name assigned.
*/
export function createHigherOrderComponent<
TInner extends ComponentType< any >,
TOuter extends ComponentType< any >,
>( mapComponent: ( Inner: TInner ) => TOuter, modifierName: string ) {
return ( Inner: TInner ) => {
const Outer = mapComponent( Inner );
Outer.displayName = hocName( modifierName, Inner );
return Outer;
};
}
/**
* Returns a displayName for a higher-order component, given a wrapper name.
*
* @example
* hocName( 'MyMemo', Widget ) === 'MyMemo(Widget)';
* hocName( 'MyMemo', <div /> ) === 'MyMemo(Component)';
*
* @param name Name assigned to higher-order component's wrapper component.
* @param Inner Wrapped component inside higher-order component.
* @return Wrapped name of higher-order component.
*/
const hocName = ( name: string, Inner: ComponentType< any > ) => {
const inner = Inner.displayName || Inner.name || 'Component';
const outer = pascalCase( name ?? '' );
return `${ outer }(${ inner })`;
};

View File

@@ -0,0 +1,76 @@
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import { createHigherOrderComponent } from '../';
describe( 'createHigherOrderComponent', () => {
it( 'should use default name for anonymous function', () => {
const TestComponent = createHigherOrderComponent(
( OriginalComponent ) => OriginalComponent,
'withTest'
)( () => <div /> );
expect( TestComponent.displayName ).toBe( 'WithTest(Component)' );
} );
it( 'should use camel case starting with upper for wrapper prefix', () => {
const TestComponent = createHigherOrderComponent(
( OriginalComponent ) => OriginalComponent,
'with-one-two_threeFOUR'
)( () => <div /> );
expect( TestComponent.displayName ).toBe(
'WithOneTwoThreeFour(Component)'
);
} );
it( 'should use function name', () => {
function SomeComponent() {
return <div />;
}
const TestComponent = createHigherOrderComponent(
( OriginalComponent ) => OriginalComponent,
'withTest'
)( SomeComponent );
expect( TestComponent.displayName ).toBe( 'WithTest(SomeComponent)' );
} );
it( 'should use component class name', () => {
class SomeAnotherComponent extends Component {
render() {
return <div />;
}
}
const TestComponent = createHigherOrderComponent(
( OriginalComponent ) => OriginalComponent,
'withTest'
)( SomeAnotherComponent );
expect( TestComponent.displayName ).toBe(
'WithTest(SomeAnotherComponent)'
);
} );
it( 'should use displayName property', () => {
class SomeYetAnotherComponent extends Component {
render() {
return <div />;
}
}
SomeYetAnotherComponent.displayName = 'CustomDisplayName';
const TestComponent = createHigherOrderComponent(
( OriginalComponent ) => OriginalComponent,
'withTest'
)( SomeYetAnotherComponent );
expect( TestComponent.displayName ).toBe(
'WithTest(CustomDisplayName)'
);
} );
} );

View File

@@ -0,0 +1,260 @@
/**
* Parts of this source were derived and modified from lodash,
* released under the MIT license.
*
* https://github.com/lodash/lodash
*
* Copyright JS Foundation and other contributors <https://js.foundation/>
*
* Based on Underscore.js, copyright Jeremy Ashkenas,
* DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
*
* This software consists of voluntary contributions made by many
* individuals. For exact contribution history, see the revision history
* available at https://github.com/lodash/lodash
*
* The following license applies to all parts of this software except as
* documented below:
*
* ====
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export interface DebounceOptions {
leading: boolean;
maxWait: number;
trailing: boolean;
}
export interface DebouncedFunc< T extends ( ...args: any[] ) => any > {
/**
* Call the original function, but applying the debounce rules.
*
* If the debounced function can be run immediately, this calls it and returns its return
* value.
*
* Otherwise, it returns the return value of the last invocation, or undefined if the debounced
* function was not invoked yet.
*/
( ...args: Parameters< T > ): ReturnType< T > | undefined;
/**
* Throw away any pending invocation of the debounced function.
*/
cancel(): void;
/**
* If there is a pending invocation of the debounced function, invoke it immediately and return
* its return value.
*
* Otherwise, return the value from the last invocation, or undefined if the debounced function
* was never invoked.
*/
flush(): ReturnType< T > | undefined;
}
/**
* A simplified and properly typed version of lodash's `debounce`, that
* always uses timers instead of sometimes using rAF.
*
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked. The debounced function comes with a `cancel` method to cancel delayed
* `func` invocations and a `flush` method to immediately invoke them. Provide
* `options` to indicate whether `func` should be invoked on the leading and/or
* trailing edge of the `wait` timeout. The `func` is invoked with the last
* arguments provided to the debounced function. Subsequent calls to the debounced
* function return the result of the last `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the debounced function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* @param {Function} func The function to debounce.
* @param {number} wait The number of milliseconds to delay.
* @param {Partial< DebounceOptions >} options The options object.
* @param {boolean} options.leading Specify invoking on the leading edge of the timeout.
* @param {number} options.maxWait The maximum time `func` is allowed to be delayed before it's invoked.
* @param {boolean} options.trailing Specify invoking on the trailing edge of the timeout.
*
* @return Returns the new debounced function.
*/
export const debounce = < FunctionT extends ( ...args: unknown[] ) => unknown >(
func: FunctionT,
wait: number,
options?: Partial< DebounceOptions >
) => {
let lastArgs: Parameters< FunctionT > | undefined;
let lastThis: unknown | undefined;
let maxWait = 0;
let result: ReturnType< FunctionT >;
let timerId: ReturnType< typeof setTimeout > | undefined;
let lastCallTime: number | undefined;
let lastInvokeTime = 0;
let leading = false;
let maxing = false;
let trailing = true;
if ( options ) {
leading = !! options.leading;
maxing = 'maxWait' in options;
if ( options.maxWait !== undefined ) {
maxWait = Math.max( options.maxWait, wait );
}
trailing = 'trailing' in options ? !! options.trailing : trailing;
}
function invokeFunc( time: number ) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = undefined;
lastThis = undefined;
lastInvokeTime = time;
result = func.apply( thisArg, args! ) as ReturnType< FunctionT >;
return result;
}
function startTimer(
pendingFunc: () => void,
waitTime: number | undefined
) {
timerId = setTimeout( pendingFunc, waitTime );
}
function cancelTimer() {
if ( timerId !== undefined ) {
clearTimeout( timerId );
}
}
function leadingEdge( time: number ) {
// Reset any `maxWait` timer.
lastInvokeTime = time;
// Start the timer for the trailing edge.
startTimer( timerExpired, wait );
// Invoke the leading edge.
return leading ? invokeFunc( time ) : result;
}
function getTimeSinceLastCall( time: number ) {
return time - ( lastCallTime || 0 );
}
function remainingWait( time: number ) {
const timeSinceLastCall = getTimeSinceLastCall( time );
const timeSinceLastInvoke = time - lastInvokeTime;
const timeWaiting = wait - timeSinceLastCall;
return maxing
? Math.min( timeWaiting, maxWait - timeSinceLastInvoke )
: timeWaiting;
}
function shouldInvoke( time: number ) {
const timeSinceLastCall = getTimeSinceLastCall( time );
const timeSinceLastInvoke = time - lastInvokeTime;
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (
lastCallTime === undefined ||
timeSinceLastCall >= wait ||
timeSinceLastCall < 0 ||
( maxing && timeSinceLastInvoke >= maxWait )
);
}
function timerExpired() {
const time = Date.now();
if ( shouldInvoke( time ) ) {
return trailingEdge( time );
}
// Restart the timer.
startTimer( timerExpired, remainingWait( time ) );
return undefined;
}
function clearTimer() {
timerId = undefined;
}
function trailingEdge( time: number ) {
clearTimer();
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if ( trailing && lastArgs ) {
return invokeFunc( time );
}
lastArgs = lastThis = undefined;
return result;
}
function cancel() {
cancelTimer();
lastInvokeTime = 0;
clearTimer();
lastArgs = lastCallTime = lastThis = undefined;
}
function flush() {
return pending() ? trailingEdge( Date.now() ) : result;
}
function pending() {
return timerId !== undefined;
}
function debounced( this: unknown, ...args: Parameters< FunctionT > ) {
const time = Date.now();
const isInvoking = shouldInvoke( time );
lastArgs = args;
lastThis = this;
lastCallTime = time;
if ( isInvoking ) {
if ( ! pending() ) {
return leadingEdge( lastCallTime );
}
if ( maxing ) {
// Handle invocations in a tight loop.
startTimer( timerExpired, wait );
return invokeFunc( lastCallTime );
}
}
if ( ! pending() ) {
startTimer( timerExpired, wait );
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
debounced.pending = pending;
return debounced;
};

View File

@@ -0,0 +1,359 @@
/**
* Internal dependencies
*/
import { debounce } from '../index';
const identity = ( value ) => value;
describe( 'debounce', () => {
it( 'should debounce a function', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce( function ( value ) {
++callCount;
return value;
}, 32 );
let results = [
debounced( 'a' ),
debounced( 'b' ),
debounced( 'c' ),
];
expect( results ).toStrictEqual( [
undefined,
undefined,
undefined,
] );
expect( callCount ).toBe( 0 );
setTimeout( function () {
expect( callCount ).toBe( 1 );
results = [
debounced( 'd' ),
debounced( 'e' ),
debounced( 'f' ),
];
expect( results ).toStrictEqual( [ 'c', 'c', 'c' ] );
expect( callCount ).toBe( 1 );
}, 128 );
setTimeout( function () {
expect( callCount ).toBe( 2 );
done( null );
}, 256 );
} );
} );
it( 'should return the last `func` result on subsequent debounced calls', () => {
return new Promise( ( done ) => {
const debounced = debounce( identity, 32 );
debounced( 'a' );
setTimeout( function () {
expect( debounced( 'b' ) ).not.toBe( 'b' );
}, 64 );
setTimeout( function () {
expect( debounced( 'c' ) ).not.toBe( 'c' );
done( null );
}, 128 );
} );
} );
it( 'should not immediately call `func` when `wait` is `0`', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce( function () {
++callCount;
}, 0 );
debounced();
debounced();
expect( callCount ).toBe( 0 );
setTimeout( function () {
expect( callCount ).toBe( 1 );
done( null );
}, 5 );
} );
} );
it( 'should apply default options', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce(
function () {
callCount++;
},
32,
{}
);
debounced();
expect( callCount ).toBe( 0 );
setTimeout( function () {
expect( callCount ).toBe( 1 );
done( null );
}, 64 );
} );
} );
it( 'should support a `leading` option', () => {
return new Promise( ( done ) => {
const callCounts = [ 0, 0 ];
const withLeading = debounce(
function () {
callCounts[ 0 ]++;
},
32,
{ leading: true }
);
const withLeadingAndTrailing = debounce(
function () {
callCounts[ 1 ]++;
},
32,
{ leading: true }
);
withLeading();
expect( callCounts[ 0 ] ).toBe( 1 );
withLeadingAndTrailing();
withLeadingAndTrailing();
expect( callCounts[ 1 ] ).toBe( 1 );
setTimeout( function () {
expect( callCounts ).toStrictEqual( [ 1, 2 ] );
withLeading();
expect( callCounts[ 0 ] ).toBe( 2 );
done( null );
}, 64 );
} );
} );
it( 'should return the last `func` result for subsequent leading debounced calls', () => {
return new Promise( ( done ) => {
const debounced = debounce( identity, 32, {
leading: true,
trailing: false,
} );
let results = [ debounced( 'a' ), debounced( 'b' ) ];
expect( results ).toStrictEqual( [ 'a', 'a' ] );
setTimeout( function () {
results = [ debounced( 'c' ), debounced( 'd' ) ];
expect( results ).toStrictEqual( [ 'c', 'c' ] );
done( null );
}, 64 );
} );
} );
it( 'should support a `trailing` option', () => {
return new Promise( ( done ) => {
let withCount = 0;
let withoutCount = 0;
const withTrailing = debounce(
function () {
withCount++;
},
32,
{ trailing: true }
);
const withoutTrailing = debounce(
function () {
withoutCount++;
},
32,
{ trailing: false }
);
withTrailing();
expect( withCount ).toBe( 0 );
withoutTrailing();
expect( withCount ).toBe( 0 );
setTimeout( function () {
expect( withCount ).toBe( 1 );
expect( withoutCount ).toBe( 0 );
done( null );
}, 64 );
} );
} );
it( 'should support a `maxWait` option', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce(
function ( value ) {
++callCount;
return value;
},
32,
{ maxWait: 64 }
);
debounced( 42 );
debounced( 42 );
expect( callCount ).toBe( 0 );
setTimeout( function () {
expect( callCount ).toBe( 1 );
debounced( 42 );
debounced( 42 );
expect( callCount ).toBe( 1 );
}, 128 );
setTimeout( function () {
expect( callCount ).toBe( 2 );
done( null );
}, 256 );
} );
} );
it( 'should support `maxWait` in a tight loop', () => {
return new Promise( ( done ) => {
const limit = 320;
let withCount = 0;
let withoutCount = 0;
const withMaxWait = debounce(
function () {
withCount++;
},
64,
{ maxWait: 128 }
);
const withoutMaxWait = debounce( function () {
withoutCount++;
}, 96 );
const start = Date.now();
while ( Date.now() - start < limit ) {
withMaxWait();
withoutMaxWait();
}
const actual = [ Boolean( withoutCount ), Boolean( withCount ) ];
setTimeout( function () {
expect( actual ).toStrictEqual( [ false, true ] );
done( null );
}, 1 );
} );
} );
it( 'should queue a trailing call for subsequent debounced calls after `maxWait`', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce(
function () {
++callCount;
},
200,
{ maxWait: 200 }
);
debounced();
setTimeout( debounced, 190 );
setTimeout( debounced, 200 );
setTimeout( debounced, 210 );
setTimeout( function () {
expect( callCount ).toBe( 2 );
done( null );
}, 500 );
} );
} );
it( 'should cancel when `cancel` is invoked', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce( function () {
callCount++;
}, 32 );
debounced();
setTimeout( function () {
debounced.cancel();
expect( callCount ).toBe( 0 );
}, 16 );
setTimeout( function () {
expect( callCount ).toBe( 0 );
done( null );
}, 64 );
} );
} );
it( 'should cancel `maxDelayed` when `delayed` is invoked', () => {
return new Promise( ( done ) => {
let callCount = 0;
const debounced = debounce(
function () {
callCount++;
},
32,
{ maxWait: 64 }
);
debounced();
setTimeout( function () {
debounced();
expect( callCount ).toBe( 1 );
}, 128 );
setTimeout( function () {
expect( callCount ).toBe( 2 );
done( null );
}, 192 );
} );
} );
it( 'should invoke the trailing call with the correct arguments and `this` binding', () => {
return new Promise( ( done ) => {
let actual;
let callCount = 0;
const object = {};
const debounced = debounce(
function ( ...args ) {
actual = [ this ];
Array.prototype.push.apply( actual, args );
return ++callCount !== 2;
},
32,
{ leading: true, maxWait: 64 }
);
while ( true ) {
if ( ! debounced.call( object, 'a' ) ) {
break;
}
}
setTimeout( function () {
expect( callCount ).toBe( 2 );
expect( actual ).toStrictEqual( [ object, 'a' ] );
done( null );
}, 64 );
} );
} );
} );

View File

@@ -0,0 +1,61 @@
export type ObservableMap< K, V > = {
get( name: K ): V | undefined;
set( name: K, value: V ): void;
delete( name: K ): void;
subscribe( name: K, listener: () => void ): () => void;
};
/**
* A constructor (factory) for `ObservableMap`, a map-like key/value data structure
* where the individual entries are observable: using the `subscribe` method, you can
* subscribe to updates for a particular keys. Each subscriber always observes one
* specific key and is not notified about any unrelated changes (for different keys)
* in the `ObservableMap`.
*
* @template K The type of the keys in the map.
* @template V The type of the values in the map.
* @return A new instance of the `ObservableMap` type.
*/
export function observableMap< K, V >(): ObservableMap< K, V > {
const map = new Map< K, V >();
const listeners = new Map< K, Set< () => void > >();
function callListeners( name: K ) {
const list = listeners.get( name );
if ( ! list ) {
return;
}
for ( const listener of list ) {
listener();
}
}
return {
get( name ) {
return map.get( name );
},
set( name, value ) {
map.set( name, value );
callListeners( name );
},
delete( name ) {
map.delete( name );
callListeners( name );
},
subscribe( name, listener ) {
let list = listeners.get( name );
if ( ! list ) {
list = new Set();
listeners.set( name, list );
}
list.add( listener );
return () => {
list.delete( listener );
if ( list.size === 0 ) {
listeners.delete( name );
}
};
},
};
}

View File

@@ -0,0 +1,43 @@
/**
* Internal dependencies
*/
import { observableMap } from '..';
describe( 'ObservableMap', () => {
test( 'should observe individual values', () => {
const map = observableMap();
const listenerA = jest.fn();
const listenerB = jest.fn();
const unsubA = map.subscribe( 'a', listenerA );
const unsubB = map.subscribe( 'b', listenerB );
// check that setting `a` doesn't notify the `b` listener
map.set( 'a', 1 );
expect( listenerA ).toHaveBeenCalledTimes( 1 );
expect( listenerB ).toHaveBeenCalledTimes( 0 );
// check that setting `b` doesn't notify the `a` listener
map.set( 'b', 2 );
expect( listenerA ).toHaveBeenCalledTimes( 1 );
expect( listenerB ).toHaveBeenCalledTimes( 1 );
// check that `delete` triggers notifications, too
map.delete( 'a' );
expect( listenerA ).toHaveBeenCalledTimes( 2 );
expect( listenerB ).toHaveBeenCalledTimes( 1 );
// check that the subscription survived the `delete`
map.set( 'a', 2 );
expect( listenerA ).toHaveBeenCalledTimes( 3 );
expect( listenerB ).toHaveBeenCalledTimes( 1 );
// check that unsubscription really works
unsubA();
unsubB();
map.set( 'a', 3 );
expect( listenerA ).toHaveBeenCalledTimes( 3 );
expect( listenerB ).toHaveBeenCalledTimes( 1 );
} );
} );

View File

@@ -0,0 +1,95 @@
/**
* Parts of this source were derived and modified from lodash,
* released under the MIT license.
*
* https://github.com/lodash/lodash
*
* Copyright JS Foundation and other contributors <https://js.foundation/>
*
* Based on Underscore.js, copyright Jeremy Ashkenas,
* DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
*
* This software consists of voluntary contributions made by many
* individuals. For exact contribution history, see the revision history
* available at https://github.com/lodash/lodash
*
* The following license applies to all parts of this software except as
* documented below:
*
* ====
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* Internal dependencies
*/
import { debounce } from '../debounce';
export interface ThrottleOptions {
leading?: boolean;
trailing?: boolean;
}
/**
* A simplified and properly typed version of lodash's `throttle`, that
* always uses timers instead of sometimes using rAF.
*
* Creates a throttled function that only invokes `func` at most once per
* every `wait` milliseconds. The throttled function comes with a `cancel`
* method to cancel delayed `func` invocations and a `flush` method to
* immediately invoke them. Provide `options` to indicate whether `func`
* should be invoked on the leading and/or trailing edge of the `wait`
* timeout. The `func` is invoked with the last arguments provided to the
* throttled function. Subsequent calls to the throttled function return
* the result of the last `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the throttled function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* @param {Function} func The function to throttle.
* @param {number} wait The number of milliseconds to throttle invocations to.
* @param {Partial< ThrottleOptions >} options The options object.
* @param {boolean} options.leading Specify invoking on the leading edge of the timeout.
* @param {boolean} options.trailing Specify invoking on the trailing edge of the timeout.
* @return Returns the new throttled function.
*/
export const throttle = < FunctionT extends ( ...args: unknown[] ) => unknown >(
func: FunctionT,
wait: number,
options?: ThrottleOptions
) => {
let leading = true;
let trailing = true;
if ( options ) {
leading = 'leading' in options ? !! options.leading : leading;
trailing = 'trailing' in options ? !! options.trailing : trailing;
}
return debounce( func, wait, {
leading,
trailing,
maxWait: wait,
} );
};

View File

@@ -0,0 +1,256 @@
/**
* Internal dependencies
*/
import { throttle } from '../index';
const identity = ( value ) => value;
describe( 'throttle', () => {
beforeEach( () => {
jest.useFakeTimers();
} );
afterEach( () => {
jest.useRealTimers();
} );
it( 'should throttle a function', () => {
let callCount = 0;
const throttled = throttle( function () {
callCount++;
}, 32 );
throttled();
throttled();
throttled();
const lastCount = callCount;
expect( callCount ).toBeGreaterThan( 0 );
jest.advanceTimersByTime( 64 );
expect( callCount ).toBeGreaterThan( lastCount );
} );
it( 'should return the result of the first call on subsequent calls', () => {
const throttled = throttle( identity, 32 );
let results = [ throttled( 'a' ), throttled( 'b' ) ];
expect( results ).toStrictEqual( [ 'a', 'a' ] );
jest.advanceTimersByTime( 64 );
results = [ throttled( 'c' ), throttled( 'd' ) ];
expect( results[ 0 ] ).not.toBe( 'a' );
expect( results[ 0 ] ).not.toBe( undefined );
expect( results[ 1 ] ).not.toBe( 'd' );
expect( results[ 1 ] ).not.toBe( undefined );
} );
it( 'should clear timeout when `func` is called', () => {
let callCount = 0;
let dateCount = 0;
const globalDateNow = global.Date.now;
global.Date.now = function () {
return ++dateCount === 5 ? Infinity : +new Date();
};
const throttled = throttle( () => {
callCount++;
}, 32 );
throttled();
throttled();
jest.advanceTimersByTime( 64 );
expect( callCount ).toBe( 2 );
global.Date.now = globalDateNow;
} );
it( 'should not trigger a trailing call when invoked once', () => {
let callCount = 0;
const throttled = throttle( () => {
callCount++;
}, 32 );
throttled();
expect( callCount ).toBe( 1 );
jest.advanceTimersByTime( 64 );
expect( callCount ).toBe( 1 );
} );
[ true, false ].forEach( ( leading ) => {
it(
'should trigger a call when invoked repeatedly' +
( ! leading ? ' and `leading` is `false`' : '' ),
() => {
let callCount = 0;
const limit = 320;
const options = leading ? {} : { leading: false };
const throttled = throttle(
() => {
callCount++;
},
32,
options
);
const start = Date.now();
while ( Date.now() - start < limit ) {
throttled();
jest.advanceTimersByTime( 1 );
}
const actual = callCount;
jest.advanceTimersByTime( 1 );
expect( actual ).toBeGreaterThan( 1 );
}
);
} );
it( 'should trigger a second throttled call as soon as possible', () => {
let callCount = 0;
const throttled = throttle(
() => {
callCount++;
},
128,
{ leading: false }
);
throttled();
jest.advanceTimersByTime( 192 );
expect( callCount ).toBe( 1 );
throttled();
jest.advanceTimersByTime( 64 );
expect( callCount ).toBe( 1 );
jest.advanceTimersByTime( 130 );
expect( callCount ).toBe( 2 );
} );
it( 'should apply default options', () => {
let callCount = 0;
const throttled = throttle(
() => {
callCount++;
},
32,
{}
);
throttled();
throttled();
expect( callCount ).toBe( 1 );
jest.advanceTimersByTime( 128 );
expect( callCount ).toBe( 2 );
} );
it( 'should support a `leading` option', () => {
const withLeading = throttle( identity, 32, { leading: true } );
expect( withLeading( 'a' ) ).toBe( 'a' );
const withoutLeading = throttle( identity, 32, { leading: false } );
expect( withoutLeading( 'a' ) ).toBeUndefined();
} );
it( 'should support a `trailing` option', () => {
let withCount = 0;
let withoutCount = 0;
const withTrailing = throttle(
( value ) => {
withCount++;
return value;
},
64,
{ trailing: true }
);
const withoutTrailing = throttle(
( value ) => {
withoutCount++;
return value;
},
64,
{ trailing: false }
);
expect( withTrailing( 'a' ) ).toBe( 'a' );
expect( withTrailing( 'b' ) ).toBe( 'a' );
expect( withoutTrailing( 'a' ) ).toBe( 'a' );
expect( withoutTrailing( 'b' ) ).toBe( 'a' );
jest.advanceTimersByTime( 256 );
expect( withCount ).toBe( 2 );
expect( withoutCount ).toBe( 1 );
} );
it( 'should not update `lastCalled`, at the end of the timeout, when `trailing` is `false`', () => {
let callCount = 0;
const throttled = throttle(
function () {
callCount++;
},
64,
{ trailing: false }
);
throttled();
throttled();
jest.advanceTimersByTime( 96 );
throttled();
throttled();
jest.advanceTimersByTime( 96 );
expect( callCount ).toBeGreaterThan( 1 );
} );
it( 'should work with a system time of `0`', () => {
let callCount = 0;
let dateCount = 0;
const globalDateNow = global.Date.now;
global.Date.now = function () {
return ++dateCount < 4 ? 0 : +new Date();
};
const throttled = throttle( ( value ) => {
callCount++;
return value;
}, 32 );
const results = [
throttled( 'a' ),
throttled( 'b' ),
throttled( 'c' ),
];
expect( results ).toStrictEqual( [ 'a', 'a', 'a' ] );
expect( callCount ).toBe( 1 );
jest.advanceTimersByTime( 64 );
expect( callCount ).toBe( 2 );
global.Date.now = globalDateNow;
} );
} );