Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a866f82
Initial field and usage of field for title
talldan Jan 15, 2026
b31c53b
Add styles
talldan Jan 15, 2026
b0dd774
Add label
talldan Jan 15, 2026
1183821
WIP - type wrangling
talldan Jan 16, 2026
c793993
RichTextControl: align with current rich-text API and add autocomplet…
adamsilverstein May 14, 2026
b738efc
fields: declare block-editor dependency and clean up title field
adamsilverstein May 14, 2026
520950d
RichTextControl: add unit tests for new control and fields wrapper
adamsilverstein May 14, 2026
7906867
Merge branch 'trunk' into move/rich-text-to-fields-package
adamsilverstein May 14, 2026
9d838b7
RichTextControl: expose label as aria-label on the textbox
adamsilverstein May 14, 2026
66fbefc
fields: align RichTextEdit signature with DataFormControlProps
adamsilverstein May 14, 2026
c702e7c
fields: re-suppress missing block-editor types in RichTextEdit
adamsilverstein May 14, 2026
3947fca
fields: sync package-lock with new block-editor dependency
adamsilverstein May 14, 2026
6046249
Merge branch 'trunk' into move/rich-text-to-fields-package
adamsilverstein May 14, 2026
fc1df64
RichTextControl: Wire format keyboard shortcuts and input events
adamsilverstein May 14, 2026
502f285
RichTextControl: Wire format input rules so backtick→code transforms
adamsilverstein May 14, 2026
79bfe08
Link format: Render InlineLinkUI regardless of isVisible
adamsilverstein May 14, 2026
cc04d05
RichTextControl: Defer deselection while a format popover takes focus
adamsilverstein May 14, 2026
55a4cfe
RichTextControl: Add opt-in focusOnMount to land focus on the field
adamsilverstein May 17, 2026
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.

300 changes: 300 additions & 0 deletions packages/block-editor/src/components/rich-text/control/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
/**
* External dependencies
*/
import clsx from 'clsx';

