diff --git a/package-lock.json b/package-lock.json index 068c611a26d035..10c55547dcfaab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52332,7 +52332,6 @@ "@wordpress/kebab-case": "file:../kebab-case", "@wordpress/keycodes": "file:../keycodes", "@wordpress/primitives": "file:../primitives", - "@wordpress/private-apis": "file:../private-apis", "@wordpress/ui": "file:../ui", "@wordpress/warning": "file:../warning", "clsx": "^2.1.1", diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index c15390e7a0fc70..3aa4fc3939ab7f 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -9,6 +9,7 @@ ### Internal - Heading: Declare the heading level and paragraph keyboard shortcuts on the block's variations and transforms, rather than in a `BlockKeyboardShortcuts` component that every editor had to render. The `BlockKeyboardShortcuts` private export has been removed ([#81588](https://github.com/WordPress/gutenberg/pull/81588)). +- Math: Use `ValidatedTextareaControl` from `@wordpress/ui` in the LaTeX popover instead of unlocking `ValidatedTextareaControl` from the `@wordpress/components` private APIs ([#81984](https://github.com/WordPress/gutenberg/pull/81984)). ### Enhancements diff --git a/packages/block-library/src/math/edit.js b/packages/block-library/src/math/edit.js index af7c60fc219a9f..b8547096d2c339 100644 --- a/packages/block-library/src/math/edit.js +++ b/packages/block-library/src/math/edit.js @@ -3,15 +3,10 @@ import { useBlockProps, store as blockEditorStore, } from '@wordpress/block-editor'; -import { - Popover, - privateApis as componentsPrivateApis, -} from '@wordpress/components'; +import { Popover } from '@wordpress/components'; +import { ValidatedTextareaControl } from '@wordpress/ui'; import { useState, useEffect, useRef } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; -import { unlock } from '../lock-unlock'; - -const { ValidatedTextareaControl } = unlock( componentsPrivateApis ); export default function MathEdit( { attributes, setAttributes, isSelected } ) { const { latex, mathML } = attributes; @@ -78,14 +73,14 @@ export default function MathEdit( { attributes, setAttributes, isSelected } ) { { + onValueChange={ ( newLatex ) => { if ( ! latexToMathML ) { setAttributes( { latex: newLatex } ); return; diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index e183ba09d67bfd..da2a7019fdd309 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -48,6 +48,7 @@ - `ControlWithError`: Remove the internal implementation in favor of the `ControlWithError` component from `@wordpress/ui` ([#81230](https://github.com/WordPress/gutenberg/issues/81230)) ([#81574](https://github.com/WordPress/gutenberg/pull/81574)). - Remove the unused `components-validated-control__indicator*` styles; the indicator is now rendered and styled by `ValidityIndicator` from `@wordpress/ui` ([#81230](https://github.com/WordPress/gutenberg/issues/81230)) ([#81574](https://github.com/WordPress/gutenberg/pull/81574)). - Deprecate `ValidatedInputControl` in the private APIs; use `ValidatedInputControl` from `@wordpress/ui` instead ([#81627](https://github.com/WordPress/gutenberg/pull/81627)). +- Deprecate `ValidatedTextareaControl` in the private APIs; use `ValidatedTextareaControl` from `@wordpress/ui` instead ([#81984](https://github.com/WordPress/gutenberg/pull/81984)). ## 39.0.0 (2026-08-12) diff --git a/packages/components/src/validated-form-controls/components/stories/textarea-control.story.tsx b/packages/components/src/validated-form-controls/components/stories/textarea-control.story.tsx deleted file mode 100644 index a38d8da3ec0b8a..00000000000000 --- a/packages/components/src/validated-form-controls/components/stories/textarea-control.story.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from '@wordpress/element'; -import type { StoryObj, Meta } from '@storybook/react-vite'; -import { formDecorator } from './story-utils'; -import { ValidatedTextareaControl } from '../textarea-control'; - -const meta: Meta< typeof ValidatedTextareaControl > = { - title: 'Components/Selection & Input/Validated Form Controls/ValidatedTextareaControl', - id: 'components-validatedtextareacontrol', - component: ValidatedTextareaControl, - tags: [ 'status-private' ], - decorators: formDecorator, - args: { onChange: () => {} }, - argTypes: { value: { control: false } }, -}; -export default meta; - -export const Default: StoryObj< typeof ValidatedTextareaControl > = { - render: function Template( { onChange, ...args } ) { - const [ value, setValue ] = - useState< - React.ComponentProps< - typeof ValidatedTextareaControl - >[ 'value' ] - >( '' ); - - return ( - { - setValue( newValue ); - onChange?.( newValue ); - } } - customValidity={ - value?.toLowerCase() === 'error' - ? { - type: 'invalid', - message: 'The word "error" is not allowed.', - } - : undefined - } - /> - ); - }, -}; -Default.args = { - required: true, - label: 'Textarea', - help: 'The word "error" will trigger an error.', -}; diff --git a/packages/components/src/validated-form-controls/components/textarea-control.tsx b/packages/components/src/validated-form-controls/components/textarea-control.tsx index fd0d9046fdee91..116968ecfa7634 100644 --- a/packages/components/src/validated-form-controls/components/textarea-control.tsx +++ b/packages/components/src/validated-form-controls/components/textarea-control.tsx @@ -1,5 +1,6 @@ import { forwardRef, useRef } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; +import deprecated from '@wordpress/deprecated'; import { ControlWithError } from '@wordpress/ui'; import type { ValidatedControlProps } from './types'; import TextareaControl from '../../textarea-control'; @@ -13,6 +14,12 @@ const UnforwardedValidatedTextareaControl = ( }: React.ComponentProps< typeof TextareaControl > & ValidatedControlProps, forwardedRef: React.ForwardedRef< HTMLTextAreaElement > ) => { + deprecated( 'wp.components.privateApis.ValidatedTextareaControl', { + since: '7.2', + alternative: 'ValidatedTextareaControl from @wordpress/ui', + hint: 'This private API will be completely removed within a few Gutenberg plugin releases.', + } ); + const validityTargetRef = useRef< HTMLTextAreaElement >( null ); const mergedRefs = useMergeRefs( [ forwardedRef, validityTargetRef ] ); diff --git a/packages/components/src/validated-form-controls/test/textarea-control.tsx b/packages/components/src/validated-form-controls/test/textarea-control.tsx index f05968a6da20db..e303c620f8103d 100644 --- a/packages/components/src/validated-form-controls/test/textarea-control.tsx +++ b/packages/components/src/validated-form-controls/test/textarea-control.tsx @@ -2,6 +2,23 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ValidatedTextareaControl } from '../components'; +describe( 'Shows a deprecation warning', () => { + it( 'ValidatedTextareaControl', () => { + render( + {} } + value="" + /> + ); + + expect( console ).toHaveWarnedWith( + 'wp.components.privateApis.ValidatedTextareaControl is deprecated since version 7.2. Please use ValidatedTextareaControl from @wordpress/ui instead. Note: This private API will be completely removed within a few Gutenberg plugin releases.' + ); + } ); +} ); + describe( 'ValidatedTextareaControl', () => { it( 'should preserve the help description', () => { render( diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 1ad866d3d5fe80..1e0dcdfc1f0a86 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -32,6 +32,7 @@ - DataForm: Remove the vendored `ControlWithError` copy in favor of the `ControlWithError` component from `@wordpress/ui`. ([#81230](https://github.com/WordPress/gutenberg/issues/81230)) ([#81574](https://github.com/WordPress/gutenberg/pull/81574)) - DataForm: Render the `date` control's validity message with `ValidityIndicator` from `@wordpress/ui` instead of hand-rolled markup styled by `@wordpress/components` global class names. ([#81230](https://github.com/WordPress/gutenberg/issues/81230)) ([#81574](https://github.com/WordPress/gutenberg/pull/81574)) - DataForm: Use `ValidatedInputControl` from `@wordpress/ui` in the `text`, `email`, `telephone`, `url`, `password`, `color`, `datetime`, and `time` controls, instead of unlocking it from the `@wordpress/components` private APIs. Prefixes and suffixes now render in an `InputLayout.Slot` from `@wordpress/ui`. ([#81627](https://github.com/WordPress/gutenberg/pull/81627)) +- DataForm: Use `ValidatedTextareaControl` from `@wordpress/ui` in the `textarea` control, instead of unlocking it from the `@wordpress/components` private APIs. This was the last private API used by the package, so the `@wordpress/private-apis` dependency is removed. ([#81984](https://github.com/WordPress/gutenberg/pull/81984)) ## 18.0.0 (2026-08-12) diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 4e7c6f624b2975..7a80b865c66e0a 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -66,7 +66,6 @@ "@wordpress/kebab-case": "file:../kebab-case", "@wordpress/keycodes": "file:../keycodes", "@wordpress/primitives": "file:../primitives", - "@wordpress/private-apis": "file:../private-apis", "@wordpress/ui": "file:../ui", "@wordpress/warning": "file:../warning", "clsx": "^2.1.1", diff --git a/packages/dataviews/src/components/dataform-controls/textarea.tsx b/packages/dataviews/src/components/dataform-controls/textarea.tsx index a5728cce04785d..007616d244095a 100644 --- a/packages/dataviews/src/components/dataform-controls/textarea.tsx +++ b/packages/dataviews/src/components/dataform-controls/textarea.tsx @@ -1,11 +1,8 @@ -import { privateApis } from '@wordpress/components'; +import { ValidatedTextareaControl } from '@wordpress/ui'; import { useCallback } from '@wordpress/element'; import type { DataFormControlProps } from '../../types'; -import { unlock } from '../../lock-unlock'; import getCustomValidity from './utils/get-custom-validity'; -const { ValidatedTextareaControl } = unlock( privateApis ); - export default function Textarea< Item >( { data, field, @@ -20,7 +17,7 @@ export default function Textarea< Item >( { const { label, placeholder, description, setValue, isValid } = field; const value = field.getValue( { item: data } ); - const onChangeControl = useCallback( + const onValueChangeControl = useCallback( ( newValue: string ) => onChange( setValue( { item: data, value: newValue } ) ), [ data, onChange, setValue ] @@ -34,8 +31,13 @@ export default function Textarea< Item >( { label={ label } placeholder={ placeholder } value={ value ?? '' } - help={ description } - onChange={ onChangeControl } + description={ + typeof description === 'string' ? description : undefined + } + details={ + typeof description === 'string' ? undefined : description + } + onValueChange={ onValueChangeControl } rows={ rows } disabled={ disabled } minLength={ diff --git a/packages/dataviews/src/lock-unlock.ts b/packages/dataviews/src/lock-unlock.ts deleted file mode 100644 index 986eb453753469..00000000000000 --- a/packages/dataviews/src/lock-unlock.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; - -export const { lock, unlock } = - __dangerousOptInToUnstableAPIsOnlyForCoreModules( - 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', - '@wordpress/dataviews' - ); diff --git a/packages/dataviews/tsconfig.build.json b/packages/dataviews/tsconfig.build.json index a2b66065a9b77a..05b672dd135895 100644 --- a/packages/dataviews/tsconfig.build.json +++ b/packages/dataviews/tsconfig.build.json @@ -22,7 +22,6 @@ { "path": "../kebab-case" }, { "path": "../keycodes/tsconfig.build.json" }, { "path": "../primitives" }, - { "path": "../private-apis" }, { "path": "../theme/tsconfig.build.json" }, { "path": "../ui/tsconfig.build.json" }, { "path": "../url" }, diff --git a/packages/dataviews/tsconfig.json b/packages/dataviews/tsconfig.json index 57c14102a86c58..3c6137209da032 100644 --- a/packages/dataviews/tsconfig.json +++ b/packages/dataviews/tsconfig.json @@ -35,7 +35,6 @@ { "path": "../kebab-case" }, { "path": "../keycodes/tsconfig.build.json" }, { "path": "../primitives" }, - { "path": "../private-apis" }, { "path": "../theme/tsconfig.build.json" }, { "path": "../ui/tsconfig.build.json" }, { "path": "../url" }, diff --git a/packages/dataviews/tsconfig.stories.json b/packages/dataviews/tsconfig.stories.json index a7023254f6435f..9d73e722c9a9e2 100644 --- a/packages/dataviews/tsconfig.stories.json +++ b/packages/dataviews/tsconfig.stories.json @@ -35,7 +35,6 @@ { "path": "../kebab-case" }, { "path": "../keycodes/tsconfig.build.json" }, { "path": "../primitives" }, - { "path": "../private-apis" }, { "path": "../theme/tsconfig.build.json" }, { "path": "../ui/tsconfig.build.json" }, { "path": "../url" }, diff --git a/packages/eslint-plugin/rules/__tests__/use-recommended-components.js b/packages/eslint-plugin/rules/__tests__/use-recommended-components.js index 36856011301ee1..baeb7a4159c661 100644 --- a/packages/eslint-plugin/rules/__tests__/use-recommended-components.js +++ b/packages/eslint-plugin/rules/__tests__/use-recommended-components.js @@ -29,6 +29,7 @@ ruleTester.run( 'use-recommended-components', rule, { "import { KeyboardShortcutDescription, KeyboardShortcutDisplay, useKeyboardShortcutProps } from '@wordpress/ui';", "import { ControlWithError } from '@wordpress/ui';", "import { ValidatedInputControl, InputLayout } from '@wordpress/ui';", + "import { ValidatedTextareaControl } from '@wordpress/ui';", // Unlocked private APIs are only checked for denied names. "import { privateApis } from '@wordpress/components'; import { unlock } from '../../lock-unlock'; const { SomethingElse } = unlock( privateApis );", diff --git a/packages/eslint-plugin/rules/use-recommended-components.js b/packages/eslint-plugin/rules/use-recommended-components.js index 1c542462e5cc23..6dff559110546f 100644 --- a/packages/eslint-plugin/rules/use-recommended-components.js +++ b/packages/eslint-plugin/rules/use-recommended-components.js @@ -43,6 +43,7 @@ const ALLOWLIST = { 'TextareaControl', 'Tooltip', 'ValidatedInputControl', + 'ValidatedTextareaControl', 'ValidityIndicator', 'VisuallyHidden', 'useKeyboardShortcutProps', diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 00d69ba3165152..735824fe3feacd 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -10,6 +10,7 @@ - Add `ControlWithError` component, ported from `@wordpress/components` and `@wordpress/dataviews` ([#81230](https://github.com/WordPress/gutenberg/issues/81230)) ([#81574](https://github.com/WordPress/gutenberg/pull/81574)). - Export `ValidityIndicator`, so custom validated controls can render the same validity message as `ControlWithError` ([#81230](https://github.com/WordPress/gutenberg/issues/81230)) ([#81574](https://github.com/WordPress/gutenberg/pull/81574)). - Add `ValidatedInputControl` component, an `InputControl` wrapped in `ControlWithError` ([#81627](https://github.com/WordPress/gutenberg/pull/81627)). +- Add `ValidatedTextareaControl` component, a `TextareaControl` wrapped in `ControlWithError` ([#81984](https://github.com/WordPress/gutenberg/pull/81984)). ### Breaking Changes diff --git a/packages/ui/src/form/with-validation/index.ts b/packages/ui/src/form/with-validation/index.ts index 27e72b4e94f891..be7213f31e83d6 100644 --- a/packages/ui/src/form/with-validation/index.ts +++ b/packages/ui/src/form/with-validation/index.ts @@ -1 +1,2 @@ export * from './validated-input-control'; +export * from './validated-textarea-control'; diff --git a/packages/ui/src/form/with-validation/validated-textarea-control/index.ts b/packages/ui/src/form/with-validation/validated-textarea-control/index.ts new file mode 100644 index 00000000000000..46e8a4e022d8e7 --- /dev/null +++ b/packages/ui/src/form/with-validation/validated-textarea-control/index.ts @@ -0,0 +1 @@ +export { ValidatedTextareaControl } from './validated-textarea-control'; diff --git a/packages/ui/src/form/with-validation/validated-textarea-control/stories/index.story.tsx b/packages/ui/src/form/with-validation/validated-textarea-control/stories/index.story.tsx new file mode 100644 index 00000000000000..7bee41c7357f1b --- /dev/null +++ b/packages/ui/src/form/with-validation/validated-textarea-control/stories/index.story.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from '@wordpress/element'; +import { ValidatedTextareaControl } from '../index'; +import { formDecorator } from '../../../stories/shared'; + +const meta: Meta< typeof ValidatedTextareaControl > = { + title: 'Design System/Components/Form/With Validation/ValidatedTextareaControl', + component: ValidatedTextareaControl, + argTypes: { + customValidity: { control: false }, + defaultValue: { control: false }, + onValueChange: { action: 'onValueChange' }, + value: { control: false }, + }, + decorators: [ formDecorator ], + parameters: { + componentStatus: { + status: 'use-with-caution', + whereUsed: 'global', + notes: 'APIs and behavior are still prone to change.', + }, + }, +}; +export default meta; + +type Story = StoryObj< typeof ValidatedTextareaControl >; + +/** + * Native constraint validation (here, `required`) is reported inline when the + * control is blurred or the form is submitted. + */ +export const Default: Story = { + args: { + label: 'Text', + required: true, + }, +}; + +/** + * Consumer-driven validation results are passed through the `customValidity` + * prop. Type the word "error" and blur the field to trigger the custom error. + */ +export const WithCustomValidity: Story = { + render: function Template( args ) { + const [ value, setValue ] = useState( '' ); + + return ( + setValue( next ?? '' ) } + customValidity={ + value.toLowerCase() === 'error' + ? { + type: 'invalid', + message: 'The word "error" is not allowed.', + } + : undefined + } + /> + ); + }, + args: { + label: 'Text', + description: "The word 'error' will trigger an error.", + }, +}; diff --git a/packages/ui/src/form/with-validation/validated-textarea-control/test/index.test.tsx b/packages/ui/src/form/with-validation/validated-textarea-control/test/index.test.tsx new file mode 100644 index 00000000000000..c8c2d71618ec8c --- /dev/null +++ b/packages/ui/src/form/with-validation/validated-textarea-control/test/index.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef, useState } from '@wordpress/element'; +import { ValidatedTextareaControl } from '../index'; + +describe( 'ValidatedTextareaControl', () => { + it( 'forwards ref to the textarea element', () => { + const ref = createRef< HTMLTextAreaElement >(); + + render( ); + + expect( ref.current ).toBeInstanceOf( HTMLTextAreaElement ); + } ); + + it( 'appends a required indicator to the label', () => { + render( ); + + expect( + screen.getByRole( 'textbox', { name: 'Bio (Required)' } ) + ).toBeVisible(); + } ); + + it( 'appends an optional indicator to the label when markWhenOptional', () => { + render( ); + + expect( + screen.getByRole( 'textbox', { name: 'Bio (Optional)' } ) + ).toBeVisible(); + } ); + + it( 'preserves the description', () => { + render( + + ); + + expect( + screen.getByRole( 'textbox', { name: 'Bio' } ) + ).toHaveAccessibleDescription( 'A short bio.' ); + } ); + + it( 'shows a native constraint violation when the form is submitted', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( +
+ + + + ); + + await user.click( screen.getByRole( 'button', { name: 'Submit' } ) ); + + await waitFor( () => { + expect( + screen.getByText( 'Constraints not satisfied' ) + ).toBeVisible(); + } ); + expect( onSubmit ).not.toHaveBeenCalled(); + } ); + + it( 'appends the validation error alongside the description', async () => { + const user = userEvent.setup(); + render( +
+ + + + ); + + const textarea = screen.getByRole( 'textbox', { name: /^Bio/ } ); + + await user.click( screen.getByRole( 'button', { name: 'Submit' } ) ); + + await waitFor( () => { + expect( textarea ).toHaveAccessibleDescription( + expect.stringContaining( 'Constraints not satisfied' ) + ); + } ); + expect( textarea ).toHaveAccessibleDescription( + expect.stringContaining( 'A short bio.' ) + ); + } ); + + it( 'shows a custom validity message once the control is touched', async () => { + const user = userEvent.setup(); + + function TestComponent() { + const [ value, setValue ] = useState( '' ); + return ( + setValue( next ?? '' ) } + customValidity={ + value === 'error' + ? { + type: 'invalid', + message: 'The word "error" is not allowed.', + } + : undefined + } + /> + ); + } + + render( ); + + const textarea = screen.getByRole( 'textbox', { name: 'Text' } ); + + await user.type( textarea, 'error' ); + await user.tab(); + + await waitFor( () => { + expect( + screen.getByText( 'The word "error" is not allowed.' ) + ).toBeVisible(); + } ); + expect( ( textarea as HTMLTextAreaElement ).validationMessage ).toBe( + 'The word "error" is not allowed.' + ); + } ); +} ); diff --git a/packages/ui/src/form/with-validation/validated-textarea-control/validated-textarea-control.tsx b/packages/ui/src/form/with-validation/validated-textarea-control/validated-textarea-control.tsx new file mode 100644 index 00000000000000..105180d7ffef4d --- /dev/null +++ b/packages/ui/src/form/with-validation/validated-textarea-control/validated-textarea-control.tsx @@ -0,0 +1,36 @@ +import { forwardRef, useRef } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; +import { ControlWithError } from '../../primitives/control-with-error'; +import { TextareaControl } from '../../textarea-control'; +import type { ValidatedControlProps } from '../types'; + +/** + * A `TextareaControl` with inline validation feedback, based on the native + * Constraint Validation API. + * + * Native constraint violations (e.g. `required`, `minLength`) and custom + * validity messages passed through the `customValidity` prop are reported + * inline once the control has been touched (blurred at least once) or the + * enclosing form is submitted. + */ +export const ValidatedTextareaControl = forwardRef< + HTMLTextAreaElement, + React.ComponentProps< typeof TextareaControl > & ValidatedControlProps +>( function ValidatedTextareaControl( + { required, markWhenOptional, customValidity, ...restProps }, + forwardedRef +) { + const validityTargetRef = useRef< HTMLTextAreaElement >( null ); + const mergedRefs = useMergeRefs( [ forwardedRef, validityTargetRef ] ); + + return ( + validityTargetRef.current } + > + + + ); +} );