From 4a71acd5c412a5ae7c23bc2860ab19caa1b77c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81rgio=20Santos?= Date: Tue, 12 May 2026 11:45:25 +0100 Subject: [PATCH 1/2] feat(rich-text-field): add RichTextField component --- README.md | 1 + components/index.ts | 1 + components/rich-text-field/index.tsx | 81 ++++++++++++++++++++++++++++ components/rich-text-field/readme.md | 75 ++++++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 components/rich-text-field/index.tsx create mode 100644 components/rich-text-field/readme.md diff --git a/README.md b/README.md index 367ec886..fc3fac05 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ The original import paths are still fully supported and will continue to work as - [Optional](./components/optional/) - [Repeater](./components/repeater/) - [RichTextCharacterLimit](./components/rich-text-character-limit) +- [RichTextField](./components/rich-text-field) ### Post related Components diff --git a/components/index.ts b/components/index.ts index e1545e8e..d64cfa68 100644 --- a/components/index.ts +++ b/components/index.ts @@ -24,4 +24,5 @@ export { PostCategoryList } from './post-category-list'; export { PostPrimaryTerm } from './post-primary-term'; export { PostPrimaryCategory } from './post-primary-category'; export { RichTextCharacterLimit, getCharacterCount } from './rich-text-character-limit'; +export { RichTextField } from './rich-text-field'; export { CircularProgressBar, Counter } from './counter'; diff --git a/components/rich-text-field/index.tsx b/components/rich-text-field/index.tsx new file mode 100644 index 00000000..c23b04a9 --- /dev/null +++ b/components/rich-text-field/index.tsx @@ -0,0 +1,81 @@ +/* eslint-disable @wordpress/no-unsafe-wp-apis */ +import { useEffect, useRef, useState } from '@wordpress/element'; +import { RichText } from '@wordpress/block-editor'; +import { + SlotFillProvider, + // @ts-ignore-next-line - experimental component, no public types. + __experimentalHStack as HStack, +} from '@wordpress/components'; + +interface RichTextFieldProps + extends Omit, 'isSelected'> { + className?: string; +} + +/** + * Drop-in `RichText` field for use outside a block's `edit` context + * (e.g. inside `InspectorControls` or a `Modal`). + * + * Handles the three things `RichText` doesn't get for free outside a block: + * + * 1. Local `SlotFillProvider` so the format-toolbar fills (which `RichText` + * routes through `BlockControls`) resolve inside this field instead of + * being captured by the surrounding block's toolbar slot. + * 2. `inlineToolbar` so the format toolbar renders as a popover near the caret. + * 3. Manual `isSelected` state plus click-outside deselect, ignoring the + * inline toolbar and any popovers it spawns (e.g. the link URL input). + */ +export const RichTextField = ({ + tagName = 'p', + className, + ...richTextProps +}: RichTextFieldProps) => { + const [isSelected, setIsSelected] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!isSelected) { + return undefined; + } + + const doc = ref.current?.ownerDocument; + if (!doc) { + return undefined; + } + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (ref.current?.contains(target)) { + return; + } + if (target?.closest?.('.block-editor-rich-text__inline-format-toolbar')) { + return; + } + if (target?.closest?.('.components-popover')) { + return; + } + setIsSelected(false); + }; + + doc.addEventListener('mousedown', handleMouseDown); + return () => doc.removeEventListener('mousedown', handleMouseDown); + }, [isSelected]); + + return ( + + setIsSelected(true)} + expanded + > + + + + ); +}; diff --git a/components/rich-text-field/readme.md b/components/rich-text-field/readme.md new file mode 100644 index 00000000..d143b3b3 --- /dev/null +++ b/components/rich-text-field/readme.md @@ -0,0 +1,75 @@ +# Rich Text Field + +A drop-in wrapper around `RichText` for use **outside** a block's `edit` context — for example, inside `InspectorControls` or a `Modal`. + +`RichTextField` accepts all the same props as `RichText`, minus `isSelected` (managed internally). Please refer to the [official RichText documentation](https://developer.wordpress.org/block-editor/reference-guides/richtext/). + +## Usage + +```jsx +import { InspectorControls } from '@wordpress/block-editor'; +import { Modal, PanelBody, Button } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { RichTextField } from '@10up/block-components'; + +function BlockEdit(props) { + const { attributes, setAttributes } = props; + const { sidebarNote, modalNote } = attributes; + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + setAttributes({ sidebarNote: value })} + placeholder={__('Type and select to see the toolbar…', 'your-textdomain')} + /> + + + + + + {isOpen && ( + setIsOpen(false)} + > + setAttributes({ modalNote: value })} + allowedFormats={['core/bold', 'core/italic', 'core/link']} + /> + + )} + + ); +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------------- | -------------------------- | ------- | ----------------------------------------------------------------- | +| `value` | `string` | — | HTML string. | +| `onChange` | `(value: string) => void` | — | Change handler. | +| `tagName` | `string` | `'p'` | Element rendered by `RichText`. | +| `placeholder` | `string` | — | Placeholder text. | +| `allowedFormats` | `string[]` | — | Allowed format names (e.g. `['core/bold', 'core/link']`). | +| `className` | `string` | — | Class added to the wrapping element. | + +All other `RichText` props are forwarded. + +## Modal z-index note + +When rendering `RichTextField` inside a `Modal`, the inline format toolbar may appear behind the modal. Add this CSS to lift it above the modal: + +```css +body:has(.your-modal-className) .block-editor-rich-text__inline-format-toolbar { + z-index: 1000001; +} +``` From 0a36d6fe83ee0759c1d68e437ca6abb14b0b3d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81rgio=20Santos?= Date: Tue, 12 May 2026 12:47:15 +0100 Subject: [PATCH 2/2] refactor(rich-text-field): use useRefEffect for click-outside listener --- components/rich-text-field/index.tsx | 52 ++++++++++++++-------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/components/rich-text-field/index.tsx b/components/rich-text-field/index.tsx index c23b04a9..3524addb 100644 --- a/components/rich-text-field/index.tsx +++ b/components/rich-text-field/index.tsx @@ -1,5 +1,6 @@ /* eslint-disable @wordpress/no-unsafe-wp-apis */ -import { useEffect, useRef, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; import { RichText } from '@wordpress/block-editor'; import { SlotFillProvider, @@ -31,35 +32,34 @@ export const RichTextField = ({ ...richTextProps }: RichTextFieldProps) => { const [isSelected, setIsSelected] = useState(false); - const ref = useRef(null); - useEffect(() => { - if (!isSelected) { - return undefined; - } + const ref = useRefEffect( + (node) => { + if (!isSelected) { + return undefined; + } - const doc = ref.current?.ownerDocument; - if (!doc) { - return undefined; - } + const doc = node.ownerDocument; - const handleMouseDown = (event: MouseEvent) => { - const target = event.target as HTMLElement | null; - if (ref.current?.contains(target)) { - return; - } - if (target?.closest?.('.block-editor-rich-text__inline-format-toolbar')) { - return; - } - if (target?.closest?.('.components-popover')) { - return; - } - setIsSelected(false); - }; + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (node.contains(target)) { + return; + } + if (target?.closest?.('.block-editor-rich-text__inline-format-toolbar')) { + return; + } + if (target?.closest?.('.components-popover')) { + return; + } + setIsSelected(false); + }; - doc.addEventListener('mousedown', handleMouseDown); - return () => doc.removeEventListener('mousedown', handleMouseDown); - }, [isSelected]); + doc.addEventListener('mousedown', handleMouseDown); + return () => doc.removeEventListener('mousedown', handleMouseDown); + }, + [isSelected], + ); return (