/**
* WordPress dependencies
*/
import { BaseControl, useBaseControlProps } from '@wordpress/components';
import { useMergeRefs, useRefEffect } from '@wordpress/compose';
import {
useEffect,
useInsertionEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { privateApis as richTextPrivateApis } from '@wordpress/rich-text';

/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';
import { useBlockEditorAutocompleteProps } from '../../autocomplete';
import { getAllowedFormats } from '../utils';
import FormatEdit from '../format-edit';
import shortcutsListener from '../event-listeners/shortcuts';
import inputEventsListener from '../event-listeners/input-events';
import { keyboardShortcutContext, inputEventContext } from '../';

const { useRichText } = unlock( richTextPrivateApis );

/**
* A rich text control component that provides a contenteditable field with
* formatting capabilities.
*
* Unlike the in-canvas `RichText` component, `RichTextControl` is intended for
* standalone form fields (DataForms, sidebar inputs, etc.). It exposes a
* straightforward `value` / `onChange` interface and skips block-editor
* selection coupling, while still wiring registered format types and
* (optionally) autocompleters such as inline mentions.
*
* @param {Object} props Component properties.
* @param {string} props.label Label text for the control.
* @param {string} props.value The rich text value (HTML string).
* @param {Function} props.onChange Callback function invoked when the value changes.
* @param {string} [props.placeholder] Placeholder text displayed when the field is empty.
* @param {string} [props.id] Unique identifier for the control.
* @param {string} [props.clientId] Block client ID for context (used by format types that need it).
* @param {string} [props.className] Additional class name applied to the contenteditable element.
* @param {boolean} [props.hideLabelFromVision] Whether to visually hide the label (still accessible to screen readers).
* @param {Array} [props.allowedFormats] Array of allowed format types.
* @param {boolean} [props.disableFormats] Whether to disable all formatting.
* @param {boolean} [props.withoutInteractiveFormatting] Whether to disable interactive formatting features.
* @param {boolean} [props.preserveWhiteSpace] Whether to preserve whitespace in the content.
* @param {boolean} [props.disableLineBreaks] Whether to disable line breaks in the content.
* @param {Array} [props.autocompleters] Optional list of autocompleters (e.g. `@`-mention completers).
* @param {boolean} [props.focusOnMount] Whether to move focus to the field when it mounts. Off by default; opt in for standalone forms (e.g. a note form) where the old in-canvas `RichText` would have received focus via block-editor selection.
*
* @return {Element} The rendered RichTextControl component.
*/
export default function RichTextControl( {
label,
value: attrValue,
onChange,
placeholder,
id,
clientId,
className,
hideLabelFromVision,
allowedFormats,
disableFormats,
withoutInteractiveFormatting,
preserveWhiteSpace,
disableLineBreaks,
autocompleters,
focusOnMount,
} ) {
const [ selection, setSelection ] = useState( {
start: undefined,
end: undefined,
} );
const [ isSelected, setIsSelected ] = useState( false );
const anchorRef = useRef();
const inputEvents = useRef( new Set() );
const keyboardShortcuts = useRef( new Set() );

// When the textbox blurs, defer flipping `isSelected` to `false` so a
// portal-rendered popover (e.g., the inline link UI opened via Cmd+K)
// can claim focus without `FormatEdit` — and therefore the popover
// itself — unmounting underneath it.
const blurDeselectTimeoutRef = useRef( undefined );
useEffect( () => () => clearTimeout( blurDeselectTimeoutRef.current ), [] );

const adjustedAllowedFormats = getAllowedFormats( {
allowedFormats,
disableFormats,
} );

const {
value,
onChange: onRichTextChange,
ref: richTextRef,
formatTypes,
getValue,
} = useRichText( {
value: attrValue,
onChange,
selectionStart: selection.start,
selectionEnd: selection.end,
onSelectionChange: ( start, end ) => setSelection( { start, end } ),
__unstableIsSelected: isSelected,
preserveWhiteSpace: !! preserveWhiteSpace,
placeholder,
__unstableDisableFormats: disableFormats,
allowedFormats: adjustedAllowedFormats,
withoutInteractiveFormatting,
__unstableFormatTypeHandlerContext: useMemo(
() => ( {
richTextIdentifier: id,
blockClientId: clientId,
} ),
[ id, clientId ]
),
} );

const autocompleteProps = useBlockEditorAutocompleteProps( {
completers: autocompleters,
record: value,
onChange: onRichTextChange,
} );

const { baseControlProps, controlProps } = useBaseControlProps( {
hideLabelFromVision,
label,
} );

function onFocus() {
anchorRef.current?.focus();
}

// Optionally move focus to the field when it mounts. `RichTextControl`
// drops the block-editor selection coupling that focuses the in-canvas
// `RichText`, so standalone consumers (e.g. a note form) have no other
// way to land the caret in the field on open.
const focusOnMountRef = useRefEffect(
( element ) => {
if ( focusOnMount ) {
element.focus();
}
},
[ focusOnMount ]
);

// Wire registered format keyboard shortcuts (e.g. Cmd+B, Cmd+I, Cmd+K)
// and InputEvent handlers (e.g. native formatBold) to the contenteditable.
// FormatEdit populates these Sets via context; without these listeners the
// callbacks would never fire.
const eventListenersPropsRef = useRef( {
keyboardShortcuts,
inputEvents,
} );

// Keep `formatTypes`/`getValue`/`onChange` accessible to the input-rule
// listener without retearing it down on every value change.
const inputRulePropsRef = useRef( {
formatTypes,
getValue,
onChange: onRichTextChange,
} );
useInsertionEffect( () => {
inputRulePropsRef.current = {
formatTypes,
getValue,
onChange: onRichTextChange,
};
} );

const eventListenersRef = useRefEffect(
( element ) => {
if ( ! isSelected ) {
return;
}
const cleanupShortcuts = shortcutsListener(
eventListenersPropsRef
)( element );
const cleanupInputEvents = inputEventsListener(
eventListenersPropsRef
)( element );

// Apply format-level input rules (e.g. `core/code`'s
// backtick→inline-code transform). Mirrors the format-rule
// branch of `block-editor`'s `input-rules.js` listener without
// pulling in its block-transform machinery, which doesn't
// apply to a standalone field.
function onFormatInput( event ) {
if (
event.inputType !== 'insertText' &&
event.type !== 'compositionend'
) {
return;
}
const {
formatTypes: types,
getValue: getCurrentValue,
onChange: handleChange,
} = inputRulePropsRef.current;
const current = getCurrentValue();
const transformed = types.reduce(
( accumulator, { __unstableInputRule } ) =>
__unstableInputRule
? __unstableInputRule( accumulator )
: accumulator,
current
);
if ( transformed !== current ) {
handleChange( {
...transformed,
activeFormats: current.activeFormats,
} );
}
}
element.addEventListener( 'input', onFormatInput );
element.addEventListener( 'compositionend', onFormatInput );

return () => {
cleanupShortcuts();
cleanupInputEvents();
element.removeEventListener( 'input', onFormatInput );
element.removeEventListener( 'compositionend', onFormatInput );
};
},
[ isSelected ]
);

return (
<>
{ isSelected && (
<keyboardShortcutContext.Provider value={ keyboardShortcuts }>
<inputEventContext.Provider value={ inputEvents }>
<FormatEdit
value={ value }
onChange={ onRichTextChange }
onFocus={ onFocus }
formatTypes={ formatTypes }
forwardedRef={ anchorRef }
isVisible={ false }
/>
</inputEventContext.Provider>
</keyboardShortcutContext.Provider>
) }
<BaseControl { ...baseControlProps }>
<div
{ ...autocompleteProps }
className={ clsx(
'block-editor-rich-text-control',
className,
autocompleteProps.className
) }
role="textbox"
aria-multiline={ ! disableLineBreaks }
aria-label={ label }
ref={ useMergeRefs( [
richTextRef,
anchorRef,
autocompleteProps.ref,
eventListenersRef,
focusOnMountRef,
] ) }
onFocus={ () => {
clearTimeout( blurDeselectTimeoutRef.current );
setIsSelected( true );
} }
onBlur={ ( event ) => {
clearTimeout( blurDeselectTimeoutRef.current );
const ownerDocument = event.currentTarget.ownerDocument;
blurDeselectTimeoutRef.current = setTimeout( () => {
// Stay selected if focus moved to a popover
// triggered by a format type (e.g., the inline
// link UI). Popovers from `@wordpress/components`
// portal out of the React tree and carry the
// `.components-popover` class.
const active = ownerDocument.activeElement;
if (
active &&
active.closest( '.components-popover' )
) {
return;
}
setIsSelected( false );
}, 0 );
} }
contentEditable
suppressContentEditableWarning
{ ...controlProps }
/>
</BaseControl>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@use "@wordpress/base-styles/colors" as *;
@use "@wordpress/base-styles/mixins" as *;
@use "@wordpress/base-styles/variables" as *;

.block-editor-rich-text-control {
width: 100%;
// Override input style margin in WP forms.css.
margin: 0;
background: $white;
color: $gray-900;
@include input-control( var(--wp-admin-theme-color, #3858e9) );
border-color: $gray-600;

&::placeholder {
color: color-mix(in srgb, $gray-900, transparent 38%);
}

min-height: $grid-unit-50;

// Subtract 1px to account for the border, which isn't included on the element
// on newer components like InputControl, SelectControl, etc.
// These values should be shared with the `controlPaddingX` in ./utils/config-values.js
padding: $grid-unit-15;
}
Loading
Loading