Skip to content

Commit

Permalink
Block editor: placeholders: try admin shadow
Browse files Browse the repository at this point in the history
  • Loading branch information
ellatrix committed Dec 14, 2021
1 parent 6dcf97e commit 1b15d4c
Show file tree
Hide file tree
Showing 48 changed files with 459 additions and 212 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/block-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,19 @@ _Related_

Undocumented declaration.

### Placeholder

Placeholder for use in blocks. Creates an admin styling context and a tabbing
context in the block editor's writing flow.

_Parameters_

- _props_ `Object`:

_Returns_

- `WPComponent`: The component

### PlainText

_Related_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function useInitialPosition( clientId ) {
export function useFocusFirstElement( clientId ) {
const ref = useRef();
const initialPosition = useInitialPosition( clientId );
const isMounting = useRef( true );

useEffect( () => {
if ( initialPosition === undefined || initialPosition === null ) {
Expand All @@ -79,16 +80,25 @@ export function useFocusFirstElement( clientId ) {
return;
}

// Find all tabbables within node.
const textInputs = focus.tabbable
.find( ref.current )
.filter( ( node ) => isTextField( node ) );
let target = ref.current;

// If reversed (e.g. merge via backspace), use the last in the set of
// tabbables.
const isReverse = -1 === initialPosition;
const target =
( isReverse ? last : first )( textInputs ) || ref.current;

// Find all text fields or placeholders within the block.
const candidates = focus.tabbable
.find( target )
.filter(
( node ) =>
isTextField( node ) ||
( isMounting.current &&
node.classList.contains(
'wp-block-editor-placeholder'
) )
);

target = ( isReverse ? last : first )( candidates ) || target;

if ( ! isInsideRootBlock( ref.current, target ) ) {
ref.current.focus();
Expand All @@ -98,5 +108,9 @@ export function useFocusFirstElement( clientId ) {
placeCaretAtHorizontalEdge( target, isReverse );
}, [ initialPosition ] );

useEffect( () => {
isMounting.current = false;
}, [] );

return ref;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import classnames from 'classnames';
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Placeholder } from '@wordpress/components';
import { Button } from '@wordpress/components';
import { layout } from '@wordpress/icons';

/**
* Internal dependencies
*/
import Placeholder from '../placeholder';

function BlockVariationPicker( {
icon = layout,
label = __( 'Choose variation' ),
Expand Down
157 changes: 157 additions & 0 deletions packages/block-editor/src/components/embedded-admin-context/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* WordPress dependencies
*/
import {
useRefEffect,
useConstrainedTabbing,
useMergeRefs,
} from '@wordpress/compose';
import { useState, createPortal } from '@wordpress/element';
import { ENTER, SPACE, ESCAPE } from '@wordpress/keycodes';
import { focus } from '@wordpress/dom';
import { __experimentalStyleProvider as StyleProvider } from '@wordpress/components';

/**
* Embeds the given children in shadow DOM that has the same styling as the top
* window (admin). A button is returned to allow the keyboard user to enter this
* context. Visually, it appears inline, but it is styled as the admin, not as
* the editor content.
*
* @param {Object} props Button props.
*
* @return {WPComponent} A button to enter the embedded admin context.
*/
export default function EmbeddedAdminContext( props ) {
const [ shadow, setShadow ] = useState();
const [ hasFocus, setHasFocus ] = useState();
const ref = useRefEffect( ( element ) => {
const root = element.attachShadow( { mode: 'open' } );

// Copy all admin styles to the shadow DOM.
const style = document.createElement( 'style' );
Array.from( document.styleSheets ).forEach( ( styleSheet ) => {
// Technically, it's fine to include this, but these are styles that
// target other components, so there's performance gain in not
// including them. Below, we use `StyleProvider` to render emotion
// styles in shadow DOM.
if ( styleSheet.ownerNode.getAttribute( 'data-emotion' ) ) {
return;
}

// Try to avoid requests for stylesheets of which we already
// know the CSS rules.
try {
let cssText = '';

for ( const cssRule of styleSheet.cssRules ) {
cssText += cssRule.cssText;
}

style.textContent += cssText;
} catch ( e ) {
root.appendChild( styleSheet.ownerNode.cloneNode( true ) );
}
} );
root.appendChild( style );
setShadow( root );

function onFocusIn() {
setHasFocus( true );
}

function onFocusOut() {
setHasFocus( false );
}

/**
* When pressing ENTER or SPACE on the wrapper (button), focus the first
* tabbable inside the shadow DOM.
*
* @param {KeyboardEvent} event The keyboard event.
*/
function onKeyDown( event ) {
if ( element !== event.path[ 0 ] ) return;
if ( event.keyCode !== ENTER && event.keyCode !== SPACE ) return;

event.preventDefault();

const [ firstTabbable ] = focus.tabbable.find( root );
if ( firstTabbable ) firstTabbable.focus();
}

/**
* When pressing ESCAPE inside the shadow DOM, focus the wrapper
* (button).
*
* @param {KeyboardEvent} event The keyboard event.
*/
function onRootKeyDown( event ) {
if ( event.keyCode !== ESCAPE ) return;

root.host.focus();
event.preventDefault();
}

let timeoutId;

/**
* When clicking inside the shadow DOM, temporarily remove the ability
* to catch focus, so focus moves to a focusable parent.
* This is done so that when the user clicks inside a placeholder, the
* block receives focus, which can handle delete, enter, etc.
*/
function onMouseDown() {
element.removeAttribute( 'tabindex' );
timeoutId = setTimeout( () =>
element.setAttribute( 'tabindex', '0' )
);
}

root.addEventListener( 'focusin', onFocusIn );
root.addEventListener( 'focusout', onFocusOut );
root.addEventListener( 'keydown', onRootKeyDown );
element.addEventListener( 'keydown', onKeyDown );
element.addEventListener( 'mousedown', onMouseDown );
return () => {
root.removeEventListener( 'focusin', onFocusIn );
root.removeEventListener( 'focusout', onFocusOut );
root.removeEventListener( 'keydown', onRootKeyDown );
element.removeEventListener( 'keydown', onKeyDown );
element.removeEventListener( 'mousedown', onMouseDown );
clearTimeout( timeoutId );
};
}, [] );

const dialogRef = useRefEffect( ( element ) => {
if (
element.getRootNode().host !== element.ownerDocument.activeElement
)
return;

const [ firstTabbable ] = focus.tabbable.find( element );
if ( firstTabbable ) firstTabbable.focus();
}, [] );

const content = (
<StyleProvider document={ { head: shadow } }>
<div
role="dialog"
ref={ useMergeRefs( [ useConstrainedTabbing(), dialogRef ] ) }
>
{ props.children }
</div>
</StyleProvider>
);

return (
<div
{ ...props }
ref={ ref }
tabIndex={ 0 }
role="button"
aria-pressed={ hasFocus }
>
{ shadow && createPortal( content, shadow ) }
</div>
);
}
1 change: 1 addition & 0 deletions packages/block-editor/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export { default as __experimentalLinkControlSearchItem } from './link-control/s
export { default as LineHeightControl } from './line-height-control';
export { default as __experimentalListView } from './list-view';
export { default as MediaReplaceFlow } from './media-replace-flow';
export { default as Placeholder } from './placeholder';
export { default as MediaPlaceholder } from './media-placeholder';
export { default as MediaUpload } from './media-upload';
export { default as MediaUploadCheck } from './media-upload/check';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import classnames from 'classnames';
import {
Button,
FormFileUpload,
Placeholder,
DropZone,
withFilters,
} from '@wordpress/components';
Expand All @@ -23,6 +22,7 @@ import { keyboardReturn } from '@wordpress/icons';
/**
* Internal dependencies
*/
import Placeholder from '../placeholder';
import MediaUpload from '../media-upload';
import MediaUploadCheck from '../media-upload/check';
import URLPopover from '../url-popover';
Expand Down
33 changes: 33 additions & 0 deletions packages/block-editor/src/components/placeholder/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* WordPress dependencies
*/
import { Placeholder } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import EmbeddedAdminContext from '../embedded-admin-context';

/**
* Placeholder for use in blocks. Creates an admin styling context and a tabbing
* context in the block editor's writing flow.
*
* @param {Object} props
*
* @return {WPComponent} The component
*/
export default function IsolatedPlaceholder( props ) {
return (
<EmbeddedAdminContext
aria-label={ sprintf(
/* translators: %s: what the placeholder is for */
__( 'Placeholder: %s' ),
props.label
) }
className="wp-block-editor-placeholder"
>
<Placeholder { ...props } />
</EmbeddedAdminContext>
);
}
29 changes: 6 additions & 23 deletions packages/block-editor/src/components/writing-flow/use-tab-nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,6 @@ import { useRef } from '@wordpress/element';
*/
import { store as blockEditorStore } from '../../store';

function isFormElement( element ) {
const { tagName } = element;
return (
tagName === 'INPUT' ||
tagName === 'BUTTON' ||
tagName === 'SELECT' ||
tagName === 'TEXTAREA'
);
}

export default function useTabNav() {
const container = useRef();
const focusCaptureBeforeRef = useRef();
Expand Down Expand Up @@ -104,8 +94,13 @@ export default function useTabNav() {
return;
}

if (
event.target.classList.contains( 'wp-block-editor-placeholder' )
) {
return;
}

const isShift = event.shiftKey;
const direction = isShift ? 'findPrevious' : 'findNext';

if ( ! hasMultiSelection() && ! getSelectedBlockClientId() ) {
// Preserve the behaviour of entering navigation mode when
Expand All @@ -118,18 +113,6 @@ export default function useTabNav() {
return;
}

// Allow tabbing between form elements rendered in a block,
// such as inside a placeholder. Form elements are generally
// meant to be UI rather than part of the content. Ideally
// these are not rendered in the content and perhaps in the
// future they can be rendered in an iframe or shadow DOM.
if (
isFormElement( event.target ) &&
isFormElement( focus.tabbable[ direction ]( event.target ) )
) {
return;
}

const next = isShift ? focusCaptureBeforeRef : focusCaptureAfterRef;

// Disable focus capturing on the focus capture element, so it
Expand Down
2 changes: 1 addition & 1 deletion packages/block-library/src/block/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
store as coreStore,
} from '@wordpress/core-data';
import {
Placeholder,
Spinner,
ToolbarGroup,
ToolbarButton,
Expand All @@ -25,6 +24,7 @@ import {
InspectorControls,
useBlockProps,
Warning,
Placeholder,
} from '@wordpress/block-editor';
import { store as reusableBlocksStore } from '@wordpress/reusable-blocks';
import { ungroup } from '@wordpress/icons';
Expand Down
4 changes: 2 additions & 2 deletions packages/block-library/src/calendar/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import memoize from 'memize';
* WordPress dependencies
*/
import { calendar as icon } from '@wordpress/icons';
import { Disabled, Placeholder, Spinner } from '@wordpress/components';
import { Disabled, Spinner } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import ServerSideRender from '@wordpress/server-side-render';
import { useBlockProps } from '@wordpress/block-editor';
import { useBlockProps, Placeholder } from '@wordpress/block-editor';
import { store as coreStore } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';

Expand Down
Loading

0 comments on commit 1b15d4c

Please sign in to comment.