diff --git a/.eslintrc.js b/.eslintrc.js index 1c42e00ba5881..44022b3b44bb3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,8 +26,8 @@ const majorMinorRegExp = */ const developmentFiles = [ '**/benchmark/**/*.js', - '**/@(__mocks__|__tests__|test)/**/*.js', - '**/@(storybook|stories)/**/*.js', + '**/@(__mocks__|__tests__|test)/**/*.[tj]s?(x)', + '**/@(storybook|stories)/**/*.[tj]s?(x)', 'packages/babel-preset-default/bin/**/*.js', ]; diff --git a/docs/manifest.json b/docs/manifest.json index 91bebab753a3b..ecea478d1546c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -629,6 +629,12 @@ "markdown_source": "../packages/components/src/box-control/README.md", "parent": "components" }, + { + "title": "BoxModelOverlay", + "slug": "box-model-overlay", + "markdown_source": "../packages/components/src/box-model-overlay/README.md", + "parent": "components" + }, { "title": "ButtonGroup", "slug": "button-group", diff --git a/packages/components/src/box-model-overlay/README.md b/packages/components/src/box-model-overlay/README.md new file mode 100644 index 0000000000000..5bb7aa592a09a --- /dev/null +++ b/packages/components/src/box-model-overlay/README.md @@ -0,0 +1,158 @@ +# BoxModelOverlay + +
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +`` component shows a visual overlay of the [box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) (currently only paddings and margins are available) on top of the target element. This is often accompanied by the `` component to show a preview of the styling changes in the editor. + +## Usage + +Wrap `` with `` with the `showValues` prop. +Note that `` should accept `ref` for `` to automatically inject into. + +```jsx +import { __experimentalBoxModelOverlay as BoxModelOverlay } from '@wordpress/components'; + +// Show all overlays and all sides. +const showValues = { + margin: { + top: true, + right: true, + bottom: true, + left: true, + }, + padding: { + top: true, + right: true, + bottom: true, + left: true, + }, +}; + +const Example = () => { + return ( + + + + ); +}; +``` + +You can also use the `targetRef` prop to manually pass the ref to `` for more advanced usage. This is useful if you need to control where the overlay is rendered or need special handling for the target's `ref`. + +```jsx +const Example = () => { + const targetRef = useRef(); + + return ( + <> + + + + + ); +}; +``` + +`` internally uses [`Popover`](https://github.com/WordPress/gutenberg/blob/HEAD/packages/components/src/popover/README.md) to position the overlay. This means that you can use `` to alternatively control where the overlay is rendered. + +```jsx +const Example = () => { + return ( + <> + + + + + + + ); +}; +``` + +`` under the hood listens to size and style changes of the target element to update the overlay style automatically using `ResizeObserver` and `MutationObserver`. In some edge cases when the observers aren't picking up the changes, you can use the instance method `update` on the ref of the overlay to update it manually. + +```jsx +const Example = () => { + const overlayRef = useRef(); + + // Update the overlay style manually when `deps` changes. + useEffect( () => { + overlayRef.current.update(); + }, [ deps ] ); + + return ( + + + + ); +}; +``` + +Here's an example of using it with ``: + +```jsx +const Example = () => { + const [ values, setValues ] = useState( { + top: '50px', + right: '10%', + bottom: '50px', + left: '10%', + } ); + const [ showValues, setShowValues ] = useState( {} ); + + return ( + <> + setValues( nextValues ) } + onChangeShowVisualizer={ setShowValues } + /> + + +
+ + + ); +}; +``` + +## Props + +Additional props not listed below will be passed to the underlying `Popover` component. + +### `showValues` + +Controls which overlays and sides are visible. Currently the only properties supported are `margin` and `padding`, each with four sides (`top`, `right`, `bottom`, `left`). + +- Type: `Object` +- Required: Yes +- Default: `{}` + +### `children` + +A single React element to rendered as the target. It should implicitly accept `ref` to be passed in. + +- Type: `React.ReactElement` +- Required: Yes if `targetRef` is not passed + +### `targetRef` + +A ref object for the target element. + +- Type: `Ref` +- Required: Yes if `children` is not passed diff --git a/packages/components/src/box-model-overlay/index.tsx b/packages/components/src/box-model-overlay/index.tsx new file mode 100644 index 0000000000000..cb89dbff52562 --- /dev/null +++ b/packages/components/src/box-model-overlay/index.tsx @@ -0,0 +1,297 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; + +/** + * WordPress dependencies + */ +import { + useRef, + useLayoutEffect, + useMemo, + useCallback, + forwardRef, + useImperativeHandle, + cloneElement, + Children, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Popover from '../popover'; +import type { + BoxModelOverlayProps, + BoxModelOverlayPropsWithChildren, + BoxModelOverlayPropsWithTargetRef, + BoxModelOverlayHandle, +} from './types'; + +const DEFAULT_SHOW_VALUES: BoxModelOverlayProps[ 'showValues' ] = {}; + +// Copied from Chrome's DevTools: https://github.com/ChromeDevTools/devtools-frontend/blob/088a8f175bd58f2e0e2d492e991a3253124d7c11/front_end/core/common/Color.ts#L931 +const MARGIN_COLOR = 'rgba( 246, 178, 107, 0.66 )'; +// Copied from Chrome's DevTools: https://github.com/ChromeDevTools/devtools-frontend/blob/088a8f175bd58f2e0e2d492e991a3253124d7c11/front_end/core/common/Color.ts#L927 +const PADDING_COLOR = 'rgba( 147, 196, 125, 0.55 )'; + +const OverlayPopover = styled( Popover )` + && { + pointer-events: none; + box-sizing: content-box; + border-style: solid; + border-color: ${ MARGIN_COLOR }; + // The overlay's top-left point is positioned at the center of the target, + // so we'll have add some negative offsets. + transform: translate( -50%, -50% ); + + &::before { + content: ''; + display: block; + position: absolute; + box-sizing: border-box; + height: var( --wp-box-model-overlay-height ); + width: var( --wp-box-model-overlay-width ); + top: var( --wp-box-model-overlay-top ); + left: var( --wp-box-model-overlay-left ); + border-color: ${ PADDING_COLOR }; + border-style: solid; + border-width: var( --wp-box-model-overlay-padding-top ) + var( --wp-box-model-overlay-padding-right ) + var( --wp-box-model-overlay-padding-bottom ) + var( --wp-box-model-overlay-padding-left ); + } + + .components-popover__content { + display: none; + } + } +`; + +const BoxModelOverlayWithRef = forwardRef< + BoxModelOverlayHandle, + BoxModelOverlayPropsWithTargetRef +>( ( { showValues = DEFAULT_SHOW_VALUES, targetRef, ...props }, ref ) => { + const overlayRef = useRef< HTMLDivElement >(); + + const update = useCallback( () => { + const target = targetRef.current; + const overlay = overlayRef.current; + + if ( ! target || ! overlay ) { + return; + } + + const defaultView = target.ownerDocument.defaultView; + + const domRect = target.getBoundingClientRect(); + const { + paddingTop, + paddingBottom, + paddingLeft, + paddingRight, + marginTop, + marginRight, + marginBottom, + marginLeft, + borderTopWidth, + borderRightWidth, + borderBottomWidth, + borderLeftWidth, + } = defaultView.getComputedStyle( target ); + + overlay.style.height = `${ domRect.height }px`; + overlay.style.width = `${ domRect.width }px`; + + // Setting margin overlays by using borders as the visual representation. + const borderWidths = { + top: showValues.margin?.top ? parseInt( marginTop, 10 ) : 0, + right: showValues.margin?.right ? parseInt( marginRight, 10 ) : 0, + bottom: showValues.margin?.bottom + ? parseInt( marginBottom, 10 ) + : 0, + left: showValues.margin?.left ? parseInt( marginLeft, 10 ) : 0, + }; + overlay.style.borderWidth = [ + borderWidths.top, + borderWidths.right, + borderWidths.bottom, + borderWidths.left, + ] + .map( ( px ) => `${ px }px` ) + .join( ' ' ); + + // The overlay will always position itself at the center of the target, + // but the overlay could have different size than the target because of the + // borders we added above. + // We want to "cancel out" those offsets by doing a `transform: translate`. + overlay.style.transform = `translate(calc(-50% + ${ + ( borderWidths.right - borderWidths.left ) / 2 + }px), calc(-50% + ${ + ( borderWidths.bottom - borderWidths.top ) / 2 + }px))`; + + // Set pseudo element's position to take account for borders. + overlay.style.setProperty( + '--wp-box-model-overlay-height', + `${ + domRect.height - + parseInt( borderTopWidth, 10 ) - + parseInt( borderBottomWidth, 10 ) + }px` + ); + overlay.style.setProperty( + '--wp-box-model-overlay-width', + `${ + domRect.width - + parseInt( borderLeftWidth, 10 ) - + parseInt( borderRightWidth, 10 ) + }px` + ); + overlay.style.setProperty( + '--wp-box-model-overlay-top', + borderTopWidth + ); + overlay.style.setProperty( + '--wp-box-model-overlay-left', + borderLeftWidth + ); + + // Setting padding values via CSS custom properties so that they can + // be applied in the pseudo elements. + overlay.style.setProperty( + '--wp-box-model-overlay-padding-top', + showValues.padding?.top ? paddingTop : '0' + ); + overlay.style.setProperty( + '--wp-box-model-overlay-padding-right', + showValues.padding?.right ? paddingRight : '0' + ); + overlay.style.setProperty( + '--wp-box-model-overlay-padding-bottom', + showValues.padding?.bottom ? paddingBottom : '0' + ); + overlay.style.setProperty( + '--wp-box-model-overlay-padding-left', + showValues.padding?.left ? paddingLeft : '0' + ); + }, [ targetRef, showValues.margin, showValues.padding ] ); + + // Make the imperative `update` method available via `ref`. + useImperativeHandle( ref, () => ( { update } ), [ update ] ); + + const getAnchorRect = useCallback( + () => targetRef.current.getBoundingClientRect(), + [ targetRef ] + ); + + // Completely skip rendering the popover if none of showValues is true. + const shouldShowOverlay = useMemo( + () => + Object.values( showValues.margin ?? {} ).some( + ( value ) => value === true + ) || + Object.values( showValues.padding ?? {} ).some( + ( value ) => value === true + ), + [ showValues.margin, showValues.padding ] + ); + + useLayoutEffect( () => { + const target = targetRef.current; + + if ( ! shouldShowOverlay || ! target ) { + return; + } + + const defaultView = target.ownerDocument.defaultView; + + update(); + + const resizeObserver = new defaultView.ResizeObserver( update ); + const mutationObserver = new defaultView.MutationObserver( update ); + + // Observing size changes. + resizeObserver.observe( target, { box: 'border-box' } ); + // Observing padding and margin changes. + mutationObserver.observe( target, { + attributes: true, + attributeFilter: [ 'style' ], + } ); + + // Percentage paddings are based on parent element's width, + // so we need to also listen to the parent's size changes. + const parentElement = target.parentElement; + let parentResizeObserver: ResizeObserver; + if ( parentElement ) { + parentResizeObserver = new defaultView.ResizeObserver( update ); + parentResizeObserver.observe( parentElement, { + box: 'content-box', + } ); + } + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + if ( parentResizeObserver ) { + parentResizeObserver.disconnect(); + } + }; + }, [ targetRef, shouldShowOverlay, update ] ); + + return shouldShowOverlay ? ( +