diff --git a/packages/block-library/src/cover/edit.js b/packages/block-library/src/cover/edit.js index c7c3538c2c3adf..7932fff4d215d1 100644 --- a/packages/block-library/src/cover/edit.js +++ b/packages/block-library/src/cover/edit.js @@ -236,15 +236,19 @@ function useCoverIsDark( url, dimRatio = 50, overlayColor, elementRef ) { return isDark; } +function mediaPosition( { x, y } ) { + return `${ Math.round( x * 100 ) }% ${ Math.round( y * 100 ) }%`; +} + function CoverEdit( { attributes, - setAttributes, isSelected, noticeUI, + noticeOperations, overlayColor, + setAttributes, setOverlayColor, toggleSelection, - noticeOperations, } ) { const { contentPosition, @@ -345,9 +349,8 @@ function CoverEdit( { const mediaStyle = { objectPosition: - // prettier-ignore focalPoint && isImgElement - ? `${ Math.round( focalPoint.x * 100 ) }% ${ Math.round( focalPoint.y * 100) }%` + ? mediaPosition( focalPoint ) : undefined, }; @@ -356,6 +359,13 @@ function CoverEdit( { isVideoBackground || ( isImageBackground && ( ! hasParallax || isRepeated ) ); + const imperativeFocalPointPreview = ( value ) => { + const [ styleOfRef, property ] = isDarkElement.current + ? [ isDarkElement.current.style, 'objectPosition' ] + : [ blockProps.ref.current.style, 'backgroundPosition' ]; + styleOfRef[ property ] = mediaPosition( value ); + }; + const controls = ( <> @@ -406,6 +416,8 @@ function CoverEdit( { label={ __( 'Focal point picker' ) } url={ url } value={ focalPoint } + onDragStart={ imperativeFocalPointPreview } + onDrag={ imperativeFocalPointPreview } onChange={ ( newFocalPoint ) => setAttributes( { focalPoint: newFocalPoint, diff --git a/packages/block-library/src/media-text/edit.js b/packages/block-library/src/media-text/edit.js index d1a488a774c0b1..55b4551a2557aa 100644 --- a/packages/block-library/src/media-text/edit.js +++ b/packages/block-library/src/media-text/edit.js @@ -9,7 +9,7 @@ import { map, filter } from 'lodash'; */ import { __, _x } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; -import { useState } from '@wordpress/element'; +import { useState, useRef } from '@wordpress/element'; import { BlockControls, BlockVerticalAlignmentToolbar, @@ -146,6 +146,13 @@ function MediaTextEdit( { attributes, isSelected, setAttributes } ) { [ isSelected, mediaId ] ); + const refMediaContainer = useRef(); + const imperativeFocalPointPreview = ( value ) => { + const { style } = refMediaContainer.current.resizable; + const { x, y } = value; + style.backgroundPosition = `${ x * 100 }% ${ y * 100 }%`; + }; + const [ temporaryMediaWidth, setTemporaryMediaWidth ] = useState( null ); const onSelectMedia = attributesFromMedia( { attributes, setAttributes } ); @@ -254,6 +261,8 @@ function MediaTextEdit( { attributes, isSelected, setAttributes } ) { onChange={ ( value ) => setAttributes( { focalPoint: value } ) } + onDragStart={ imperativeFocalPointPreview } + onDrag={ imperativeFocalPointPreview } /> ) } { mediaType === 'image' && ( @@ -325,6 +334,7 @@ function MediaTextEdit( { attributes, isSelected, setAttributes } ) { onSelectMedia={ onSelectMedia } onWidthChange={ onWidthChange } commitWidthChange={ commitWidthChange } + ref={ refMediaContainer } { ...{ focalPoint, imageFill, diff --git a/packages/block-library/src/media-text/media-container.js b/packages/block-library/src/media-text/media-container.js index 373caee6ede527..26a87128096407 100644 --- a/packages/block-library/src/media-text/media-container.js +++ b/packages/block-library/src/media-text/media-container.js @@ -18,6 +18,7 @@ import { import { __ } from '@wordpress/i18n'; import { useViewportMatch } from '@wordpress/compose'; import { useDispatch } from '@wordpress/data'; +import { forwardRef } from '@wordpress/element'; /** * Internal dependencies @@ -40,15 +41,20 @@ export function imageFillStyles( url, focalPoint ) { : {}; } -function ResizableBoxContainer( { isSelected, isStackedOnMobile, ...props } ) { - const isMobile = useViewportMatch( 'small', '<' ); - return ( - - ); -} +const ResizableBoxContainer = forwardRef( + ( { isSelected, isStackedOnMobile, ...props }, ref ) => { + const isMobile = useViewportMatch( 'small', '<' ); + return ( + + ); + } +); function ToolbarEditButton( { mediaId, mediaUrl, onSelectMedia } ) { return ( @@ -91,7 +97,7 @@ function PlaceholderContainer( { ); } -function MediaContainer( props ) { +function MediaContainer( props, ref ) { const { className, commitWidthChange, @@ -155,6 +161,7 @@ function MediaContainer( props ) { axis="x" isSelected={ isSelected } isStackedOnMobile={ isStackedOnMobile } + ref={ ref } > ; } -export default withNotices( MediaContainer ); +export default withNotices( forwardRef( MediaContainer ) ); diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 6fa395a03157ac..4f358b39b0664f 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Breaking Change +- `onChange` prop of `FocalPointPicker` is called at the end of drag operations. Previously, it was called repetitively while dragging. + +### New Feature +- Supports ref forwarding in `withNotices` and `ResizableBox`. +- Adds `onDrag` prop of `FocalPointPicker`. + +### Bug Fix +- Allows focus of the `FocalPointPicker` draggable area and adjustment with arrow keys. This was added in [#22531](https://github.com/WordPress/gutenberg/pull/22264) but was no longer working. + ## 12.0.0 (2020-12-17) ### Enhancements diff --git a/packages/components/src/focal-point-picker/README.md b/packages/components/src/focal-point-picker/README.md index 7989f7e8ec90c9..a707ba39c03086 100644 --- a/packages/components/src/focal-point-picker/README.md +++ b/packages/components/src/focal-point-picker/README.md @@ -60,13 +60,6 @@ URL of the image or video to be displayed Autoplays HTML5 video. This only applies to video sources (`url`). -### `dimensions` - -- Type: `Object` -- Required: Yes - -An object describing the height and width of the image. Requires two parameters: `height`, `width`. - ### `value` - Type: `Object` @@ -80,3 +73,24 @@ The focal point. Should be an object containing `x` and `y` params. - Required: Yes Callback which is called when the focal point changes. + +### `onDrag` + +- Type: `Function` +- Required: No + +Callback which is called repetitively during drag operations. + +### `onDragEnd` + +- Type: `Function` +- Required: No + +Callback which is called at the end of drag operations. + +### `onDragStart` + +- Type: `Function` +- Required: No + +Callback which is called at the start of drag operations. diff --git a/packages/components/src/focal-point-picker/index.js b/packages/components/src/focal-point-picker/index.js index 174fde67c75367..56a8b0e098c962 100644 --- a/packages/components/src/focal-point-picker/index.js +++ b/packages/components/src/focal-point-picker/index.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import { noop } from 'lodash'; import classnames from 'classnames'; /** @@ -9,13 +8,12 @@ import classnames from 'classnames'; */ import { __ } from '@wordpress/i18n'; import { Component, createRef } from '@wordpress/element'; -import { withInstanceId, compose } from '@wordpress/compose'; +import { withInstanceId } from '@wordpress/compose'; import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; /** * Internal dependencies */ -import withFocusOutside from '../higher-order/with-focus-outside'; import BaseControl from '../base-control'; import Controls from './controls'; import FocalPoint from './focal-point'; @@ -41,17 +39,26 @@ export class FocalPointPicker extends Component { this.containerRef = createRef(); this.mediaRef = createRef(); - this.handleOnClick = this.handleOnClick.bind( this ); - this.handleOnMouseUp = this.handleOnMouseUp.bind( this ); - this.handleOnKeyDown = this.handleOnKeyDown.bind( this ); - this.onMouseMove = this.onMouseMove.bind( this ); + this.onMouseDown = this.startDrag.bind( this ); + this.onMouseUp = this.stopDrag.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.onMouseMove = this.doDrag.bind( this ); + this.ifDraggingStop = () => { + if ( this.state.isDragging ) { + this.stopDrag(); + } + }; + this.onChangeAtControls = ( value ) => { + this.updateValue( value ); + this.props.onChange( value ); + }; this.updateBounds = this.updateBounds.bind( this ); this.updateValue = this.updateValue.bind( this ); } componentDidMount() { - document.addEventListener( 'mouseup', this.handleOnMouseUp ); - window.addEventListener( 'resize', this.updateBounds ); + const { defaultView } = this.containerRef.current.ownerDocument; + defaultView.addEventListener( 'resize', this.updateBounds ); /* * Set initial bound values. @@ -63,24 +70,25 @@ export class FocalPointPicker extends Component { } componentDidUpdate( prevProps ) { if ( prevProps.url !== this.props.url ) { - this.setState( { - isDragging: false, - } ); + this.ifDraggingStop(); } /* * Handles cases where the incoming value changes. * An example is the values resetting based on an UNDO action. */ - if ( - this.props.value.x !== this.state.percentages.x || - this.props.value.y !== this.state.percentages.y - ) { + const { + isDragging, + percentages: { x, y }, + } = this.state; + const { value } = this.props; + if ( ! isDragging && ( value.x !== x || value.y !== y ) ) { this.setState( { percentages: this.props.value } ); } } componentWillUnmount() { - document.removeEventListener( 'mouseup', this.handleOnMouseUp ); - window.removeEventListener( 'resize', this.updateBounds ); + const { defaultView } = this.containerRef.current.ownerDocument; + defaultView.removeEventListener( 'resize', this.updateBounds ); + this.ifDraggingStop(); } calculateBounds() { const bounds = INITIAL_BOUNDS; @@ -121,7 +129,6 @@ export class FocalPointPicker extends Component { return bounds; } updateValue( nextValue = {} ) { - const { onChange } = this.props; const { x, y } = nextValue; const nextPercentage = { @@ -129,84 +136,79 @@ export class FocalPointPicker extends Component { y: parseFloat( y ).toFixed( 2 ), }; - this.setState( { percentages: nextPercentage }, () => { - onChange( nextPercentage ); - } ); + this.setState( { percentages: nextPercentage } ); } updateBounds() { this.setState( { bounds: this.calculateBounds(), } ); } - handleOnClick( event ) { + startDrag( event ) { event.persist(); - this.setState( { isDragging: true }, () => { - this.onMouseMove( event ); - } ); + this.containerRef.current.focus(); + this.setState( { isDragging: true } ); + const { ownerDocument } = this.containerRef.current; + ownerDocument.addEventListener( 'mouseup', this.onMouseUp ); + ownerDocument.addEventListener( 'mousemove', this.onMouseMove ); + const value = this.getValueFromPoint( + { x: event.pageX, y: event.pageY }, + event.shiftKey + ); + this.updateValue( value ); + this.props.onDragStart?.( value, event ); } - handleOnMouseUp() { - this.setState( { isDragging: false } ); + stopDrag( event ) { + const { ownerDocument } = this.containerRef.current; + ownerDocument.removeEventListener( 'mouseup', this.onMouseUp ); + ownerDocument.removeEventListener( 'mousemove', this.onMouseMove ); + this.setState( { isDragging: false }, () => { + this.props.onChange( this.state.percentages ); + } ); + this.props.onDragEnd?.( event ); } - handleOnKeyDown( event ) { + onKeyDown( event ) { const { keyCode, shiftKey } = event; if ( ! [ UP, DOWN, LEFT, RIGHT ].includes( keyCode ) ) return; - const { x, y } = this.state.percentages; - event.preventDefault(); - // Normalizing values for incrementing/decrementing based on arrow keys - let nextX = parseFloat( x ); - let nextY = parseFloat( y ); + const next = { ...this.state.percentages }; const step = shiftKey ? 0.1 : 0.01; + const delta = keyCode === UP || keyCode === LEFT ? -1 * step : step; + const axis = keyCode === UP || keyCode === DOWN ? 'y' : 'x'; + const value = parseFloat( next[ axis ] ) + delta; - switch ( event.keyCode ) { - case UP: - nextY = nextY - step; - break; - case DOWN: - nextY = nextY + step; - break; - case LEFT: - nextX = nextX - step; - break; - case RIGHT: - nextX = nextX + step; - break; - } + next[ axis ] = roundClamp( value, 0, 1, step ); - nextX = roundClamp( nextX, 0, 1, step ); - nextY = roundClamp( nextY, 0, 1, step ); - - const percentages = { - x: nextX, - y: nextY, - }; - - this.updateValue( percentages ); + this.updateValue( next ); + this.props.onChange( next ); } - onMouseMove( event ) { - const { isDragging, bounds } = this.state; - - if ( ! isDragging ) return; - + doDrag( event ) { // Prevents text-selection when dragging. event.preventDefault(); + const value = this.getValueFromPoint( + { x: event.pageX, y: event.pageY }, + event.shiftKey + ); + this.updateValue( value ); + this.props.onDrag?.( value, event ); + } + getValueFromPoint( point, byTenths ) { + const { bounds } = this.state; - const { shiftKey } = event; const pickerDimensions = this.pickerDimensions(); - const cursorPosition = { - left: event.pageX - pickerDimensions.left, - top: event.pageY - pickerDimensions.top, + const relativePoint = { + left: point.x - pickerDimensions.left, + top: point.y - pickerDimensions.top, }; const left = Math.max( bounds.left, - Math.min( cursorPosition.left, bounds.right ) + Math.min( relativePoint.left, bounds.right ) ); const top = Math.max( bounds.top, - Math.min( cursorPosition.top, bounds.bottom ) + Math.min( relativePoint.top, bounds.bottom ) ); let nextX = @@ -216,17 +218,12 @@ export class FocalPointPicker extends Component { ( top - bounds.top ) / ( pickerDimensions.height - bounds.top * 2 ); // Enables holding shift to jump values by 10% - const step = shiftKey ? 0.1 : 0.01; + const step = byTenths ? 0.1 : 0.01; nextX = roundClamp( nextX, 0, 1, step ); nextY = roundClamp( nextY, 0, 1, step ); - const nextPercentage = { - x: nextX, - y: nextY, - }; - - this.updateValue( nextPercentage ); + return { x: nextX, y: nextY }; } pickerDimensions() { const containerNode = this.containerRef.current; @@ -251,8 +248,10 @@ export class FocalPointPicker extends Component { }; } iconCoordinates() { - const { value } = this.props; - const { bounds } = this.state; + const { + bounds, + percentages: { x, y }, + } = this.state; if ( bounds.left === undefined || bounds.top === undefined ) { return { @@ -261,23 +260,11 @@ export class FocalPointPicker extends Component { }; } - const pickerDimensions = this.pickerDimensions(); - const iconCoordinates = { - left: - value.x * ( pickerDimensions.width - bounds.left * 2 ) + - bounds.left, - top: - value.y * ( pickerDimensions.height - bounds.top * 2 ) + - bounds.top, + const { width, height } = this.pickerDimensions(); + return { + left: x * ( width - bounds.left * 2 ) + bounds.left, + top: y * ( height - bounds.top * 2 ) + bounds.top, }; - - return iconCoordinates; - } - // Callback method for the withFocusOutside higher-order component - handleFocusOutside() { - this.setState( { - isDragging: false, - } ); } render() { const { @@ -286,8 +273,6 @@ export class FocalPointPicker extends Component { help, instanceId, label, - onDragStart, - onDragEnd, url, } = this.props; const { bounds, isDragging, percentages } = this.state; @@ -310,18 +295,9 @@ export class FocalPointPicker extends Component { { - this.setState( { isDragging: true } ); - onDragStart( event ); - } } - onDrop={ ( event ) => { - this.setState( { isDragging: false } ); - onDragEnd( event ); - } } - onKeyDown={ this.handleOnKeyDown } - onMouseDown={ this.handleOnClick } - onMouseMove={ this.onMouseMove } - onMouseUp={ this.handleOnMouseUp } + onKeyDown={ this.onKeyDown } + onMouseDown={ this.onMouseDown } + onBlur={ this.ifDraggingStop } ref={ this.containerRef } role="button" tabIndex="-1" @@ -345,7 +321,7 @@ export class FocalPointPicker extends Component { ); @@ -354,9 +330,6 @@ export class FocalPointPicker extends Component { FocalPointPicker.defaultProps = { autoPlay: true, - onDragStart: noop, - onDragEnd: noop, - onChange: noop, value: { x: 0.5, y: 0.5, @@ -364,6 +337,4 @@ FocalPointPicker.defaultProps = { url: null, }; -export default compose( [ withInstanceId, withFocusOutside ] )( - FocalPointPicker -); +export default withInstanceId( FocalPointPicker ); diff --git a/packages/components/src/focal-point-picker/styles/focal-point-style.js b/packages/components/src/focal-point-picker/styles/focal-point-style.js index 7a6f416cbe4e0b..0e3f5eef4e0e16 100644 --- a/packages/components/src/focal-point-picker/styles/focal-point-style.js +++ b/packages/components/src/focal-point-picker/styles/focal-point-style.js @@ -26,9 +26,7 @@ export const FocalPointWrapper = styled.div` will-change: transform; z-index: 10000; - &.is-dragging { - cursor: grabbing; - } + ${ ( { isDragging } ) => isDragging && 'cursor: grabbing;' } `; export const PointerIconSVG = styled( SVG )` diff --git a/packages/components/src/focal-point-picker/test/index.js b/packages/components/src/focal-point-picker/test/index.js new file mode 100644 index 00000000000000..d914ff64fc7b7e --- /dev/null +++ b/packages/components/src/focal-point-picker/test/index.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { act, fireEvent, render } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import Picker from '../index.js'; + +describe( 'FocalPointPicker', () => { + describe( 'focus and blur', () => { + let firedDragEnd; + let firedDrag; + const { getByRole, unmount } = render( + {} } + onDragEnd={ () => ( firedDragEnd = true ) } + onDrag={ () => ( firedDrag = true ) } + /> + ); + const dragArea = getByRole( 'button' ); + fireEvent.mouseDown( dragArea ); + const gainedFocus = dragArea.ownerDocument.activeElement === dragArea; + + fireEvent.blur( dragArea ); + fireEvent.mouseMove( dragArea ); + + // cleans up as it's not automated for renders outside of test blocks + unmount(); + + it( 'should focus the draggable area', () => { + expect( gainedFocus ).toBe( true ); + } ); + + it( 'should stop a drag operation when focus is lost', () => { + expect( firedDragEnd && ! firedDrag ).toBe( true ); + } ); + } ); + + describe( 'drag gestures', () => { + it( 'should call onDragStart, onDrag, onDragEnd and onChange in that order', () => { + const logs = []; + const eventLogger = ( name, args ) => logs.push( { name, args } ); + const events = [ 'onDragStart', 'onDrag', 'onDragEnd', 'onChange' ]; + const handlers = {}; + events.forEach( ( name ) => { + handlers[ name ] = ( ...all ) => eventLogger( name, all ); + } ); + const { getByRole } = render( ); + const dragArea = getByRole( 'button' ); + act( () => { + fireEvent.mouseDown( dragArea ); + fireEvent.mouseMove( dragArea ); + fireEvent.mouseUp( dragArea ); + } ); + expect( + events.reduce( ( last, eventName, index ) => { + return last && logs[ index ].name === eventName; + }, true ) + ).toBe( true ); + } ); + } ); + + describe( 'controllability', () => { + it( 'should update value from props', () => { + const { rerender, getByRole } = render( + + ); + const xInput = getByRole( 'spinbutton', { name: 'Left' } ); + rerender( ); + expect( xInput.value ).toBe( '93' ); + } ); + } ); +} ); diff --git a/packages/components/src/higher-order/with-notices/index.js b/packages/components/src/higher-order/with-notices/index.js index 0a1280438282cb..1e0c50e52ae91b 100644 --- a/packages/components/src/higher-order/with-notices/index.js +++ b/packages/components/src/higher-order/with-notices/index.js @@ -6,7 +6,7 @@ import { v4 as uuid } from 'uuid'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { forwardRef, useState } from '@wordpress/element'; import { createHigherOrderComponent } from '@wordpress/compose'; /** @@ -22,87 +22,79 @@ import NoticeList from '../../notice/list'; * @return {WPComponent} Wrapped component. */ export default createHigherOrderComponent( ( OriginalComponent ) => { - return class WrappedBlockEdit extends Component { - constructor() { - super( ...arguments ); + let isForwardRef; + const { render } = OriginalComponent; + // Returns a forwardRef if OriginalComponent appears to be a forwardRef + if ( typeof render === 'function' ) { + isForwardRef = true; + return forwardRef( Component ); + } + return Component; - this.createNotice = this.createNotice.bind( this ); - this.createErrorNotice = this.createErrorNotice.bind( this ); - this.removeNotice = this.removeNotice.bind( this ); - this.removeAllNotices = this.removeAllNotices.bind( this ); + function Component( props, ref ) { + const [ noticeList, setNoticeList ] = useState( [] ); - this.state = { - noticeList: [], - }; + const noticeOperations = { + /** + * Function passed down as a prop that adds a new notice. + * + * @param {Object} notice Notice to add. + */ + createNotice: ( notice ) => { + const noticeToAdd = notice.id + ? notice + : { ...notice, id: uuid() }; + setNoticeList( ( current ) => [ ...current, noticeToAdd ] ); + }, - this.noticeOperations = { - createNotice: this.createNotice, - createErrorNotice: this.createErrorNotice, - removeAllNotices: this.removeAllNotices, - removeNotice: this.removeNotice, - }; - } + /** + * Function passed as a prop that adds a new error notice. + * + * @param {string} msg Error message of the notice. + */ + createErrorNotice: ( msg ) => { + noticeOperations.createNotice( { + status: 'error', + content: msg, + } ); + }, - /** - * Function passed down as a prop that adds a new notice. - * - * @param {Object} notice Notice to add. - */ - createNotice( notice ) { - const noticeToAdd = notice.id ? notice : { ...notice, id: uuid() }; - this.setState( ( state ) => ( { - noticeList: [ ...state.noticeList, noticeToAdd ], - } ) ); - } + /** + * Removes a notice by id. + * + * @param {string} id Id of the notice to remove. + */ + removeNotice: ( id ) => { + setNoticeList( ( current ) => + current.filter( ( notice ) => notice.id !== id ) + ); + }, - /** - * Function passed as a prop that adds a new error notice. - * - * @param {string} msg Error message of the notice. - */ - createErrorNotice( msg ) { - this.createNotice( { status: 'error', content: msg } ); - } + /** + * Removes all notices + */ + removeAllNotices: () => { + setNoticeList( [] ); + }, + }; - /** - * Removes a notice by id. - * - * @param {string} id Id of the notice to remove. - */ - removeNotice( id ) { - this.setState( ( state ) => ( { - noticeList: state.noticeList.filter( - ( notice ) => notice.id !== id - ), - } ) ); - } - - /** - * Removes all notices - */ - removeAllNotices() { - this.setState( { - noticeList: [], - } ); - } - - render() { - return ( - 0 && ( - - ) - } - { ...this.props } + const propsOut = { + ...props, + noticeList, + noticeOperations, + noticeUI: noticeList.length > 0 && ( + - ); - } - }; + ), + }; + + return isForwardRef ? ( + + ) : ( + + ); + } } ); diff --git a/packages/components/src/higher-order/with-notices/test/index.js b/packages/components/src/higher-order/with-notices/test/index.js new file mode 100644 index 00000000000000..5f9f871aabedfb --- /dev/null +++ b/packages/components/src/higher-order/with-notices/test/index.js @@ -0,0 +1,166 @@ +/** + * External dependencies + */ +import { + act, + render, + fireEvent, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import withNotices from '..'; + +// Implementation detail of Notice component used to query the dismissal button +const stockDismissText = 'Dismiss this notice'; + +function noticesFrom( list ) { + return list.map( ( item ) => ( { id: item, content: item } ) ); +} + +function isComponentLike( object ) { + return typeof object === 'function'; +} + +function isForwardRefLike( { render: renderMethod } ) { + return typeof renderMethod === 'function'; +} + +const content = 'Base content'; + +const BaseComponent = ( { noticeOperations, noticeUI, notifications } ) => { + useEffect( () => { + if ( notifications ) { + notifications.forEach( ( item ) => + noticeOperations.createNotice( item ) + ); + } + }, [] ); + return ( +
+ { noticeUI } + { content } +
+ ); +}; + +const TestComponent = withNotices( BaseComponent ); + +const TestNoticeOperations = withNotices( + forwardRef( ( props, ref ) => { + useImperativeHandle( ref, () => ( { ...props.noticeOperations } ) ); + return ; + } ) +); + +describe( 'withNotices return type', () => { + it( 'should be a component given a component', () => { + expect( isComponentLike( TestComponent ) ).toBe( true ); + } ); + + it( 'should be a forwardRef given a forwardRef', () => { + expect( isForwardRefLike( TestNoticeOperations ) ).toBe( true ); + } ); +} ); + +describe( 'withNotices operations', () => { + let handle; + const Handle = ( props ) => { + handle = useRef(); + return ; + }; + + it( 'should create notices with createNotice', () => { + const message = 'Aló!'; + const { container } = render( ); + const { getByText } = within( container ); + act( () => { + handle.current.createNotice( { content: message } ); + } ); + expect( getByText( message ) ).not.toBeNull(); + } ); + + it( 'should create notices of error status with createErrorNotice', () => { + const message = 'can’t touch this'; + const { container } = render( ); + const { getByText } = within( container ); + act( () => { + handle.current.createErrorNotice( message ); + } ); + expect( getByText( message )?.closest( '.is-error' ) ).not.toBeNull(); + } ); + + it( 'should remove a notice with removeNotice', async () => { + const notice = { id: 'so real', content: 'so why can’t I touch it?' }; + const { container } = render( ); + const { getByText } = within( container ); + act( () => { + handle.current.createNotice( notice ); + } ); + expect( + await waitForElementToBeRemoved( () => { + const target = getByText( notice.content ); + act( () => handle.current.removeNotice( notice.id ) ); + return target; + } ).then( () => true ) + ).toBe( true ); + } ); + + it( 'should remove all notices with removeAllNotices', async () => { + const messages = [ 'Aló!', 'hu dis?', 'Otis' ]; + const notices = noticesFrom( messages ); + const { container } = render( ); + const { getByText } = within( container ); + expect( + await waitForElementToBeRemoved( () => { + const targets = notices.map( ( notice ) => + getByText( notice.content ) + ); + act( () => handle.current.removeAllNotices() ); + return targets; + } ).then( () => true ) + ).toBe( true ); + } ); +} ); + +describe( 'withNotices rendering', () => { + it( 'should display the original component given no notices', () => { + const { container } = render( ); + expect( container.innerHTML ).toBe( `
${ content }
` ); + } ); + + it( 'should display notices with functioning dismissal triggers', async () => { + const messages = [ 'Aló!', 'hu dis?', 'Otis' ]; + const notices = noticesFrom( messages ); + const { container, getAllByLabelText } = render( + + ); + const [ buttonRemoveFirst ] = getAllByLabelText( stockDismissText ); + const getRemovalTarget = () => + within( container ).getByText( + // the last item corresponds to the first notice in the DOM + messages[ messages.length - 1 ] + ); + expect( + await waitForElementToBeRemoved( () => { + const target = getRemovalTarget(); + // Removes the first notice in the DOM + fireEvent.click( buttonRemoveFirst ); + return target; + } ).then( () => true ) + ).toBe( true ); + } ); +} ); diff --git a/packages/components/src/resizable-box/index.js b/packages/components/src/resizable-box/index.js index 38a780f8e71b6b..3acae66719cfc6 100644 --- a/packages/components/src/resizable-box/index.js +++ b/packages/components/src/resizable-box/index.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + /** * External dependencies */ @@ -9,14 +14,17 @@ import { Resizable } from 're-resizable'; */ import ResizeTooltip from './resize-tooltip'; -function ResizableBox( { - className, - children, - showHandle = true, - __experimentalShowTooltip: showTooltip = false, - __experimentalTooltipProps: tooltipProps = {}, - ...props -} ) { +function ResizableBox( + { + className, + children, + showHandle = true, + __experimentalShowTooltip: showTooltip = false, + __experimentalTooltipProps: tooltipProps = {}, + ...props + }, + ref +) { // Removes the inline styles in the drag handles. const handleStylesOverrides = { width: null, @@ -94,6 +102,7 @@ function ResizableBox( { bottomRight: handleStylesOverrides, bottomLeft: handleStylesOverrides, } } + ref={ ref } { ...props } > { children } @@ -102,4 +111,4 @@ function ResizableBox( { ); } -export default ResizableBox; +export default forwardRef( ResizableBox );