Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Block editor: placeholders: try admin shadow #33494

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' ) ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels weird to target data-emotion. That's sounds like an implementation detail that shouldn't be relied upon.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, ideally we should only include the styles that are needed for the placeholder, but that's very hard to do. Not sure what else we can do here, aside from just including everything. It's not really a problem to include the styles, it's just bad for performance.

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 );
Comment on lines +110 to +114
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels very awkward to use. I had to look at this patch to figure out that I could press Enter or Space at the right moment and then Tab to reach the placeholder controls.

And there is no indication that Enter or Space did anything related to focus (Space even scrolls the page), and on Safari 15.1 I sometimes need to press not once, but twice, when moving into the Table block from above.

So there is no membrane visible to the eye, but there is one to the fingers. This should be made consistent: either there is always a clear membrane, either there is never one. I'd prefer the latter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And there is no indication that Enter or Space did anything related to focus

Doesn't focus move to the first element in the placeholder?

This should be made consistent: either there is always a clear membrane, either there is never one. I'd prefer the latter.

There has always been a membrane of some sort. Tab works in placeholders but doesn't work in the content (to tab within the content). What do you suggest by removing the membrane?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I shared my screen with Ella in private, and I think she'll put up some notes soon. In the meantime, for other readers:

Doesn't focus move to the first element in the placeholder?

The focus is never apparent to the visual keyboard user. Only once one of the placeholder's controls is focused do I see visual feedback of that focus.

There has always been a membrane of some sort. Tab works in placeholders but doesn't work in the content (to tab within the content). What do you suggest by removing the membrane?

I was coming from the position that the placeholder, while not really content, doesn't communicate that it should behave differently from content when it comes to keyboard navigation and focus. And, visually, there's not enough indication that the focus is on a different type of UI when the placeholder has the focus. That's what I meant by "a visual membrane not existing". From that interpretation it followed that there should also not be a membrane or hindrance when navigating with the keyboard.

return () => {
root.removeEventListener( 'focusin', onFocusIn );
root.removeEventListener( 'focusout', onFocusOut );
root.removeEventListener( 'keydown', onRootKeyDown );
element.removeEventListener( 'keydown', onKeyDown );
element.removeEventListener( 'mousedown', onMouseDown );
clearTimeout( timeoutId );
};
}, [] );
ellatrix marked this conversation as resolved.
Show resolved Hide resolved

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>
);
}
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