From 19912ec7b78f186e81c091ed3134d4cf0e2f8421 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Thu, 16 Dec 2021 00:12:36 +1100 Subject: [PATCH] Query Loop: Add useBlockPreview, fix Query Loop wide alignment in the editor (#36431) * Add useBlockPreview, fix Query Loop wide alignment in the editor * Add test for useDisabled * Add minimal test for useBlockPreview hook * Add useDisabled test to cover updates to a component * Update useBlockPreview test to ensure elements within the block content are disabled * Switch queryBy to getBy to ensure check for block is stricter * Remove comment * Export useBlockPreview as __experimentalUseBlockPreview * Optimise block preview via memoizing the component, and always rendering a preview for each block context * Tidy up settings useMemo Co-authored-by: Ramon * Add documentation for useDisabled hook * Move documentation to the hook Co-authored-by: Ramon --- .../src/components/block-preview/index.js | 60 +++++++++ .../src/components/block-preview/style.scss | 23 ++++ .../components/block-preview/test/index.js | 114 ++++++++++++++++++ packages/block-editor/src/components/index.js | 5 +- .../block-library/src/post-template/edit.js | 71 ++++++++--- .../compose/src/hooks/use-disabled/index.js | 113 +++++++++++++++++ .../src/hooks/use-disabled/test/index.js | 90 ++++++++++++++ packages/compose/src/index.js | 1 + 8 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 packages/block-editor/src/components/block-preview/test/index.js create mode 100644 packages/compose/src/hooks/use-disabled/index.js create mode 100644 packages/compose/src/hooks/use-disabled/test/index.js diff --git a/packages/block-editor/src/components/block-preview/index.js b/packages/block-editor/src/components/block-preview/index.js index cea2e8ee9b62c..b43d8e9a74abd 100644 --- a/packages/block-editor/src/components/block-preview/index.js +++ b/packages/block-editor/src/components/block-preview/index.js @@ -2,10 +2,15 @@ * External dependencies */ import { castArray } from 'lodash'; +import classnames from 'classnames'; /** * WordPress dependencies */ +import { + __experimentalUseDisabled as useDisabled, + useMergeRefs, +} from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { memo, useMemo } from '@wordpress/element'; @@ -16,6 +21,7 @@ import BlockEditorProvider from '../provider'; import LiveBlockPreview from './live'; import AutoHeightBlockPreview from './auto'; import { store as blockEditorStore } from '../../store'; +import { BlockListItems } from '../block-list'; export function BlockPreview( { blocks, @@ -63,3 +69,57 @@ export function BlockPreview( { * @return {WPComponent} The component to be rendered. */ export default memo( BlockPreview ); + +/** + * This hook is used to lightly mark an element as a block preview wrapper + * element. Call this hook and pass the returned props to the element to mark as + * a block preview wrapper, automatically rendering inner blocks as children. If + * you define a ref for the element, it is important to pass the ref to this + * hook, which the hook in turn will pass to the component through the props it + * returns. Optionally, you can also pass any other props through this hook, and + * they will be merged and returned. + * + * @param {Object} options Preview options. + * @param {WPBlock[]} options.blocks Block objects. + * @param {Object} options.props Optional. Props to pass to the element. Must contain + * the ref if one is defined. + * @param {Object} options.__experimentalLayout Layout settings to be used in the preview. + * + */ +export function useBlockPreview( { + blocks, + props = {}, + __experimentalLayout, +} ) { + const originalSettings = useSelect( + ( select ) => select( blockEditorStore ).getSettings(), + [] + ); + const disabledRef = useDisabled(); + const ref = useMergeRefs( [ props.ref, disabledRef ] ); + const settings = useMemo( + () => ( { ...originalSettings, __experimentalBlockPatterns: [] } ), + [ originalSettings ] + ); + const renderedBlocks = useMemo( () => castArray( blocks ), [ blocks ] ); + + const children = ( + + + + ); + + return { + ...props, + ref, + className: classnames( + props.className, + 'block-editor-block-preview__live-content', + 'components-disabled' + ), + children: blocks?.length ? children : null, + }; +} diff --git a/packages/block-editor/src/components/block-preview/style.scss b/packages/block-editor/src/components/block-preview/style.scss index bbcccc58a7d8a..7c522fec5c38c 100644 --- a/packages/block-editor/src/components/block-preview/style.scss +++ b/packages/block-editor/src/components/block-preview/style.scss @@ -41,3 +41,26 @@ .block-editor-block-preview__content-iframe .block-list-appender { display: none; } + +.block-editor-block-preview__live-content { + * { + pointer-events: none; + } + + // Hide the block appender, as the block is not editable in this context. + .block-list-appender { + display: none; + } + + // Revert button disable styles to ensure that button styles render as they will on the + // front end of the site. For example, this ensures that Social Links icons display correctly. + .components-button:disabled { + opacity: initial; + } + + // Hide placeholders. + .components-placeholder, + .block-editor-block-list__block[data-empty="true"] { + display: none; + } +} diff --git a/packages/block-editor/src/components/block-preview/test/index.js b/packages/block-editor/src/components/block-preview/test/index.js new file mode 100644 index 0000000000000..9dcd070b83d69 --- /dev/null +++ b/packages/block-editor/src/components/block-preview/test/index.js @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + createBlock, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { useBlockPreview } from '../'; + +jest.mock( '@wordpress/dom', () => { + const focus = jest.requireActual( '../../../../../dom/src' ).focus; + + return { + focus: { + ...focus, + focusable: { + ...focus.focusable, + find( context ) { + // In JSDOM, all elements have zero'd widths and height. + // This is a metric for focusable's `isVisible`, so find + // and apply an arbitrary non-zero width. + Array.from( context.querySelectorAll( '*' ) ).forEach( + ( element ) => { + Object.defineProperties( element, { + offsetWidth: { + get: () => 1, + configurable: true, + }, + } ); + } + ); + + return focus.focusable.find( ...arguments ); + }, + }, + }, + }; +} ); + +describe( 'useBlockPreview', () => { + beforeAll( () => { + registerBlockType( 'core/test-block', { + save: () => ( +
+ Test block save view + +
+ ), + edit: () => ( +
+ Test block edit view + +
+ ), + category: 'text', + title: 'test block', + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-block' ); + } ); + + function BlockPreviewComponent( { blocks, className } ) { + const blockPreviewProps = useBlockPreview( { + blocks, + props: { className }, + } ); + return
; + } + + it( 'will render a block preview with minimal nesting', async () => { + const blocks = []; + blocks.push( createBlock( 'core/test-block' ) ); + + const { container } = render( + + ); + + // Test block and block contents are rendered. + const previewedBlock = screen.getByLabelText( 'Block: test block' ); + const previewedBlockContents = screen.getByText( + 'Test block edit view' + ); + expect( previewedBlockContents ).toBeInTheDocument(); + + // Test elements within block contents are disabled. + await waitFor( () => { + const button = screen.getByText( 'Button' ); + expect( button.hasAttribute( 'disabled' ) ).toBe( true ); + } ); + + // Ensure the block preview class names are merged with the component's class name. + expect( container.firstChild.className ).toBe( + 'test-container-classname block-editor-block-preview__live-content components-disabled' + ); + + // Ensure there is no nesting between the parent component and rendered blocks. + expect( container.firstChild.firstChild ).toBe( previewedBlock ); + } ); +} ); diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index dcccb216217e3..d725e185ca405 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -103,7 +103,10 @@ export { default as BlockList } from './block-list'; export { useBlockProps } from './block-list/use-block-props'; export { LayoutStyle as __experimentalLayoutStyle } from './block-list/layout'; export { default as BlockMover } from './block-mover'; -export { default as BlockPreview } from './block-preview'; +export { + default as BlockPreview, + useBlockPreview as __experimentalUseBlockPreview, +} from './block-preview'; export { default as BlockSelectionClearer, useBlockSelectionClearer as __unstableUseBlockSelectionClearer, diff --git a/packages/block-library/src/post-template/edit.js b/packages/block-library/src/post-template/edit.js index 75c7046eba142..59a3e306dd247 100644 --- a/packages/block-library/src/post-template/edit.js +++ b/packages/block-library/src/post-template/edit.js @@ -6,12 +6,12 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useState, useMemo } from '@wordpress/element'; +import { memo, useMemo, useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { BlockContextProvider, - BlockPreview, + __experimentalUseBlockPreview as useBlockPreview, useBlockProps, useInnerBlocksProps, store as blockEditorStore, @@ -30,6 +30,39 @@ function PostTemplateInnerBlocks() { return
  • ; } +function PostTemplateBlockPreview( { + blocks, + blockContextId, + isHidden, + setActiveBlockContextId, +} ) { + const blockPreviewProps = useBlockPreview( { + blocks, + } ); + + const handleOnClick = () => { + setActiveBlockContextId( blockContextId ); + }; + + const style = { + display: isHidden ? 'none' : undefined, + }; + + return ( +
  • + ); +} + +const MemoizedPostTemplateBlockPreview = memo( PostTemplateBlockPreview ); + export default function PostTemplateEdit( { clientId, context: { @@ -53,7 +86,7 @@ export default function PostTemplateEdit( { }, } ) { const [ { page } ] = queryContext; - const [ activeBlockContext, setActiveBlockContext ] = useState(); + const [ activeBlockContextId, setActiveBlockContextId ] = useState(); const { posts, blocks } = useSelect( ( select ) => { @@ -115,7 +148,6 @@ export default function PostTemplateEdit( { templateSlug, ] ); - const blockContexts = useMemo( () => posts?.map( ( post ) => ( { @@ -144,6 +176,10 @@ export default function PostTemplateEdit( { return

    { __( 'No results found.' ) }

    ; } + // To avoid flicker when switching active block contexts, a preview is rendered + // for each block context, but the preview for the active block context is hidden. + // This ensures that when it is displayed again, the cached rendering of the + // block preview is used, instead of having to re-render the preview from scratch. return (
      { blockContexts && @@ -152,20 +188,21 @@ export default function PostTemplateEdit( { key={ blockContext.postId } value={ blockContext } > - { blockContext === - ( activeBlockContext || blockContexts[ 0 ] ) ? ( + { blockContext.postId === + ( activeBlockContextId || + blockContexts[ 0 ]?.postId ) ? ( - ) : ( -
    • - - setActiveBlockContext( blockContext ) - } - /> -
    • - ) } + ) : null } + ) ) }
    diff --git a/packages/compose/src/hooks/use-disabled/index.js b/packages/compose/src/hooks/use-disabled/index.js new file mode 100644 index 0000000000000..8cb44e39a6798 --- /dev/null +++ b/packages/compose/src/hooks/use-disabled/index.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import { includes, debounce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useCallback, useLayoutEffect, useRef } from '@wordpress/element'; +import { focus } from '@wordpress/dom'; + +/** + * Names of control nodes which qualify for disabled behavior. + * + * See WHATWG HTML Standard: 4.10.18.5: "Enabling and disabling form controls: the disabled attribute". + * + * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute + * + * @type {string[]} + */ +const DISABLED_ELIGIBLE_NODE_NAMES = [ + 'BUTTON', + 'FIELDSET', + 'INPUT', + 'OPTGROUP', + 'OPTION', + 'SELECT', + 'TEXTAREA', +]; + +/** + * 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. + * + * @return {import('react').RefObject} Element Ref. + * + * @example + * ```js + * import { __experimentalUseDisabled as useDisabled } from '@wordpress/compose'; + * const DisabledExample = () => { + * const disabledRef = useDisabled(); + * return ( + * + * ); + * }; + * ``` + */ +export default function useDisabled() { + /** @type {import('react').RefObject} */ + const node = useRef( null ); + + const disable = () => { + if ( ! node.current ) { + return; + } + + focus.focusable.find( node.current ).forEach( ( focusable ) => { + if ( + includes( DISABLED_ELIGIBLE_NODE_NAMES, focusable.nodeName ) + ) { + focusable.setAttribute( 'disabled', '' ); + } + + if ( focusable.nodeName === 'A' ) { + focusable.setAttribute( 'tabindex', '-1' ); + } + + const tabIndex = focusable.getAttribute( 'tabindex' ); + if ( tabIndex !== null && tabIndex !== '-1' ) { + focusable.removeAttribute( 'tabindex' ); + } + + if ( focusable.hasAttribute( 'contenteditable' ) ) { + focusable.setAttribute( 'contenteditable', 'false' ); + } + } ); + }; + + // Debounce re-disable since disabling process itself will incur + // additional mutations which should be ignored. + const debouncedDisable = useCallback( + debounce( disable, undefined, { leading: true } ), + [] + ); + + useLayoutEffect( () => { + disable(); + + /** @type {MutationObserver | undefined} */ + let observer; + if ( node.current ) { + observer = new window.MutationObserver( debouncedDisable ); + observer.observe( node.current, { + childList: true, + attributes: true, + subtree: true, + } ); + } + + return () => { + if ( observer ) { + observer.disconnect(); + } + debouncedDisable.cancel(); + }; + }, [] ); + + return node; +} diff --git a/packages/compose/src/hooks/use-disabled/test/index.js b/packages/compose/src/hooks/use-disabled/test/index.js new file mode 100644 index 0000000000000..45bd139965a81 --- /dev/null +++ b/packages/compose/src/hooks/use-disabled/test/index.js @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useDisabled from '../'; + +jest.mock( '@wordpress/dom', () => { + const focus = jest.requireActual( '../../../../../dom/src' ).focus; + + return { + focus: { + ...focus, + focusable: { + ...focus.focusable, + find( context ) { + // In JSDOM, all elements have zero'd widths and height. + // This is a metric for focusable's `isVisible`, so find + // and apply an arbitrary non-zero width. + Array.from( context.querySelectorAll( '*' ) ).forEach( + ( element ) => { + Object.defineProperties( element, { + offsetWidth: { + get: () => 1, + configurable: true, + }, + } ); + } + ); + + return focus.focusable.find( ...arguments ); + }, + }, + }, + }; +} ); + +describe( 'useDisabled', () => { + const Form = forwardRef( ( { showButton }, ref ) => { + return ( +
    + + A link +

    + { showButton && } +
    + ); + } ); + + function DisabledComponent( props ) { + const disabledRef = useDisabled(); + return
    ; + } + + it( 'will disable all fields', () => { + const { container } = render( ); + + const input = screen.getByRole( 'textbox' ); + const link = screen.getByRole( 'link' ); + const p = container.querySelector( 'p' ); + + expect( input.hasAttribute( 'disabled' ) ).toBe( true ); + expect( link.getAttribute( 'tabindex' ) ).toBe( '-1' ); + expect( p.getAttribute( 'contenteditable' ) ).toBe( 'false' ); + expect( p.hasAttribute( 'tabindex' ) ).toBe( false ); + expect( p.hasAttribute( 'disabled' ) ).toBe( false ); + } ); + + it( 'will disable an element rendered in an update to the component', async () => { + const { rerender } = render( + + ); + + expect( screen.queryByText( 'Button' ) ).not.toBeInTheDocument(); + rerender( ); + + const button = screen.getByText( 'Button' ); + await waitFor( () => { + expect( button.hasAttribute( 'disabled' ) ).toBe( true ); + } ); + } ); +} ); diff --git a/packages/compose/src/index.js b/packages/compose/src/index.js index 2ce3a2ab33f6f..2bfc7c7f5f848 100644 --- a/packages/compose/src/index.js +++ b/packages/compose/src/index.js @@ -17,6 +17,7 @@ export { default as useConstrainedTabbing } from './hooks/use-constrained-tabbin 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 __experimentalUseDisabled } 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';