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..3524addb --- /dev/null +++ b/components/rich-text-field/index.tsx @@ -0,0 +1,81 @@ +/* eslint-disable @wordpress/no-unsafe-wp-apis */ +import { useState } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; +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 = useRefEffect( + (node) => { + if (!isSelected) { + return undefined; + } + + const doc = node.ownerDocument; + + 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], + ); + + 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; +} +```