From 577ad5236e8ce82bb95713bc7f4c525d49c91a0c Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 14:51:45 +0000 Subject: [PATCH 1/2] feat: add Rich Text Editor (WIP) --- build-tools/utils/pluralize.js | 1 + pages/rich-text-editor/permutations.page.tsx | 43 +++ pages/rich-text-editor/simple.page.tsx | 37 ++ .../__snapshots__/documenter.test.ts.snap | 361 ++++++++++++++++++ .../test-utils-selectors.test.tsx.snap | 6 + .../test-utils-wrappers.test.tsx.snap | 80 ++++ .../__tests__/rich-text-editor.test.tsx | 110 ++++++ src/rich-text-editor/index.tsx | 243 ++++++++++++ src/rich-text-editor/interfaces.ts | 122 ++++++ src/rich-text-editor/styles.scss | 162 ++++++++ src/test-utils/dom/rich-text-editor/index.ts | 45 +++ 11 files changed, 1210 insertions(+) create mode 100644 pages/rich-text-editor/permutations.page.tsx create mode 100644 pages/rich-text-editor/simple.page.tsx create mode 100644 src/rich-text-editor/__tests__/rich-text-editor.test.tsx create mode 100644 src/rich-text-editor/index.tsx create mode 100644 src/rich-text-editor/interfaces.ts create mode 100644 src/rich-text-editor/styles.scss create mode 100644 src/test-utils/dom/rich-text-editor/index.ts diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..1407c30136 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -66,6 +66,7 @@ const pluralizationMap = { PromptInput: 'PromptInputs', PropertyFilter: 'PropertyFilters', RadioButton: 'RadioButtons', + RichTextEditor: 'RichTextEditors', RadioGroup: 'RadioGroups', S3ResourceSelector: 'S3ResourceSelectors', SegmentedControl: 'SegmentedControls', diff --git a/pages/rich-text-editor/permutations.page.tsx b/pages/rich-text-editor/permutations.page.tsx new file mode 100644 index 0000000000..8bd3882e6d --- /dev/null +++ b/pages/rich-text-editor/permutations.page.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import RichTextEditor, { RichTextEditorProps } from '~components/rich-text-editor'; + +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; +import ScreenshotArea from '../utils/screenshot-area'; + +const permutations = createPermutations([ + { + value: ['', '

Formatted bold and italic text.

'], + placeholder: ['Enter formatted text'], + disabled: [false, true], + }, + { + value: [''], + readOnly: [false, true], + }, + { + value: ['

Invalid state

'], + invalid: [true], + }, + { + value: ['

Custom toolbar

'], + toolbarControls: [['bold', 'italic', 'link']], + }, +]); + +export default function RichTextEditorPermutations() { + return ( + <> +

Rich text editor permutations

+ + {}} {...permutation} />} + /> + + + ); +} diff --git a/pages/rich-text-editor/simple.page.tsx b/pages/rich-text-editor/simple.page.tsx new file mode 100644 index 0000000000..f85275c12c --- /dev/null +++ b/pages/rich-text-editor/simple.page.tsx @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import FormField from '~components/form-field'; +import RichTextEditor from '~components/rich-text-editor'; +import SpaceBetween from '~components/space-between'; + +import ScreenshotArea from '../utils/screenshot-area'; + +export default function RichTextEditorSimplePage() { + const [value, setValue] = useState('

Hello, Cloudscape!

'); + return ( + +

Rich text editor (WIP v0)

+ + + + setValue(event.detail.value)} + /> + + + + {}} /> + + + {value} + + +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..09b1aceed9 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -24598,6 +24598,235 @@ If you want to clear the selection, use \`null\`.", } `; +exports[`Components definition for rich-text-editor matches the snapshot: rich-text-editor 1`] = ` +{ + "dashCaseName": "rich-text-editor", + "events": [ + { + "cancelable": false, + "description": "Called when the editor loses focus.", + "name": "onBlur", + }, + { + "cancelable": false, + "description": "Called whenever the editor content changes. The event detail contains the +new value as an HTML string.", + "detailInlineType": { + "name": "RichTextEditorProps.ChangeDetail", + "properties": [ + { + "name": "value", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "RichTextEditorProps.ChangeDetail", + "name": "onChange", + }, + { + "cancelable": false, + "description": "Called when the editor receives focus.", + "name": "onFocus", + }, + ], + "functions": [ + { + "description": "Moves focus into the editable region.", + "name": "focus", + "parameters": [], + "returnType": "void", + }, + ], + "name": "RichTextEditor", + "properties": [ + { + "description": "Adds \`aria-describedby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for each element that you want to use as a description +and set the property to a string of each ID separated by spaces (for example, \`"id1 id2 id3"\`).", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds an \`aria-label\` to the editable region. Use this to provide an +accessible name when the component is not associated with a form field.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for the element you want to use as label and set the property to that ID.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the ID of the native form element. You can use it to relate +a label element's \`for\` attribute to this control. + +It defaults to an automatically generated ID that +is provided by its parent form field component.", + "name": "controlId", + "optional": true, + "type": "string", + }, + { + "description": "Determines whether the editor is disabled. A disabled editor cannot be +focused or edited and its toolbar controls are inactive.", + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "{}", + "description": "An object containing all the localized strings required by the component.", + "inlineType": { + "name": "RichTextEditorProps.I18nStrings", + "properties": [ + { + "name": "boldButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "bulletedListButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "italicButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "linkButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "linkUrlPromptText", + "optional": true, + "type": "string", + }, + { + "name": "numberedListButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "strikethroughButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "toolbarAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "underlineButtonAriaLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "RichTextEditorProps.I18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Overrides the invalidation state. Usually the invalid state +comes from the parent \`FormField\`component, +however sometimes you need to override its +state when you have more than one input within a +single form field.", + "name": "invalid", + "optional": true, + "type": "boolean", + }, + { + "description": "Placeholder text rendered when the editor is empty.", + "name": "placeholder", + "optional": true, + "type": "string", + }, + { + "description": "Determines whether the editor is read-only. A read-only editor renders its +value but cannot be edited. The toolbar is hidden in read-only mode.", + "name": "readOnly", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "DEFAULT_TOOLBAR_CONTROLS", + "description": "Controls which formatting actions are shown in the toolbar, and in which +order. When omitted, all supported v0 controls are shown.", + "name": "toolbarControls", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "The current value of the editor, as an HTML string. + +The component is controlled: pass the value returned by the latest \`onChange\` +event back into this property to keep the editor in sync. + +Note (WIP v0): the value is sanitized only for rendering by the browser's +\`contenteditable\` implementation. Consuming applications are responsible for +sanitizing/escaping the HTML before persisting or re-rendering it elsewhere.", + "name": "value", + "optional": false, + "type": "string", + }, + { + "description": "Overrides the warning state. Usually the warning state +comes from the parent \`FormField\`component, +however sometimes you need to override its +state when you have more than one input within a +single form field. +When you use it, provide additional context with +information on the input state, and associate it +with the input using \`ariaDescribedby\`.", + "name": "warning", + "optional": true, + "type": "boolean", + }, + ], + "regions": [], + "releaseStatus": "stable", +} +`; + exports[`Components definition for s3-resource-selector matches the snapshot: s3-resource-selector 1`] = ` { "dashCaseName": "s3-resource-selector", @@ -43783,6 +44012,84 @@ Returns the current value of the input.", ], "name": "FilteringGroupedTokenWrapper", }, + { + "methods": [ + { + "description": "Finds the editable (contenteditable) region.", + "name": "findContent", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLDivElement", + }, + ], + }, + }, + { + "description": "Finds the formatting toolbar. Returns \`null\` when the component is read-only.", + "name": "findToolbar", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds a specific toolbar button by its control name (e.g. \`"bold"\`).", + "name": "findToolbarButton", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "control", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLButtonElement", + }, + ], + }, + }, + { + "description": "Finds all toolbar buttons.", + "name": "findToolbarButtons", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns the current value of the editor as an HTML string.", + "name": "getValue", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "string", + }, + }, + ], + "name": "RichTextEditorWrapper", + }, { "methods": [ { @@ -53334,6 +53641,60 @@ In this case, use findContentEditableElement() instead.", ], "name": "FilteringGroupedTokenWrapper", }, + { + "methods": [ + { + "description": "Finds the editable (contenteditable) region.", + "name": "findContent", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the formatting toolbar. Returns \`null\` when the component is read-only.", + "name": "findToolbar", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds a specific toolbar button by its control name (e.g. \`"bold"\`).", + "name": "findToolbarButton", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "control", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds all toolbar buttons.", + "name": "findToolbarButtons", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "RichTextEditorWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 4b1af42bc5..ed617ec2c2 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -569,6 +569,12 @@ exports[`test-utils selectors 1`] = ` "radio-group": [ "awsui_root_1np5w", ], + "rich-text-editor": [ + "awsui_content_1wzki", + "awsui_root_1wzki", + "awsui_toolbar-button_1wzki", + "awsui_toolbar_1wzki", + ], "s3-resource-selector": [ "awsui_alert_1u0yw", "awsui_browse-button_1vtzr", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 118ac8d1b2..809fef36f9 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -74,6 +74,7 @@ import PromptInputWrapper from './prompt-input'; import PropertyFilterWrapper from './property-filter'; import RadioButtonWrapper from './radio-button'; import RadioGroupWrapper from './radio-group'; +import RichTextEditorWrapper from './rich-text-editor'; import S3ResourceSelectorWrapper from './s3-resource-selector'; import SegmentedControlWrapper from './segmented-control'; import SelectWrapper from './select'; @@ -170,6 +171,7 @@ export { PromptInputWrapper }; export { PropertyFilterWrapper }; export { RadioButtonWrapper }; export { RadioGroupWrapper }; +export { RichTextEditorWrapper }; export { S3ResourceSelectorWrapper }; export { SegmentedControlWrapper }; export { SelectWrapper }; @@ -2023,6 +2025,34 @@ findAllRadioGroups(selector?: string): Array; * @returns {RadioGroupWrapper | null} */ findClosestRadioGroup(): RadioGroupWrapper | null; +/** + * Returns the wrapper of the first RichTextEditor that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first RichTextEditor. + * If no matching RichTextEditor is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {RichTextEditorWrapper | null} + */ +findRichTextEditor(selector?: string): RichTextEditorWrapper | null; + +/** + * Returns an array of RichTextEditor wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the RichTextEditors inside the current wrapper. + * If no matching RichTextEditor is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllRichTextEditors(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent RichTextEditor for the current element, + * or the element itself if it is an instance of RichTextEditor. + * If no RichTextEditor is found, returns \`null\`. + * + * @returns {RichTextEditorWrapper | null} + */ +findClosestRichTextEditor(): RichTextEditorWrapper | null; /** * Returns the wrapper of the first S3ResourceSelector that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first S3ResourceSelector. @@ -3684,6 +3714,19 @@ ElementWrapper.prototype.findRadioGroup = function(selector) { ElementWrapper.prototype.findAllRadioGroups = function(selector) { return this.findAllComponents(RadioGroupWrapper, selector); }; +ElementWrapper.prototype.findRichTextEditor = function(selector) { + let rootSelector = \`.\${RichTextEditorWrapper.rootSelector}\`; + if("legacyRootSelector" in RichTextEditorWrapper && RichTextEditorWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${RichTextEditorWrapper.rootSelector}, .\${RichTextEditorWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, RichTextEditorWrapper); +}; + +ElementWrapper.prototype.findAllRichTextEditors = function(selector) { + return this.findAllComponents(RichTextEditorWrapper, selector); +}; ElementWrapper.prototype.findS3ResourceSelector = function(selector) { let rootSelector = \`.\${S3ResourceSelectorWrapper.rootSelector}\`; if("legacyRootSelector" in S3ResourceSelectorWrapper && S3ResourceSelectorWrapper.legacyRootSelector){ @@ -4387,6 +4430,11 @@ ElementWrapper.prototype.findClosestRadioGroup = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(RadioGroupWrapper); }; +ElementWrapper.prototype.findClosestRichTextEditor = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(RichTextEditorWrapper); +}; ElementWrapper.prototype.findClosestS3ResourceSelector = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4616,6 +4664,7 @@ import PromptInputWrapper from './prompt-input'; import PropertyFilterWrapper from './property-filter'; import RadioButtonWrapper from './radio-button'; import RadioGroupWrapper from './radio-group'; +import RichTextEditorWrapper from './rich-text-editor'; import S3ResourceSelectorWrapper from './s3-resource-selector'; import SegmentedControlWrapper from './segmented-control'; import SelectWrapper from './select'; @@ -4712,6 +4761,7 @@ export { PromptInputWrapper }; export { PropertyFilterWrapper }; export { RadioButtonWrapper }; export { RadioGroupWrapper }; +export { RichTextEditorWrapper }; export { S3ResourceSelectorWrapper }; export { SegmentedControlWrapper }; export { SelectWrapper }; @@ -5850,6 +5900,23 @@ findRadioGroup(selector?: string): RadioGroupWrapper; * @returns {MultiElementWrapper} */ findAllRadioGroups(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the RichTextEditors with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches RichTextEditors. + * + * @param {string} [selector] CSS Selector + * @returns {RichTextEditorWrapper} + */ +findRichTextEditor(selector?: string): RichTextEditorWrapper; + +/** + * Returns a multi-element wrapper that matches RichTextEditors with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches RichTextEditors. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllRichTextEditors(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the S3ResourceSelectors with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches S3ResourceSelectors. @@ -7192,6 +7259,19 @@ ElementWrapper.prototype.findRadioGroup = function(selector) { ElementWrapper.prototype.findAllRadioGroups = function(selector) { return this.findAllComponents(RadioGroupWrapper, selector); }; +ElementWrapper.prototype.findRichTextEditor = function(selector) { + let rootSelector = \`.\${RichTextEditorWrapper.rootSelector}\`; + if("legacyRootSelector" in RichTextEditorWrapper && RichTextEditorWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${RichTextEditorWrapper.rootSelector}, .\${RichTextEditorWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, RichTextEditorWrapper); +}; + +ElementWrapper.prototype.findAllRichTextEditors = function(selector) { + return this.findAllComponents(RichTextEditorWrapper, selector); +}; ElementWrapper.prototype.findS3ResourceSelector = function(selector) { let rootSelector = \`.\${S3ResourceSelectorWrapper.rootSelector}\`; if("legacyRootSelector" in S3ResourceSelectorWrapper && S3ResourceSelectorWrapper.legacyRootSelector){ diff --git a/src/rich-text-editor/__tests__/rich-text-editor.test.tsx b/src/rich-text-editor/__tests__/rich-text-editor.test.tsx new file mode 100644 index 0000000000..5f5050d8fc --- /dev/null +++ b/src/rich-text-editor/__tests__/rich-text-editor.test.tsx @@ -0,0 +1,110 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { createRef } from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import RichTextEditor, { RichTextEditorProps } from '../../../lib/components/rich-text-editor'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +function renderEditor(props: Partial & React.RefAttributes = {}) { + const { container } = render( {}} {...props} />); + const wrapper = createWrapper(container).findRichTextEditor()!; + return { wrapper, container }; +} + +describe('RichTextEditor', () => { + test('renders the editable contenteditable region', () => { + const { wrapper } = renderEditor(); + const content = wrapper.findContent().getElement(); + expect(content).toHaveAttribute('contenteditable', 'true'); + expect(content).toHaveAttribute('role', 'textbox'); + }); + + test('renders the provided HTML value', () => { + const { wrapper } = renderEditor({ value: '

hello world

' }); + expect(wrapper.getValue()).toBe('

hello world

'); + }); + + test('applies the aria-label to the editable region', () => { + const { wrapper } = renderEditor({ ariaLabel: 'My editor' }); + expect(wrapper.findContent().getElement()).toHaveAttribute('aria-label', 'My editor'); + }); + + test('shows the placeholder only when the value is empty', () => { + const { wrapper } = renderEditor({ placeholder: 'Type here' }); + expect(wrapper.getElement().textContent).toContain('Type here'); + + const { wrapper: filled } = renderEditor({ value: '

x

', placeholder: 'Type here' }); + expect(filled.getElement().textContent).not.toContain('Type here'); + }); + + describe('toolbar', () => { + test('renders the default set of toolbar buttons', () => { + const { wrapper } = renderEditor(); + expect(wrapper.findToolbar()).not.toBeNull(); + expect(wrapper.findToolbarButtons()).toHaveLength(7); + }); + + test('renders only the requested toolbar controls', () => { + const { wrapper } = renderEditor({ toolbarControls: ['bold', 'italic', 'link'] }); + expect(wrapper.findToolbarButtons()).toHaveLength(3); + expect(wrapper.findToolbarButton('bold')).not.toBeNull(); + expect(wrapper.findToolbarButton('numbered-list')).toBeNull(); + }); + + test('exposes an accessible label per toolbar button', () => { + const { wrapper } = renderEditor(); + expect(wrapper.findToolbarButton('bold')!.getElement()).toHaveAttribute('aria-label', 'Bold'); + }); + + test('applies i18nStrings labels', () => { + const { wrapper } = renderEditor({ i18nStrings: { boldButtonAriaLabel: 'Gras' } }); + expect(wrapper.findToolbarButton('bold')!.getElement()).toHaveAttribute('aria-label', 'Gras'); + }); + + test('is hidden in read-only mode', () => { + const { wrapper } = renderEditor({ readOnly: true }); + expect(wrapper.findToolbar()).toBeNull(); + }); + + test('clicking a toolbar button does not throw and fires onChange', () => { + const onChange = jest.fn(); + const { wrapper } = renderEditor({ onChange }); + fireEvent.click(wrapper.findToolbarButton('bold')!.getElement()); + expect(onChange).toHaveBeenCalled(); + }); + }); + + test('fires onChange with the current HTML on input', () => { + const onChange = jest.fn(); + const { wrapper } = renderEditor({ onChange }); + const content = wrapper.findContent().getElement(); + content.innerHTML = '

updated

'; + fireEvent.input(content); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: '

updated

' } })); + }); + + test('fires onFocus and onBlur', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { wrapper } = renderEditor({ onFocus, onBlur }); + const content = wrapper.findContent().getElement(); + fireEvent.focus(content); + fireEvent.blur(content); + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + }); + + test('disabled editor is not editable and toolbar buttons are disabled', () => { + const { wrapper } = renderEditor({ disabled: true }); + expect(wrapper.findContent().getElement()).toHaveAttribute('contenteditable', 'false'); + expect(wrapper.findToolbarButton('bold')!.getElement()).toBeDisabled(); + }); + + test('exposes an imperative focus() handle', () => { + const ref = createRef(); + const { wrapper } = renderEditor({ ref }); + ref.current!.focus(); + expect(wrapper.findContent().getElement()).toHaveFocus(); + }); +}); diff --git a/src/rich-text-editor/index.tsx b/src/rich-text-editor/index.tsx new file mode 100644 index 0000000000..9f146c5fa9 --- /dev/null +++ b/src/rich-text-editor/index.tsx @@ -0,0 +1,243 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React, { Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import clsx from 'clsx'; + +import Icon from '../icon/internal'; +import { getBaseProps } from '../internal/base-component'; +import { useFormFieldContext } from '../internal/context/form-field-context'; +import { fireNonCancelableEvent } from '../internal/events'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { RichTextEditorProps } from './interfaces'; + +import styles from './styles.css.js'; + +export { RichTextEditorProps }; + +const DEFAULT_TOOLBAR_CONTROLS: ReadonlyArray = [ + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'bulleted-list', + 'numbered-list', + 'link', +]; + +interface ToolbarControlDefinition { + /** The `document.execCommand` command name backing this control. */ + command: string; + /** Default accessible label (overridable through `i18nStrings`). */ + defaultAriaLabel: string; + /** Resolves the accessible label from the provided `i18nStrings`. */ + getAriaLabel: (i18n: RichTextEditorProps.I18nStrings) => string | undefined; + /** Whether the control needs a value argument (e.g. a URL for links). */ + requiresValue?: boolean; + /** Rendered content of the toolbar button. */ + render: () => React.ReactNode; +} + +const CONTROL_DEFINITIONS: Record = { + bold: { + command: 'bold', + defaultAriaLabel: 'Bold', + getAriaLabel: i18n => i18n.boldButtonAriaLabel, + render: () => B, + }, + italic: { + command: 'italic', + defaultAriaLabel: 'Italic', + getAriaLabel: i18n => i18n.italicButtonAriaLabel, + render: () => I, + }, + underline: { + command: 'underline', + defaultAriaLabel: 'Underline', + getAriaLabel: i18n => i18n.underlineButtonAriaLabel, + render: () => U, + }, + strikethrough: { + command: 'strikeThrough', + defaultAriaLabel: 'Strikethrough', + getAriaLabel: i18n => i18n.strikethroughButtonAriaLabel, + render: () => S, + }, + 'bulleted-list': { + command: 'insertUnorderedList', + defaultAriaLabel: 'Bulleted list', + getAriaLabel: i18n => i18n.bulletedListButtonAriaLabel, + render: () => , + }, + 'numbered-list': { + command: 'insertOrderedList', + defaultAriaLabel: 'Numbered list', + getAriaLabel: i18n => i18n.numberedListButtonAriaLabel, + render: () => 1., + }, + link: { + command: 'createLink', + defaultAriaLabel: 'Insert link', + getAriaLabel: i18n => i18n.linkButtonAriaLabel, + requiresValue: true, + render: () => , + }, +}; + +const RichTextEditor = React.forwardRef( + ( + { + value, + onChange, + onBlur, + onFocus, + placeholder, + disabled, + readOnly, + ariaLabel, + toolbarControls = DEFAULT_TOOLBAR_CONTROLS, + i18nStrings = {}, + ...rest + }: RichTextEditorProps, + ref: Ref + ) => { + const { __internalRootRef } = useBaseComponent('RichTextEditor', { + props: { disabled, readOnly, toolbarControls: toolbarControls.join(',') }, + }); + const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = useFormFieldContext(rest); + const baseProps = getBaseProps(rest); + + const editorRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focus: () => editorRef.current?.focus(), + })); + + // Sync the controlled value into the DOM without resetting the caret while + // the user is actively typing (only write when the value actually differs). + useEffect(() => { + const node = editorRef.current; + if (node && node.innerHTML !== value) { + // WIP v0: the controlled value is written directly to the contenteditable + // element. Consuming applications are responsible for sanitizing the HTML + // (see interfaces.ts). Built-in sanitization is tracked as follow-up work. + // eslint-disable-next-line no-unsanitized/property + node.innerHTML = value ?? ''; + } + }, [value]); + + const emitChange = useCallback(() => { + const node = editorRef.current; + if (node) { + fireNonCancelableEvent(onChange, { value: node.innerHTML }); + } + }, [onChange]); + + const runCommand = useCallback( + (control: RichTextEditorProps.ToolbarControl) => { + if (disabled || readOnly) { + return; + } + const definition = CONTROL_DEFINITIONS[control]; + const node = editorRef.current; + if (!node) { + return; + } + // Ensure the selection is inside the editor before running the command. + node.focus(); + try { + if (definition.requiresValue) { + // WIP v0: use a native prompt for the link URL. A dedicated, + // accessible link dialog is tracked as follow-up work. + const url = + typeof window !== 'undefined' + ? window.prompt(i18nStrings.linkUrlPromptText ?? 'Enter a URL', 'https://') + : null; + if (!url) { + return; + } + document.execCommand(definition.command, false, url); + } else { + document.execCommand(definition.command); + } + } catch { + // `document.execCommand` is deprecated and may throw or be unsupported + // in some environments (e.g. jsdom). Swallow to keep the UI resilient; + // replacing execCommand with a Selection-API editor core is follow-up work. + } + emitChange(); + }, + [disabled, readOnly, emitChange, i18nStrings.linkUrlPromptText] + ); + + const showPlaceholder = !value && !!placeholder; + const editable = !disabled && !readOnly; + + return ( +
+
+ {!readOnly && ( +
+ {toolbarControls.map(control => { + const definition = CONTROL_DEFINITIONS[control]; + return ( + + ); + })} +
+ )} + +
+ {showPlaceholder &&
{placeholder}
} +
fireNonCancelableEvent(onBlur)} + onFocus={() => fireNonCancelableEvent(onFocus)} + /> +
+
+
+ ); + } +); + +applyDisplayName(RichTextEditor, 'RichTextEditor'); +export default RichTextEditor; diff --git a/src/rich-text-editor/interfaces.ts b/src/rich-text-editor/interfaces.ts new file mode 100644 index 0000000000..bd385cd04f --- /dev/null +++ b/src/rich-text-editor/interfaces.ts @@ -0,0 +1,122 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events'; +import { FormFieldValidationControlProps } from '../types/form-field'; + +export interface RichTextEditorProps extends BaseComponentProps, FormFieldValidationControlProps { + /** + * The current value of the editor, as an HTML string. + * + * The component is controlled: pass the value returned by the latest `onChange` + * event back into this property to keep the editor in sync. + * + * Note (WIP v0): the value is sanitized only for rendering by the browser's + * `contenteditable` implementation. Consuming applications are responsible for + * sanitizing/escaping the HTML before persisting or re-rendering it elsewhere. + */ + value: string; + + /** + * Called whenever the editor content changes. The event detail contains the + * new value as an HTML string. + */ + onChange?: NonCancelableEventHandler; + + /** + * Called when the editor loses focus. + */ + onBlur?: NonCancelableEventHandler; + + /** + * Called when the editor receives focus. + */ + onFocus?: NonCancelableEventHandler; + + /** + * Placeholder text rendered when the editor is empty. + */ + placeholder?: string; + + /** + * Determines whether the editor is disabled. A disabled editor cannot be + * focused or edited and its toolbar controls are inactive. + */ + disabled?: boolean; + + /** + * Determines whether the editor is read-only. A read-only editor renders its + * value but cannot be edited. The toolbar is hidden in read-only mode. + */ + readOnly?: boolean; + + /** + * Adds an `aria-label` to the editable region. Use this to provide an + * accessible name when the component is not associated with a form field. + */ + ariaLabel?: string; + + /** + * Controls which formatting actions are shown in the toolbar, and in which + * order. When omitted, all supported v0 controls are shown. + */ + toolbarControls?: ReadonlyArray; + + /** + * An object containing all the localized strings required by the component. + */ + i18nStrings?: RichTextEditorProps.I18nStrings; +} + +export namespace RichTextEditorProps { + export interface ChangeDetail { + /** + * The new value of the editor as an HTML string. + */ + value: string; + } + + export interface Ref { + /** + * Moves focus into the editable region. + */ + focus(): void; + } + + /** + * The formatting actions supported by the v0 toolbar. + * + * - `bold`, `italic`, `underline`, `strikethrough`: inline text styles. + * - `bulleted-list`, `numbered-list`: block list formatting. + * - `link`: wraps the current selection in a hyperlink. + */ + export type ToolbarControl = + | 'bold' + | 'italic' + | 'underline' + | 'strikethrough' + | 'bulleted-list' + | 'numbered-list' + | 'link'; + + export interface I18nStrings { + /** Accessible label for the toolbar container. */ + toolbarAriaLabel?: string; + /** Accessible label for the "bold" toolbar button. */ + boldButtonAriaLabel?: string; + /** Accessible label for the "italic" toolbar button. */ + italicButtonAriaLabel?: string; + /** Accessible label for the "underline" toolbar button. */ + underlineButtonAriaLabel?: string; + /** Accessible label for the "strikethrough" toolbar button. */ + strikethroughButtonAriaLabel?: string; + /** Accessible label for the "bulleted list" toolbar button. */ + bulletedListButtonAriaLabel?: string; + /** Accessible label for the "numbered list" toolbar button. */ + numberedListButtonAriaLabel?: string; + /** Accessible label for the "link" toolbar button. */ + linkButtonAriaLabel?: string; + /** Prompt shown when requesting the URL for a new link. */ + linkUrlPromptText?: string; + } +} diff --git a/src/rich-text-editor/styles.scss b/src/rich-text-editor/styles.scss new file mode 100644 index 0000000000..8c87dc1b1d --- /dev/null +++ b/src/rich-text-editor/styles.scss @@ -0,0 +1,162 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; + +.root { + /* used for test-utils */ + @include styles.styles-reset; + display: block; + inline-size: 100%; +} + +.editor { + box-sizing: border-box; + display: flex; + flex-direction: column; + inline-size: 100%; + background-color: awsui.$color-background-input-default; + border-block: awsui.$border-width-field solid awsui.$color-border-input-default; + border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; + border-start-start-radius: styles.$control-border-radius; + border-start-end-radius: styles.$control-border-radius; + border-end-start-radius: styles.$control-border-radius; + border-end-end-radius: styles.$control-border-radius; + overflow: hidden; + + &:focus-within { + border-color: awsui.$color-border-item-focused; + box-shadow: 0 0 0 1px awsui.$color-border-item-focused; + } +} + +.editor-invalid { + border-color: awsui.$color-text-status-error; + border-inline-start-width: awsui.$border-invalid-width; +} + +.editor-warning { + border-color: awsui.$color-text-status-warning; + border-inline-start-width: awsui.$border-invalid-width; +} + +.editor-readonly { + background-color: awsui.$color-background-input-disabled; +} + +.editor-disabled { + background-color: awsui.$color-background-input-disabled; + border-color: awsui.$color-border-input-disabled; +} + +.toolbar { + /* used for test-utils */ + display: flex; + flex-wrap: wrap; + gap: awsui.$space-xxs; + padding-block: awsui.$space-xxs; + padding-inline: awsui.$space-xxs; + border-block-end: awsui.$border-width-field solid awsui.$color-border-divider-default; + background-color: awsui.$color-background-container-content; +} + +.toolbar-button { + /* used for test-utils */ + @include styles.styles-reset; + @include styles.font-body-m; + box-sizing: border-box; + cursor: pointer; + min-inline-size: 28px; + block-size: 28px; + padding-block: 0; + padding-inline: awsui.$space-xxs; + display: inline-flex; + align-items: center; + justify-content: center; + color: awsui.$color-text-body-default; + background-color: transparent; + border-block: awsui.$border-width-field solid transparent; + border-inline: awsui.$border-width-field solid transparent; + border-start-start-radius: styles.$control-border-radius; + border-start-end-radius: styles.$control-border-radius; + border-end-start-radius: styles.$control-border-radius; + border-end-end-radius: styles.$control-border-radius; + + &:hover { + background-color: awsui.$color-background-dropdown-item-hover; + } + + &:focus-visible { + @include styles.focus-highlight(2px); + } + + &-pressed { + background-color: awsui.$color-background-control-checked; + color: awsui.$color-text-interactive-active; + } + + &-disabled { + cursor: default; + color: awsui.$color-text-interactive-disabled; + + &:hover { + background-color: transparent; + } + } +} + +.toolbar-button-glyph-italic { + font-style: italic; +} + +.toolbar-button-glyph-bold { + font-weight: styles.$font-weight-bold; +} + +.toolbar-button-glyph-underline { + text-decoration: underline; +} + +.toolbar-button-glyph-strikethrough { + text-decoration: line-through; +} + +.content-wrapper { + position: relative; +} + +.content { + /* used for test-utils - the contenteditable region */ + @include styles.font-body-m; + box-sizing: border-box; + inline-size: 100%; + min-block-size: 96px; + padding-block: styles.$control-padding-vertical; + padding-inline: styles.$control-padding-horizontal; + color: awsui.$color-text-body-default; + overflow-wrap: break-word; + overflow-y: auto; + + &:focus { + outline: none; + } + + &-disabled { + color: awsui.$color-text-interactive-disabled; + } +} + +.placeholder { + /* used for test-utils */ + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + padding-block: styles.$control-padding-vertical; + padding-inline: styles.$control-padding-horizontal; + color: awsui.$color-text-input-placeholder; + pointer-events: none; + user-select: none; +} diff --git a/src/test-utils/dom/rich-text-editor/index.ts b/src/test-utils/dom/rich-text-editor/index.ts new file mode 100644 index 0000000000..4dccb2abb5 --- /dev/null +++ b/src/test-utils/dom/rich-text-editor/index.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ComponentWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; + +import selectors from '../../../rich-text-editor/styles.selectors.js'; + +export default class RichTextEditorWrapper extends ComponentWrapper { + static rootSelector: string = selectors.root; + + /** + * Finds the editable (contenteditable) region. + */ + findContent(): ElementWrapper { + return this.find(`.${selectors.content}`)!; + } + + /** + * Finds the formatting toolbar. Returns `null` when the component is read-only. + */ + findToolbar(): ElementWrapper | null { + return this.findByClassName(selectors.toolbar); + } + + /** + * Finds all toolbar buttons. + */ + findToolbarButtons(): Array> { + return this.findAllByClassName(selectors['toolbar-button']); + } + + /** + * Finds a specific toolbar button by its control name (e.g. `"bold"`). + */ + findToolbarButton(control: string): ElementWrapper | null { + return this.find(`.${selectors['toolbar-button']}[data-control="${control}"]`); + } + + /** + * Returns the current value of the editor as an HTML string. + */ + @usesDom getValue(): string { + return this.findContent().getElement().innerHTML; + } +} From e0cc8fc1d31b2fc059904a118096ab7dbd64c9a5 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 16:21:45 +0000 Subject: [PATCH 2/2] fix: add accessible name to rich text editor permutations page The RichTextEditor renders a contenteditable region with role="textbox", so it requires an accessible name. The permutations dev page rendered the editor without an ariaLabel and outside a FormField, so axe reported a serious aria-input-field-name violation on the a11y shards. Pass a default ariaLabel in the permutations render so every permutation has an accessible name. --- pages/rich-text-editor/permutations.page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/rich-text-editor/permutations.page.tsx b/pages/rich-text-editor/permutations.page.tsx index 8bd3882e6d..d77584036c 100644 --- a/pages/rich-text-editor/permutations.page.tsx +++ b/pages/rich-text-editor/permutations.page.tsx @@ -35,7 +35,7 @@ export default function RichTextEditorPermutations() { {}} {...permutation} />} + render={permutation => {}} {...permutation} />} />