diff --git a/docs/installation.mdx b/docs/installation.mdx index 32c8a09c11..20e69073bf 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -37,12 +37,6 @@ You will need to install the following dependencies in your project, as they are npm install @sage/design-tokens@^4.0 react@^17.0 react-dom@^17.0 styled-components@^4.4.1 ``` -Carbon uses [draft-js](https://draftjs.org/) for the `TextEditor` or `Note` components. If you plan on using these components in your project, run the following to install the package: - -```shell -npm install draft-js -``` - ### Fonts and icons Carbon uses **Sage UI** font by default, or a fallback Sans-Serif font if it cannot find it. **Carbon does not come with Sage UI built in**, so make sure you have `@sage/design-tokens` installed as it provides the font assets: diff --git a/docs/using-draft-js.mdx b/docs/using-draft-js.mdx deleted file mode 100644 index 19b4eb0d8b..0000000000 --- a/docs/using-draft-js.mdx +++ /dev/null @@ -1,138 +0,0 @@ -import { Meta } from "@storybook/blocks"; - - - -# Using Draft.js EditorState and ContentState - -## Contents - -[Introduction to Draft.js](#installation) - -- [EditorState](#editorstate) -- [ContentState](#contentstate) -- [Useful Links](#useful-links) - -## Installation - -The [TextEditor](https://carbon.sage.com/?path=/docs/text-editor--default) and -[Note](https://carbon.sage.com/?path=/docs/note--default) components utilise the `Draft.js` framework to -support creating and rendering rich-text content. As such, the framework has been added as a peer-dependency and -consuming projects are required to install it if they wish to use either component, this can be done as an -[npm package](https://www.npmjs.com/package/draft-js). - -```sh -npm install draft-js@^0.11.5 -``` - -### EditorState - -The `EditorState` is an Immutable Record representing the the top-level object for the entire state of the `Editor`. -Interacting with it will provide access to a wide range of useful information. This includes the current text -`ContentState`, accessible via the `getCurrentContent()` method exposed as part of the API. It also provides access to -the current `SelectionState` (`getSelection()`), the fully decorated representation of the contents -(`getCurrentInlineStyle()` and `getBlockTree()`), undo/redo stacks and the most recent type of change made to the -contents. For more information refer to the API documentation https://draftjs.org/docs/api-reference-editor-state. - -#### Importing EditorState - -The `EditorState` can be imported either directly from `draft-js` or alternatively it has been exposed as part of the -`TextEditor` component's interface. - -```js -import { EditorState } from "draft-js"; -``` - -```js -import { TextEditorState } from "carbon-react/lib/components/text-editor"; -``` - -#### Useful static methods - -The framework surfaces a range of static methods for initialising the state: - -- `EditorState.createEmpty(decorator?: DraftDecoratorType)` - will intitialise the component with a new `EditorState` - object with an empty `ContentState` and any `Decorators` passed to it. -- `EditorState.createWithContent(contentState: ContentState, decorator?: DraftDecoratorType)` - is used to initialise - the component with some existing `ContentState` and any `Decorators` and return a new `EditorState` object. -- `EditorState.create(config: EditorStateCreationConfig)` - offers the same as `createWithContent` but enables - initialising the `EditorState` from a config, affording you more fine grain control. For example, it is possible to - define an intitial `SelectionState` using this static method. - -#### Other useful methods - -- `EditorState.push(editorState: EditorState, contentState: ContentState, changeType: EditorChangeType)` - it is very - unlikely that you will have a need to use this but if direct content changes are required they must be applied to the - `EditorState` using this method. It returns a new `EditorState` object with the specified `ContentState` applied as the - new currentContent, the `changeType` defines the operation being carried out on the state, see - https://draftjs.org/docs/api-reference-editor-change-type for the defined options. -- The current `ContentState` can be accessed by calling the `getCurrentContent()` instance method. -- The current `SelectionState` can be accessed by calling the `getSelection()` instance method. - -### ContentState - -`ContentState` is also an Immutable Record and represents the state of the editor's entire contents (text, block and -inline styles, and entity ranges) and its two selection states (before and after rendering). There are range of useful -methods accessible through `ContentState` that can provide information about the current content rendered in the editor, -for more details it is best to refer to the API reference documentation -https://draftjs.org/docs/api-reference-content-state. - -#### Importing ContentState - -The `ContentState` can be imported either directly from `draft-js` or alternatively it has been exposed as part of the -`TextEditor` component's interface. - -```js -import { ContentState } from "draft-js"; -``` - -```js -import { TextEditorContentState } from "carbon-react/lib/components/text-editor"; -``` - -#### Useful static methods - -There are two static methods that facilitate creating `ContentState`: - -- `createFromText(text: string, delimiter?: string)` - will generate state from a given string paramater, passing an - optional delimiter will define how the content blocks are split; if no delimiter is provided the method will default to - using `\n`. This method will commonly be used with the `createWithContent` method surfaced by the `EditorState` like so: - -```js -const Foo = (props) => { - const [state, setState] = useState( - EditorState.createWithContent(ContentState.createFromText("text content")) - ); - - return ( - setState(newState)} /> - ); -}; -``` - -- `createFromBlockArray(blocks: Array, entityMap: ?OrderedMap)` - this method will produce a - `ContentState` object from an array of `ContentBlock`s, an optional Immutable map of `DraftEntity` records can also be - provided as the second argument. A `ContentBlock` is itself an Immutable Record that maintains the following - information about a block: a string key, the entity type, the block's text and an Immutable characterList which - maintains the styling and other data for each character. Commonly this method will be used in conjunction with `Draft`'s - data conversion methods (https://draftjs.org/docs/api-reference-data-conversion), below is an example of using it when - converting html into `ContentState` using `convertFromHTML`. - -```js - const Foo = (props) => { - const html = `

Lorem ipsum

`; - const blocksFromHTML = convertFromHTML(html); - const contentState = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap - ); - const [state, setState] = useState(EditorState.createWithContent(contentState); - - return ( - setState(newState)} /> - ); - }; -``` - -### Useful Links - -- API reference documentaion - https://draftjs.org/docs/api-reference-editor-state. diff --git a/docs/using-lexical.mdx b/docs/using-lexical.mdx new file mode 100644 index 0000000000..6ce56eebf2 --- /dev/null +++ b/docs/using-lexical.mdx @@ -0,0 +1,57 @@ +import { Meta } from "@storybook/blocks"; + + + +# Lexical + +## Why Lexical? + +In previous versions of Carbon, the `Note` and `RichTextEditor` components were built using [DraftJS](https://draftjs.org/), a framework developed by Facebook. However, `DraftJS` has been deprecated and is no longer maintained. As a result, we have migrated to a new framework called +[Lexical](https://lexical.dev), which is also developed by Meta. In doing so, we have been able to maintain the functionality of the `Note` and `RichTextEditor` components while also ensuring that they are up-to-date and well-maintained. `Lexical` is a powerful and flexible framework +that allows for the creation of rich text editors and other text-based components, and is fully customisable which allows us to cater for a wide range of use cases. + +## Using the Lexical-based components + +If you wish to use these components in your project, you will need to install the `lexical` library as a dependency. To do so, run the following command: + +```bash +npm install --save lexical +``` + +### Packages + +The following `lexical` packages are used in Carbon to provide the functionality for `Note` and `RichTextEditor`: + +- `lexical`: The core package; +- `@lexical/react`: A wrapper around the core package for React; +- `@lexical/headless`: A headless version of the core package used internally for testing the components; +- `@lexical/link`: A package for handling links in the `RichTextEditor`; +- `@lexical/selection`: A package for handling selections in the `RichTextEditor`. + +### Usage + +First, import the relevant components from the `carbon-react` package: + +```jsx +import RichTextEditor, { CreateFromHTML } from 'carbon-react/lib/components/rich-text-editor'; +import Note from 'carbon-react/lib/components/note'; +``` + +You don't need to do anything specific to use the `lexical` library with `Note` or `RichTextEditor`; the components are already set up to use the `@lexical/react` package internally. +The reason we ask you to install the `lexical` library is to ensure that you have access to the full functionality of the library in case you need it. If you do not use, or have no plans to use, +the `Note` or `RichTextEditor` components, you can safely ignore this dependency. + +Previously, we asked that you provide your data to the components wrapped in one of DraftJS' `EditorState` static methods: `createEmpty`, `createWithContent`, or `create`. The new approach +is to provide your data in one of two structures to the `RichTextEditor`, or three to the `Note` component. + +`RichTextEditor` accepts data in the following structures: + +- **A string of HTML**: This is the simplest way to provide data to the `RichTextEditor`. Simply pass your HTML string to the `CreateFromHTML` function, which will convert it to the required structure for the `RichTextEditor`; +- **A JSON object**: This is the more complex way to provide data to the `RichTextEditor`. The JSON object must be in the format used internally by Lexical; consult the [Lexical documentation](https://lexical.dev/docs/concepts/editor-state) for more information. When passing the object to the editor, ensure that it has first been converted to a raw string via `JSON.stringify`. + +`Note` accepts data in the same two structures as the `RichTextEditor`, as well as a third: plain text. Simply set the value of `noteContent` to your plain text value and the editor will take care of the rest. + +The documentation for both `RichTextEditor` and `Note` contain examples of how to use these structures: + +- [Rich Text Editor](../?path=/docs/rich-text-editor--docs) +- [Note](../?path=/docs/note--docs) diff --git a/package-lock.json b/package-lock.json index 721553dd49..ee8b83d40e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@floating-ui/dom": "~1.2.9", "@floating-ui/react-dom": "~1.3.0", + "@lexical/react": "^0.21.0", "@octokit/rest": "^18.12.0", "@styled-system/prop-types": "^5.1.5", "@tanstack/react-virtual": "3.10.1", @@ -23,6 +24,7 @@ "date-fns": "^2.30.0", "immutable": "~3.8.2", "invariant": "^2.2.4", + "lexical": "^0.21.0", "lodash": "^4.17.21", "polished": "^4.2.2", "prop-types": "^15.8.1", @@ -49,6 +51,9 @@ "@commitlint/cli": "^17.6.3", "@commitlint/config-conventional": "^17.6.3", "@dotenvx/dotenvx": "^1.25.1", + "@lexical/headless": "^0.21.0", + "@lexical/link": "^0.21.0", + "@lexical/selection": "^0.21.0", "@playwright/experimental-ct-react17": "~1.47.0", "@playwright/test": "~1.47.0", "@sage/design-tokens": "~4.29.0", @@ -75,7 +80,6 @@ "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.1", "@types/crypto-js": "^4.2.1", - "@types/draft-js": "^0.11.10", "@types/glob": "^8.1.0", "@types/invariant": "^2.2.37", "@types/jest": "^29.5.0", @@ -105,7 +109,6 @@ "cz-conventional-changelog": "^3.3.0", "date-fns-tz": "^1.3.8", "dayjs": "^1.11.10", - "draft-js": "^0.11.7", "eslint": "^8.55.0", "eslint-config-airbnb": "^19.0.0", "eslint-config-prettier": "^9.1.0", @@ -157,7 +160,6 @@ }, "peerDependencies": { "@sage/design-tokens": "^4.17.0", - "draft-js": "^0.11.7", "react": "^17.0.2", "react-dom": "^17.0.2", "styled-components": "^4.4.1" @@ -4012,6 +4014,272 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, + "node_modules/@lexical/clipboard": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.21.0.tgz", + "integrity": "sha512-3lNMlMeUob9fcnRXGVieV/lmPbmet/SVWckNTOwzfKrZ/YW5HiiyJrWviLRVf50dGXTbmBGt7K/2pfPYvWCHFA==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.21.0.tgz", + "integrity": "sha512-E0DNSFu4I+LMn3ft+UT0Dbntc8ZKjIA0BJj6BDewm0qh3bir40YUf5DkI2lpiFNRF2OpcmmcIxakREeU6avqTA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.21.0", + "lexical": "0.21.0", + "prismjs": "^1.27.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.21.0.tgz", + "integrity": "sha512-csK41CmRLZbKNV5pT4fUn5RzdPjU5PoWR8EqaS9kiyayhDg2zEnuPtvUYWanLfCLH9A2oOfbEsGxjMctAySlJw==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.21.0", + "@lexical/link": "0.21.0", + "@lexical/mark": "0.21.0", + "@lexical/table": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.21.0.tgz", + "integrity": "sha512-ahTCaOtRFNauEzplN1qVuPjyGAlDd+XcVM5FQCdxVh/1DvqmBxEJRVuCBqatzUUVb89jRBekYUcEdnY9iNjvEQ==", + "license": "MIT", + "dependencies": { + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.21.0.tgz", + "integrity": "sha512-O4dxcZNq1Xm45HLoRifbGAYvQkg3qLoBc6ibmHnDqZL5mQDsufnH6QEKWfgDtrvp9++3iqsSC+TE7VzWIvA7ww==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/headless": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/headless/-/headless-0.21.0.tgz", + "integrity": "sha512-7/eEz6ed39MAg34c+rU7xUn46UV4Wdt5dEZwsdBzuflWhpNeUscQmkw8wIoFhEhJdCc+ZbB17CnjJlUZ1RxHvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.21.0.tgz", + "integrity": "sha512-Sv2sici2NnAfHYHYRSjjS139MDT8fHP6PlYM2hVr+17dOg7/fJl22VBLRgQ7/+jLtAPxQjID69jvaMlOvt4Oog==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.21.0.tgz", + "integrity": "sha512-UGahVsGz8OD7Ya39qwquE+JPStTxCw/uaQrnUNorCM7owtPidO2H+tsilAB3A1GK3ksFGdHeEjBjG0Gf7gOg+Q==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.21.0.tgz", + "integrity": "sha512-/coktIyRXg8rXz/7uxXsSEfSQYxPIx8CmignAXWYhcyYtCWA0fD2mhEhWwVvHH9ofNzvidclRPYKUnrmUm3z3Q==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.21.0.tgz", + "integrity": "sha512-WItGlwwNJCS8b6SO1QPKzArShmD+OXQkLbhBcAh+EfpnkvmCW5T5LqY+OfIRmEN1dhDOnwqCY7mXkivWO8o5tw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.21.0.tgz", + "integrity": "sha512-2x/LoHDYPOkZbKHz4qLFWsPywjRv9KggTOtmRazmaNRUG0FpkImJwUbbaKjWQXeESVGpzfL3qNFSAmCWthsc4g==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.21.0.tgz", + "integrity": "sha512-XCQCyW5ujK0xR6evV8sF0hv/MRUA//kIrB2JiyF12tLQyjLRNEXO+0IKastWnMKSaDdJMKjzgd+4PiummYs7uA==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.21.0", + "@lexical/link": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/rich-text": "0.21.0", + "@lexical/text": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.21.0.tgz", + "integrity": "sha512-UR0wHg+XXbq++6aeUPdU0K41xhUDBYzX+AeiqU9bZ7yoOq4grvKD8KBr5tARCSYTy0yvQnL1ddSO12TrP/98Lg==", + "license": "MIT", + "dependencies": { + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.21.0.tgz", + "integrity": "sha512-93P+d1mbvaJvZF8KK2pG22GuS2pHLtyC7N3GBfkbyAIb7TL/rYs47iR+eADJ4iNY680lylJ4Sl/AEnWvlY7hAg==", + "license": "MIT", + "dependencies": { + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.21.0.tgz", + "integrity": "sha512-r4CsAknBD7qGYSE5fPdjpJ6EjfvzHbDtuCeKciL9muiswQhw4HeJrT1qb/QUIY+072uvXTgCgmjUmkbYnxKyPA==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.21.0.tgz", + "integrity": "sha512-tKwx8EoNkBBKOZf8c10QfyDImH87+XUI1QDL8KXt+Lb8E4ho7g1jAjoEirNEn9gMBj33K4l2qVdbe3XmPAdpMQ==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.21.0", + "@lexical/code": "0.21.0", + "@lexical/devtools-core": "0.21.0", + "@lexical/dragon": "0.21.0", + "@lexical/hashtag": "0.21.0", + "@lexical/history": "0.21.0", + "@lexical/link": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/mark": "0.21.0", + "@lexical/markdown": "0.21.0", + "@lexical/overflow": "0.21.0", + "@lexical/plain-text": "0.21.0", + "@lexical/rich-text": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/table": "0.21.0", + "@lexical/text": "0.21.0", + "@lexical/utils": "0.21.0", + "@lexical/yjs": "0.21.0", + "lexical": "0.21.0", + "react-error-boundary": "^3.1.4" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.21.0.tgz", + "integrity": "sha512-+pvEKUneEkGfWOSTl9jU58N9knePilMLxxOtppCAcgnaCdilOh3n5YyRppXhvmprUe0JaTseCMoik2LP51G/JA==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.21.0.tgz", + "integrity": "sha512-4u53bc8zlPPF0rnHjsGQExQ1St8NafsDd70/t1FMw7yvoMtUsKdH7+ap00esLkJOMv45unJD7UOzKRqU1X0sEA==", + "license": "MIT", + "dependencies": { + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.21.0.tgz", + "integrity": "sha512-JhylAWcf4qKD4FmxMUt3YzH5zg2+baBr4+/haLZL7178hMvUzJwGIiWk+3hD3phzmW3WrP49uFXzM7DMSCkE8w==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.21.0.tgz", + "integrity": "sha512-ceB4fhYejCoR8ID4uIs0sO/VyQoayRjrRWTIEMvOcQtwUkcyciKRhY0A7f2wVeq/MFStd+ajLLjy4WKYK5zUnA==", + "license": "MIT", + "dependencies": { + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.21.0.tgz", + "integrity": "sha512-YzsNOAiLkCy6R3DuP18gtseDrzgx+30lFyqRvp5M7mckeYgQElwdfG5biNFDLv7BM9GjSzgU5Cunjycsx6Sjqg==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/table": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.21.0.tgz", + "integrity": "sha512-AtPhC3pJ92CHz3dWoniSky7+MSK2WSd0xijc76I2qbTeXyeuFfYyhR6gWMg4knuY9Wz3vo9/+dXGdbQIPD8efw==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.21.0", + "@lexical/selection": "0.21.0", + "lexical": "0.21.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@mdx-js/react": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz", @@ -7352,25 +7620,6 @@ "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", "dev": true }, - "node_modules/@types/draft-js": { - "version": "0.11.18", - "resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.11.18.tgz", - "integrity": "sha512-lP6yJ+EKv5tcG1dflWgDKeezdwBa8wJ7KkiNrrHqXuXhl/VGes1SKjEfKHDZqOz19KQbrAhFvNhDPWwnQXYZGQ==", - "dev": true, - "dependencies": { - "@types/react": "*", - "immutable": "~3.7.4" - } - }, - "node_modules/@types/draft-js/node_modules/immutable": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", - "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@types/escodegen": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/escodegen/-/escodegen-0.0.6.tgz", @@ -8737,12 +8986,6 @@ "node": ">=0.10.0" } }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true - }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -11248,30 +11491,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/draft-js": { - "version": "0.11.7", - "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.11.7.tgz", - "integrity": "sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg==", - "dev": true, - "dependencies": { - "fbjs": "^2.0.0", - "immutable": "~3.7.4", - "object-assign": "^4.1.1" - }, - "peerDependencies": { - "react": ">=0.14.0", - "react-dom": ">=0.14.0" - } - }, - "node_modules/draft-js/node_modules/immutable": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", - "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -12990,28 +13209,6 @@ "bser": "2.1.1" } }, - "node_modules/fbjs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-2.0.0.tgz", - "integrity": "sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ==", - "dev": true, - "dependencies": { - "core-js": "^3.6.4", - "cross-fetch": "^3.0.4", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^0.7.18" - } - }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", - "dev": true - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -15328,6 +15525,17 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/issue-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", @@ -16728,6 +16936,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.21.0.tgz", + "integrity": "sha512-Dxc5SCG4kB+wF+Rh55ism3SuecOKeOtCtGHFGKd6pj2QKVojtjkxGTQPMt7//2z5rMSue4R+hmRM0pCEZflupA==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.99", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.99.tgz", + "integrity": "sha512-vwztYuUf1uf/1zQxfzRfO5yzfNKhTtgOByCruuiQQxWQXnPb8Itaube5ylofcV0oM0aKal9Mv+S1s1Ky0UYP1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -24283,6 +24519,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -24307,15 +24552,6 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, - "node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "dev": true, - "dependencies": { - "asap": "~2.0.3" - } - }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -24739,7 +24975,6 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", - "dev": true, "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -27352,12 +27587,6 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -28995,32 +29224,6 @@ "node": ">=4.2.0" } }, - "node_modules/ua-parser-js": { - "version": "0.7.39", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.39.tgz", - "integrity": "sha512-IZ6acm6RhQHNibSt7+c09hhvsKy9WUr4DVbeq9U8o71qxyYtJpQeDxQnMrVqnIFMLcQjHO0I9wgfO2vIahht4w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -31046,6 +31249,24 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.20", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.20.tgz", + "integrity": "sha512-Z2YZI+SYqK7XdWlloI3lhMiKnCdFCVC4PchpdO+mCYwtiTwncjUbnRK9R1JmkNfdmHyDXuWN3ibJAt0wsqTbLQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.98" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index f2413e84ca..a433f530e2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "homepage": "https://carbon.sage.com", "peerDependencies": { "@sage/design-tokens": "^4.17.0", - "draft-js": "^0.11.7", "react": "^17.0.2", "react-dom": "^17.0.2", "styled-components": "^4.4.1" @@ -70,6 +69,9 @@ "@commitlint/cli": "^17.6.3", "@commitlint/config-conventional": "^17.6.3", "@dotenvx/dotenvx": "^1.25.1", + "@lexical/headless": "^0.21.0", + "@lexical/link": "^0.21.0", + "@lexical/selection": "^0.21.0", "@playwright/experimental-ct-react17": "~1.47.0", "@playwright/test": "~1.47.0", "@sage/design-tokens": "~4.29.0", @@ -96,7 +98,6 @@ "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.1", "@types/crypto-js": "^4.2.1", - "@types/draft-js": "^0.11.10", "@types/glob": "^8.1.0", "@types/invariant": "^2.2.37", "@types/jest": "^29.5.0", @@ -126,7 +127,6 @@ "cz-conventional-changelog": "^3.3.0", "date-fns-tz": "^1.3.8", "dayjs": "^1.11.10", - "draft-js": "^0.11.7", "eslint": "^8.55.0", "eslint-config-airbnb": "^19.0.0", "eslint-config-prettier": "^9.1.0", @@ -179,6 +179,7 @@ "dependencies": { "@floating-ui/dom": "~1.2.9", "@floating-ui/react-dom": "~1.3.0", + "@lexical/react": "^0.21.0", "@octokit/rest": "^18.12.0", "@styled-system/prop-types": "^5.1.5", "@tanstack/react-virtual": "3.10.1", @@ -190,6 +191,7 @@ "date-fns": "^2.30.0", "immutable": "~3.8.2", "invariant": "^2.2.4", + "lexical": "^0.21.0", "lodash": "^4.17.21", "polished": "^4.2.2", "prop-types": "^15.8.1", diff --git a/playwright/components/note/locators.ts b/playwright/components/note/locators.ts index c7a754df76..16eb71d4f6 100644 --- a/playwright/components/note/locators.ts +++ b/playwright/components/note/locators.ts @@ -1,5 +1,5 @@ // component preview locators export const NOTE_COMPONENT = '[data-component="note"]'; export const NOTE_STATUS = '[data-component="note-status"]'; -export const DATA_CONTENTS = '[data-contents="true"]'; +export const DATA_CONTENTS = '[data-role="carbon-rte-readonly-content-editor"]'; export const NOTE_FOOTER = '[data-element="note-footer"]'; diff --git a/src/__internal__/label/label.component.tsx b/src/__internal__/label/label.component.tsx index 1ef1e0e944..7bad24d188 100644 --- a/src/__internal__/label/label.component.tsx +++ b/src/__internal__/label/label.component.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext } from "react"; +import React, { useState, useContext, useRef } from "react"; import { TooltipPositions } from "../../components/tooltip/tooltip.config"; import Help from "../../components/help"; import StyledLabel, { @@ -11,6 +11,7 @@ import StyledIconWrapper from "./icon-wrapper.style"; import { InputContext, InputGroupContext } from "../input-behaviour"; import { ValidationProps } from "../validations"; import { IconType } from "../../components/icon"; +import createGuid from "../../__internal__/utils/helpers/guid"; export interface LabelProps extends ValidationProps, @@ -90,6 +91,7 @@ export const Label = ({ const { onMouseEnter, onMouseLeave } = useContext(InputContext); const { onMouseEnter: onGroupMouseEnter, onMouseLeave: onGroupMouseLeave } = useContext(InputGroupContext); + const guid = useRef(createGuid()); const handleMouseEnter = () => { if (onMouseEnter) onMouseEnter(); @@ -159,6 +161,11 @@ export const Label = ({ return ( { + const [editorState, setEditorState] = useState(() => { + // 'empty' editor + const value = + '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; + return value; + }); + const handleEditorChange = (newState: string) => { setEditorState(newState); }; const selectOptions = [ @@ -163,11 +167,10 @@ export const Default = ({ setDate(e.target.value.formattedValue) } /> -
This is an example of a dialog with a Form as content
diff --git a/src/components/form/form-test.stories.tsx b/src/components/form/form-test.stories.tsx index 1a12dcc4a4..c8bdbca1f5 100644 --- a/src/components/form/form-test.stories.tsx +++ b/src/components/form/form-test.stories.tsx @@ -1,7 +1,6 @@ /* eslint-disable no-console */ import React, { useState } from "react"; -import TextEditor, { EditorState } from "../text-editor"; import Form from "."; import Button from "../button"; import { Tab, Tabs } from "../tabs"; @@ -25,6 +24,7 @@ import InlineInputs from "../inline-inputs"; import Pager from "../pager"; import Password from "../password"; import Search, { SearchProps } from "../search"; +import RichTextEditor from "../rich-text-editor"; export default { title: "Form/Test", @@ -381,15 +381,20 @@ DefaultWithPager.storyName = "default with pager"; export const MockFormForAriaLiveDemo = () => { const [textareaValue, setTextareaValue] = useState(""); const [textboxValue, setTextboxValue] = useState(""); - const [textEditorValue, setTextEditorValue] = useState( - EditorState.createEmpty(), - ); + const [textEditorValue, setTextEditorValue] = useState(() => { + // 'empty' editor + const value = + '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; + return value; + }); const [passwordValue, setPasswordValue] = useState(""); const resetValues = () => { setTextareaValue(""); setTextboxValue(""); - setTextEditorValue(EditorState.createEmpty()); + setTextEditorValue( + '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}', + ); setPasswordValue(""); }; @@ -435,8 +440,7 @@ export const MockFormForAriaLiveDemo = () => { setTextboxValue(newValue.target.value); }} /> - { setTextEditorValue(newValue); }} diff --git a/src/components/note/components.test-pw.tsx b/src/components/note/components.test-pw.tsx index f2f658cd30..a693b60e0d 100644 --- a/src/components/note/components.test-pw.tsx +++ b/src/components/note/components.test-pw.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { EditorState, ContentState, convertFromHTML } from "draft-js"; import Note, { NoteProps } from "./note.component"; import { ActionPopover, ActionPopoverItem } from "../action-popover"; import LinkPreview from "../link-preview"; @@ -12,15 +11,11 @@ const NoteComponent = ({ text?: string; createdDate?: string; }) => { - const initialValue = text - ? EditorState.createWithContent(ContentState.createFromText(text)) - : EditorState.createEmpty(); - return ( @@ -31,12 +26,7 @@ const NoteComponentWithInlineControl = () => { const html = `

Lorem ipsum dolor sit amet. Aenean commodo ligula eget dolor. Aenean massa.

Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

Aenean commodo ligula eget dolor. Aenean massa.

`; - const blocksFromHTML = convertFromHTML(html); - const content = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap, - ); - const noteContentVal = EditorState.createWithContent(content); + const inlineControl = ( {}}>Edit @@ -46,7 +36,7 @@ const NoteComponentWithInlineControl = () => { @@ -54,9 +44,7 @@ const NoteComponentWithInlineControl = () => { }; const NoteComponentWithPreviews = () => { - const noteContent = EditorState.createWithContent( - ContentState.createFromText("Here is some plain text content"), - ); + const noteContent = "Here is some plain text content"; const previews = [ ) => { - const noteContent = EditorState.createWithContent( - ContentState.createFromText("Here is some plain text content"), - ); + const noteContent = "Here is some plain text content"; return (
  • ordered

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    Aenean commodo ligula eget dolor. Aenean massa.

    `; - const blocksFromHTML = convertFromHTML(html); - const content = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap, - ); - const noteContent = EditorState.createWithContent(content); + const noteContent = html; const inlineControl = ( + - + ); }; @@ -103,9 +95,7 @@ InlineControlMenuButton.parameters = { }; export const TitleNodes = () => { - const noteContent = EditorState.createWithContent( - ContentState.createFromText("Here is some plain text content"), - ); + const noteContent = "Here is some plain text content"; const titleElements = ( diff --git a/src/components/note/note.component.tsx b/src/components/note/note.component.tsx index 20dcde5510..209dae5fe0 100644 --- a/src/components/note/note.component.tsx +++ b/src/components/note/note.component.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Editor, EditorState } from "draft-js"; import { MarginProps } from "styled-system"; import invariant from "invariant"; import { @@ -13,8 +12,8 @@ import { } from "./note.style"; import StatusIcon from "./__internal__/status-icon"; import { ActionPopover } from "../action-popover"; -import { getDecoratedValue } from "../text-editor/__internal__/utils"; -import EditorContext from "../text-editor/__internal__/editor.context"; +import ReadOnlyEditor from "../rich-text-editor/__internal__"; +import RichTextEditorContext from "../rich-text-editor/rich-text-editor.context"; import LinkPreview, { LinkPreviewProps } from "../link-preview"; import Typography from "../typography"; @@ -26,7 +25,7 @@ export interface NoteProps extends MarginProps { /** Adds a name to the Note footer */ name?: string; /** The rich text content to display in the Note */ - noteContent: EditorState; + noteContent: string; /** Callback to report a url when a link is added */ onLinkAdded?: (url: string) => void; /** The previews to display of any links added to the Editor */ @@ -84,7 +83,7 @@ export const Note = ({ }; return ( - + @@ -103,11 +102,7 @@ export const Note = ({ ) : ( {title} ))} - {}} - /> + {inlineControl && ( {inlineControl} @@ -142,7 +137,7 @@ export const Note = ({ )} - + ); }; diff --git a/src/components/note/note.mdx b/src/components/note/note.mdx index 63b71ffe7b..4cefe489cf 100644 --- a/src/components/note/note.mdx +++ b/src/components/note/note.mdx @@ -6,8 +6,7 @@ import * as NoteStories from "./note.stories"; # Note -The `Note` was created using the `draft-js` framework which allows rich text content to be rendered. It requires -consuming projects to install `draft-js` as a peer-dependency to enable it to work. +The `Note` was created using the `lexical` framework which allows rich text content to be rendered. For further documentation on this component, please read [our documentation regarding Lexical](../?path=/docs/documentation-using-lexical--docs) ## Contents @@ -19,27 +18,22 @@ consuming projects to install `draft-js` as a peer-dependency to enable it to wo ```javascript import Note from "carbon-react/lib/components/note"; -import { EditorState, ContentState, convertFromHTML } from "draft-js"; ``` -To use `Note`, use the import path above and pass the content via the `noteContent` prop by utilising the static -methods provided by the `draft-js` (see the import above) framework https://draftjs.org/docs/api-reference-content-state#static-methods. +To use `Note`, use the import path above and pass the content via the `noteContent` prop. ## Examples ### Default -In its default form, the component can render plain text content by passing a value via the `noteContent` prop using -`EditorState.createWithContent(ContentState.createFromText(''))` to ensure the value is in the correct format. +In its default form, the component can render plain text content by passing the value via the `noteContent` prop. ### With rich text content -It is also possible to render rich text content: below is an example of how the component supports rendering `html` -content but there is a range of supporting packages that will support converting -content to a format you prefer and back into one that `draft-js` supports, again utilising the `createWithContent` -static method. +It is possible to render rich text content: below is an example of how the component supports rendering `html` content. +Pass the value via the `noteContent` prop. @@ -66,8 +60,7 @@ An optional status can be provided using the `status` prop. It is possible to supply link previews to the `Note` component by passing them in via the `previews` prop. Previews are rendered as anchor elements and will behave as links, opening the page in a new tab when they are clicked or when focused -and the enter key is pressed. Similarly to the `TextEditor` component, a `onLinkAdded` prop is surfaced to allow for a link -preview's url to be reported and calls to an api can be made here as well if needed. +and the enter key is pressed. @@ -80,6 +73,12 @@ the props table at the bottom of this page. +### Plain-text Links + +If you provide a plain-text sstring that contains a URL, the `Note` component will automatically convert it into a link. + + + ## Props ### Note diff --git a/src/components/note/note.pw.tsx b/src/components/note/note.pw.tsx index 710021c019..cf36f244b5 100644 --- a/src/components/note/note.pw.tsx +++ b/src/components/note/note.pw.tsx @@ -136,27 +136,6 @@ test.describe("check styling for Note component", () => { }); }); -test.describe("check action events for Note component", () => { - test("should call onLinkAdded callback when a valid url is detected", async ({ - mount, - page, - }) => { - let hasOnLinkAddedBeenCalledCount = 0; - - await mount( - { - hasOnLinkAddedBeenCalledCount += 1; - }} - />, - ); - - await expect(page.getByText("https://carbon.s")).toBeAttached(); - expect(hasOnLinkAddedBeenCalledCount).toBe(1); - }); -}); - test.describe("Accessibility tests for Note component", () => { testData.forEach((text) => { test(`should render with noteContent prop as ${text} for accessibility tests`, async ({ diff --git a/src/components/note/note.stories.tsx b/src/components/note/note.stories.tsx index ad53a59dfe..ae82366b90 100644 --- a/src/components/note/note.stories.tsx +++ b/src/components/note/note.stories.tsx @@ -1,13 +1,6 @@ import React from "react"; import { Meta, StoryObj } from "@storybook/react"; -import { - EditorState, - ContentState, - convertFromHTML, - convertFromRaw, -} from "draft-js"; - import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; import { @@ -36,17 +29,15 @@ export default meta; type Story = StoryObj; export const Default: Story = () => { - const noteContent = EditorState.createWithContent( - ContentState.createFromText("Here is some plain text content"), - ); + const noteContent = "Here is some plain text content"; return ( -
    + -
    +
    ); }; Default.storyName = "Default"; @@ -57,16 +48,11 @@ export const WithRichText: Story = () => {
    1. ordered

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    Aenean commodo ligula eget dolor. Aenean massa.

    `; - const blocksFromHTML = convertFromHTML(html); - const content = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap, - ); - const noteContent = EditorState.createWithContent(content); + return ( @@ -81,19 +67,13 @@ export const WithTitle: Story = () => {
    1. ordered

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    Aenean commodo ligula eget dolor. Aenean massa.

    `; - const blocksFromHTML = convertFromHTML(html); - const content = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap, - ); - const noteContent = EditorState.createWithContent(content); const titleNode = Here is a Title Node; return ( @@ -108,12 +88,7 @@ export const WithInlineControls: Story = () => {
    1. ordered

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    Aenean commodo ligula eget dolor. Aenean massa.

    `; - const blocksFromHTML = convertFromHTML(html); - const content = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap, - ); - const noteContent = EditorState.createWithContent(content); + const inlineControl = ( {}}>Edit @@ -126,7 +101,7 @@ export const WithInlineControls: Story = () => { @@ -141,12 +116,7 @@ export const WithStatus: Story = () => {
    1. ordered

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

    Aenean commodo ligula eget dolor. Aenean massa.

    `; - const blocksFromHTML = convertFromHTML(html); - const content = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap, - ); - const noteContent = EditorState.createWithContent(content); + const inlineControl = ( {}}>Edit @@ -162,7 +132,7 @@ export const WithStatus: Story = () => { name="Lauren Smith" createdDate="23 May 2020, 12:08 PM" status={{ text: "Edited", timeStamp: "23 May 2020, 12:08 PM" }} - noteContent={noteContent} + noteContent={html} />
    ); @@ -171,30 +141,73 @@ WithStatus.storyName = "With Status"; export const WithPreviews: Story = () => { const json = JSON.stringify({ - blocks: [ - { - key: "47lv5", - text: "www.bbc.co.uk", - type: "unstyled", - depth: 0, - inlineStyleRanges: [], - entityRanges: [], - data: {}, - }, - { - key: "ab5do", - text: "www.sage.com", - type: "unstyled", - depth: 0, - inlineStyleRanges: [], - entityRanges: [], - data: {}, - }, - ], - entityMap: {}, + root: { + children: [ + { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "www.bbc.co.uk", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "autolink", + version: 1, + rel: null, + target: null, + title: null, + url: "https://www.bbc.co.uk", + isUnlinked: false, + }, + { type: "linebreak", version: 1 }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "www.sage.com", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "autolink", + version: 1, + rel: null, + target: null, + title: null, + url: "https://www.sage.com", + isUnlinked: false, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "code", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, }); - const content = convertFromRaw(JSON.parse(json)); - const noteContent = EditorState.createWithContent(content); + const noteContent = JSON.stringify(json); const inlineControl = ( {}}>Edit @@ -233,9 +246,7 @@ export const WithPreviews: Story = () => { WithPreviews.storyName = "With Previews"; export const WithMargin: Story = () => { - const noteContent = EditorState.createWithContent( - ContentState.createFromText("Here is some plain text content"), - ); + const noteContent = "Here is some plain text content"; return ( { ); }; WithMargin.storyName = "With Margin"; + +export const PlainTextWithLinks: Story = () => { + const noteContent = + "Hello, World! www.bbc.co.uk http://www.google.com https://www.sage.com"; + return ( + + + + ); +}; +PlainTextWithLinks.storyName = "Plain text with links"; diff --git a/src/components/note/note.test.tsx b/src/components/note/note.test.tsx index 9c505f554e..7c7f7c816f 100644 --- a/src/components/note/note.test.tsx +++ b/src/components/note/note.test.tsx @@ -1,7 +1,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { EditorState } from "draft-js"; import Note from "."; import LinkPreview from "../link-preview"; import { ActionPopover, ActionPopoverItem } from "../action-popover"; @@ -9,23 +8,14 @@ import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test- import Typography from "../typography"; test("should render with required props", () => { - render( - , - ); + render(); expect(screen.getByText("23 May 2020, 12:08 PM")).toBeVisible(); }); test("renders a Typography component with h2 `variant` and `title` as its child when `title` prop is a string", () => { render( - , + , ); const titleElement = screen.getByRole("heading", { level: 2 }); @@ -38,7 +28,7 @@ test("renders the `title` node when `title` prop is a React node", () => { render( Title @@ -55,11 +45,7 @@ test("renders the `title` node when `title` prop is a React node", () => { test("should render with provided `name` prop", () => { render( - , + , ); expect(screen.getByText("Carbon")).toBeVisible(); @@ -70,7 +56,7 @@ test("should render tooltip containing status `timeStamp` when status `text` is render( , ); @@ -118,7 +104,7 @@ test("should render LinkPreviews when passed via the `previews` prop as a node", render( , ); @@ -140,7 +126,7 @@ test("should render with `ActionPopover` when passed via the `inlineControl` pro render( , ); @@ -157,7 +143,7 @@ test("should throw when `inlineControls` is not an instance of `ActionPopover`", render( A Button} />, ), @@ -169,11 +155,7 @@ test("should throw when width is 0", () => { const spy = jest.spyOn(console, "error").mockImplementation(() => {}); expect(() => render( - , + , ), ).toThrow(" width must be greater than 0"); spy.mockRestore(); @@ -185,7 +167,7 @@ testStyledSystemMargin( {...props} data-role="note" createdDate="23 May 2020, 12:08 PM" - noteContent={EditorState.createEmpty()} + noteContent="" /> ), () => screen.getByTestId("note"), diff --git a/src/components/rich-text-editor/__internal__/index.ts b/src/components/rich-text-editor/__internal__/index.ts new file mode 100644 index 0000000000..05c32803a4 --- /dev/null +++ b/src/components/rich-text-editor/__internal__/index.ts @@ -0,0 +1 @@ +export { default } from "./read-only-rte.component"; diff --git a/src/components/rich-text-editor/__internal__/read-only-rte.component.tsx b/src/components/rich-text-editor/__internal__/read-only-rte.component.tsx new file mode 100644 index 0000000000..fb59f352e0 --- /dev/null +++ b/src/components/rich-text-editor/__internal__/read-only-rte.component.tsx @@ -0,0 +1,79 @@ +/* eslint-disable no-console */ +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; + +import React, { useMemo } from "react"; + +import { + CreateFromHTML, + RichTextEditorProps, +} from "../rich-text-editor.component"; +import { markdownNodes, theme } from "../constants"; + +const wrapLinksInAnchors = (value: string) => { + const urlRegex = /((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)/g; + return value.replace(urlRegex, "$1"); +}; + +const determineFormat = (value: string | undefined) => { + let isJson; + + if (!value) { + return CreateFromHTML("


    "); + } + + try { + const isValidJSON = JSON.parse(value); + /* istanbul ignore else */ + if (isValidJSON) isJson = true; + } catch (e) { + isJson = false; + } + + if (!isJson) { + const isHTML = /<[a-z][\s\S]*>/i.test(value); + if (isHTML) { + return CreateFromHTML(value); + } + const wrappedPlainText = `

    ${wrapLinksInAnchors(value)}

    `; + return CreateFromHTML(wrappedPlainText); + } + return JSON.parse(value); +}; + +const ReadOnlyEditor = ({ + namespace = "carbon-rte-readonly", + + value, +}: Partial) => { + const initialConfig = useMemo(() => { + return { + namespace, + nodes: markdownNodes, + onError: console.error, + theme, + editorState: determineFormat(value), + editable: false, + }; + }, [namespace, value]); + + return ( + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + ); +}; + +export default ReadOnlyEditor; diff --git a/src/components/rich-text-editor/__internal__/read-only-rte.test.tsx b/src/components/rich-text-editor/__internal__/read-only-rte.test.tsx new file mode 100644 index 0000000000..5481a4ee1c --- /dev/null +++ b/src/components/rich-text-editor/__internal__/read-only-rte.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import ReadOnlyEditor from "./read-only-rte.component"; +import { componentPrefix } from "../constants"; + +test("should render read-only editor with plain text", () => { + render(); + expect(screen.getByText("Hello, World!")).toBeInTheDocument(); +}); + +test("should wrap plain-text links with anchors in the editor", () => { + render( + , + ); + expect(screen.getByText("Hello, World!")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "www.bbc.co.uk" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "http://www.google.com" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "https://www.sage.com" }), + ).toBeInTheDocument(); +}); + +test("should render read-only editor with HTML", () => { + const html = `

    + www.bbc.co.uk +

    This is a paragraph

    +

    `; + render(); + expect( + screen.getByRole("link", { name: "www.bbc.co.uk" }), + ).toBeInTheDocument(); + expect(screen.getByText("This is a paragraph")).toBeInTheDocument(); +}); + +test("should render read-only editor with JSON", () => { + const json = JSON.stringify({ + root: { + children: [ + { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "www.bbc.co.uk", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "autolink", + version: 1, + rel: null, + target: null, + title: null, + url: "https://www.bbc.co.uk", + isUnlinked: false, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "code", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + }); + const jsonContent = JSON.stringify(json); + render(); + expect( + screen.getByRole("link", { name: "www.bbc.co.uk" }), + ).toBeInTheDocument(); +}); + +test("should render read-only editor with default value if no content provided", () => { + render(); + const editor = screen.getByTestId( + `${componentPrefix}-readonly-content-editor`, + ); + expect(editor).toBeInTheDocument(); + expect(editor).toHaveTextContent(""); +}); diff --git a/src/components/rich-text-editor/__snapshots__/rich-text-editor.test.tsx.snap b/src/components/rich-text-editor/__snapshots__/rich-text-editor.test.tsx.snap new file mode 100644 index 0000000000..8b48076f50 --- /dev/null +++ b/src/components/rich-text-editor/__snapshots__/rich-text-editor.test.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`serialisation of editor 1`] = ` +{ + "htmlString": "

    Sample text

    ", + "json": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Sample text", + "type": "text", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "root", + "version": 1, + }, + }, +} +`; + +exports[`valid data is parsed when HTML is passed into the CreateFromHTML function 1`] = `"{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"This is a HTML example.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Look, it has lists!","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":1}],"direction":null,"format":"","indent":0,"type":"list","version":1,"listType":"number","start":1,"tag":"ol"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}"`; diff --git a/src/components/rich-text-editor/components.test-pw.tsx b/src/components/rich-text-editor/components.test-pw.tsx new file mode 100644 index 0000000000..12d18ad560 --- /dev/null +++ b/src/components/rich-text-editor/components.test-pw.tsx @@ -0,0 +1,134 @@ +import React from "react"; +import RichTextEditor from "./rich-text-editor.component"; + +export const RichTextEditorDefaultComponent = ({ ...props }) => { + return ( + + ); +}; + +export const RichTextEditorWithValue = ({ ...props }) => { + const initialValue = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Sample text with ", + type: "text", + version: 1, + }, + { + detail: 0, + format: 1, + mode: "normal", + style: "", + text: "some formatting", + type: "text", + version: 1, + }, + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: " ", + type: "text", + version: 1, + }, + { + detail: 0, + format: 2, + mode: "normal", + style: "", + text: "applied", + type: "text", + version: 1, + }, + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: ".", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + textFormat: 0, + textStyle: "", + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + }; + const value = JSON.stringify(initialValue); + return ( + + ); +}; + +export const RichTextEditorWithUnformattedValue = ({ ...props }) => { + const initialValue = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "This text needs formatting", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + textFormat: 0, + textStyle: "", + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + }; + const value = JSON.stringify(initialValue); + return ( + + ); +}; diff --git a/src/components/rich-text-editor/constants.ts b/src/components/rich-text-editor/constants.ts new file mode 100644 index 0000000000..d7f5156ff7 --- /dev/null +++ b/src/components/rich-text-editor/constants.ts @@ -0,0 +1,35 @@ +import { CodeNode } from "@lexical/code"; +import { LinkNode, AutoLinkNode } from "@lexical/link"; +import { ListNode, ListItemNode, ListType } from "@lexical/list"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; + +import { EditorThemeClasses, TextFormatType } from "lexical"; + +/** The default prefix applied to the editor's internal class names, IDs, etc. */ +const componentPrefix = "carbon-rte"; + +/** The theme overrides needed to correctly style the editor */ +const theme: EditorThemeClasses = {}; + +/** The available actions that can be used in the editor */ +const RichTextEditorActionTypes = { + Bold: "bold" as TextFormatType, + Italic: "italic" as TextFormatType, + OrderedList: "number" as ListType, + UnorderedList: "bullet" as ListType, +}; + +/** The nodes supported by markdown */ +const markdownNodes = [ + AutoLinkNode, + CodeNode, + LinkNode, + ListNode, + ListItemNode, + HeadingNode, + QuoteNode, + HorizontalRuleNode, +]; + +export { componentPrefix, markdownNodes, RichTextEditorActionTypes, theme }; diff --git a/src/components/rich-text-editor/helpers.test.ts b/src/components/rich-text-editor/helpers.test.ts new file mode 100644 index 0000000000..6c933b6384 --- /dev/null +++ b/src/components/rich-text-editor/helpers.test.ts @@ -0,0 +1,25 @@ +import { DeserializeHTML, validateUrl } from "./helpers"; + +describe("Helper functions", () => { + describe("DeserializeHTML", () => { + test("deserializes HTML into JSON", () => { + const html = `

    This is a HTML example.

    1. Look, it has lists!
    `; + const json = DeserializeHTML(html); + expect(json).toEqual( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"This is a HTML example.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Look, it has lists!","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":1}],"direction":null,"format":"","indent":0,"type":"list","version":1,"listType":"number","start":1,"tag":"ol"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`, + ); + }); + }); + + describe("validateUrl", () => { + test("returns true when the URL is valid", () => { + const validUrl = "https://www.example.com"; + expect(validateUrl(validUrl)).toBe(true); + }); + + test("returns false when the URL is invalid", () => { + const invalidUrl = "example.url"; + expect(validateUrl(invalidUrl)).toBe(false); + }); + }); +}); diff --git a/src/components/rich-text-editor/helpers.ts b/src/components/rich-text-editor/helpers.ts new file mode 100644 index 0000000000..af8c03b869 --- /dev/null +++ b/src/components/rich-text-editor/helpers.ts @@ -0,0 +1,92 @@ +import { createHeadlessEditor } from "@lexical/headless"; + +import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"; + +import { $getRoot, $getSelection } from "lexical"; + +import { markdownNodes, theme } from "./constants"; + +/** + * This helper takes the current state of the editor and serializes it into two formats: + * 1. HTML + * 2. JSON + * This allows the editor state to be saved and restored at a later time, in a format suitable + * for the majority of customers' use cases. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SerializeLexical = (editor: any) => { + let htmlString; + let json; + + editor.update(() => { + // Get the current editor state + const editorState = editor.getEditorState(); + // Serialize the editor state to JSON + json = editorState.toJSON(); + // Generate HTML from the editor state + htmlString = $generateHtmlFromNodes(editor, null); + }); + + return { htmlString, json }; +}; + +/** + * This helper takes an HTML string and deserializes it into the editor. + * This allows the editor to be restored from a previously saved state. + */ +const DeserializeHTML = (html: string) => { + // Create a new headless editor instance. This allows us to process the editor state + // without needing to render the editor itself. + const editor = createHeadlessEditor({ + namespace: "html-to-json", + // eslint-disable-next-line no-console + onError: console.error, + theme, + nodes: markdownNodes, + }); + let parsingError; + + editor.update( + () => { + // Parse the HTML string into a DOM + const parser = new DOMParser(); + const dom = parser.parseFromString(html, "text/html"); + // Generate nodes from the DOM + const nodes = $generateNodesFromDOM(editor, dom); + // Select the root of the editor + $getRoot().select(); + // Insert the nodes into the editor + const selection = $getSelection(); + /* istanbul ignore else */ + if (selection) { + try { + selection.insertNodes(nodes); + } catch (err) { + /* istanbul ignore next */ + parsingError = err; + } + } + }, + { discrete: true }, + ); + + /* istanbul ignore next */ + if (parsingError) { + throw parsingError; + } + + // Return the editor instance + const json = editor.getEditorState().toJSON(); + return JSON.stringify(json); +}; + +const urlRegExp = new RegExp( + /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/, +); + +/** Function to validate a given URL */ +function validateUrl(url: string): boolean { + return url === "https://" || urlRegExp.test(url); +} + +export { DeserializeHTML, SerializeLexical, validateUrl }; diff --git a/src/components/rich-text-editor/index.ts b/src/components/rich-text-editor/index.ts new file mode 100644 index 0000000000..2e2a5a8d6f --- /dev/null +++ b/src/components/rich-text-editor/index.ts @@ -0,0 +1,3 @@ +export { default } from "./rich-text-editor.component"; +export { CreateFromHTML } from "./rich-text-editor.component"; +export type { RichTextEditorProps } from "./rich-text-editor.component"; diff --git a/src/components/rich-text-editor/plugins/AutoLinker/auto-link.component.tsx b/src/components/rich-text-editor/plugins/AutoLinker/auto-link.component.tsx new file mode 100644 index 0000000000..1bf7a775a8 --- /dev/null +++ b/src/components/rich-text-editor/plugins/AutoLinker/auto-link.component.tsx @@ -0,0 +1,39 @@ +/* istanbul ignore file */ +/** + * Owing to the nature of how this plugin runs, it is not possible to test it in isolation. + * It is tested as part of the RichTextEditor Playwright tests. + * + * The AutoLinkerPlugin component is a wrapper around the AutoLinkPlugin component provided + * by Lexical. It is used to automatically convert URLs and email addresses into clickable + * links. + * + * The regular expressions used to match URLs and email addresses are provided as per the + * Lexical documentation; as such not all edge cases may be covered. + */ +import { + AutoLinkPlugin, + createLinkMatcherWithRegExp, +} from "@lexical/react/LexicalAutoLinkPlugin"; + +import * as React from "react"; + +const URL_REGEX = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; + +const MATCHERS = [ + createLinkMatcherWithRegExp(URL_REGEX, (text) => { + return text.startsWith("http") ? text : `https://${text}`; + }), + createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => { + return `mailto:${text}`; + }), +]; + +const AutoLinkerPlugin = () => { + return ; +}; + +export default AutoLinkerPlugin; diff --git a/src/components/rich-text-editor/plugins/AutoLinker/index.ts b/src/components/rich-text-editor/plugins/AutoLinker/index.ts new file mode 100644 index 0000000000..07e0cda309 --- /dev/null +++ b/src/components/rich-text-editor/plugins/AutoLinker/index.ts @@ -0,0 +1 @@ +export { default } from "./auto-link.component"; diff --git a/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.component.tsx b/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.component.tsx new file mode 100644 index 0000000000..b9fdf07feb --- /dev/null +++ b/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.component.tsx @@ -0,0 +1,47 @@ +import { EditorState, $getRoot } from "lexical"; + +import React, { useMemo } from "react"; + +import StyledCharacterCounter from "./character-counter.style"; + +import useLocale from "../../../../hooks/__internal__/useLocale"; + +export interface CharacterCounterPluginProps { + /** The editor's current state. Needed to get the raw character count */ + editorState: EditorState | undefined; + /** The maximum number of characters to allow before showing the warning */ + maxChars: number; + /** The namespace of the editor that this counter belongs to */ + namespace: string; +} + +const CharacterCounterPlugin = ({ + editorState, + maxChars, + namespace, +}: CharacterCounterPluginProps) => { + // Get the locale to enable translations + const locale = useLocale(); + + // Calculate the number of characters remaining + const rawCharactersRemaining = useMemo(() => { + // If there is no editor state, return the max number of characters + if (!editorState) return maxChars; + // Get the text content of the editor + const editorStateTextString = editorState.read(() => + $getRoot().getTextContent(), + ); + // Calculate the number of characters remaining + const activeCount = maxChars - editorStateTextString.length; + // Return the active count if it is greater than 0, otherwise return 0 + return activeCount > 0 ? activeCount : 0; + }, [editorState, maxChars]); + + return ( + + {locale.richTextEditor.characterCounter(rawCharactersRemaining)} + + ); +}; + +export default CharacterCounterPlugin; diff --git a/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.style.ts b/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.style.ts new file mode 100644 index 0000000000..7b4a3cb7a3 --- /dev/null +++ b/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.style.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +const StyledCharacterCounter = styled.div` + text-align: left; + font-size: var(--fontSizes100); + margin-top: var(--spacing050); + margin-bottom: var(--spacing050); + color: var(--colorsUtilityYin055); +`; + +export default StyledCharacterCounter; diff --git a/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.test.tsx b/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.test.tsx new file mode 100644 index 0000000000..b389c48c1d --- /dev/null +++ b/src/components/rich-text-editor/plugins/CharacterCounter/character-counter.test.tsx @@ -0,0 +1,73 @@ +/** + * The CharacterCounterPlugin component is a plugin for the RichTextEditor. It can be unit tested in isolation + * as it has no direct dependencies on the RichTextEditor component itself and the state can easily be mocked using the + * headless editor. + */ +import { createHeadlessEditor } from "@lexical/headless"; +import { render, screen } from "@testing-library/react"; +import { $createParagraphNode, $createTextNode, $getRoot } from "lexical"; +import React from "react"; + +import CharacterCounterPlugin from "./character-counter.component"; + +function interactWith(sampleText = "Hello world") { + const editor = createHeadlessEditor({ + nodes: [], + onError: () => {}, + namespace: "test", + }); + + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode(sampleText)), + ); + }, + { discrete: true }, + ); + + const editorState = editor.getEditorState(); + return editorState; +} + +describe("CharacterCounterPlugin", () => { + test("should render with the correct default text", () => { + render( + , + ); + const content = screen.getByTestId("test-character-limit"); + expect(content).toBeInTheDocument(); + }); + + test("should update the text correctly when the user types into the editor", () => { + const editorState = interactWith(); + render( + , + ); + const content = screen.getByTestId("test-character-limit"); + expect(content).toBeInTheDocument(); + expect(content).toHaveTextContent("89 characters remaining"); + }); + + test("should handle excessive characters correctly", () => { + const editorState = interactWith("abcdefghijklmnopqrstuvwxyz0123456789"); + render( + , + ); + const content = screen.getByTestId("test-character-limit"); + expect(content).toBeInTheDocument(); + expect(content).toHaveTextContent("0 characters remaining"); + }); +}); diff --git a/src/components/rich-text-editor/plugins/CharacterCounter/index.ts b/src/components/rich-text-editor/plugins/CharacterCounter/index.ts new file mode 100644 index 0000000000..37dda8f9e8 --- /dev/null +++ b/src/components/rich-text-editor/plugins/CharacterCounter/index.ts @@ -0,0 +1 @@ +export { default } from "./character-counter.component"; diff --git a/src/components/rich-text-editor/plugins/ContentEditor/content-editor.component.tsx b/src/components/rich-text-editor/plugins/ContentEditor/content-editor.component.tsx new file mode 100644 index 0000000000..79938a17f3 --- /dev/null +++ b/src/components/rich-text-editor/plugins/ContentEditor/content-editor.component.tsx @@ -0,0 +1,59 @@ +/** + * This is where the actual content editor is rendered. It uses the `ContentEditable` component from the `@lexical/react` package + * as per their documentation. It also uses the `LinkPreviewerPlugin` to render link previews. + */ +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; + +import React from "react"; + +import StyledContentEditable from "./content-editor.style"; +import { LinkPreviewerPlugin } from ".."; + +import useLocale from "../../../../hooks/__internal__/useLocale"; + +export interface ContentEditorProps { + /** The active error message of the editor */ + error?: string; + /** The namespace of the editor that this content editor belongs to */ + namespace: string; + /** The link previews to render at the foot of the editor */ + previews?: React.JSX.Element[]; + /** The number of rows to render in the editor */ + rows?: number; + /** The active warning message of the editor */ + warning?: string; +} + +const ContentEditor = ({ + error, + namespace, + previews = [], + rows, + warning, +}: ContentEditorProps) => { + // Get the locale to enable translations + const locale = useLocale(); + + return ( + + + + + ); +}; + +export default ContentEditor; diff --git a/src/components/rich-text-editor/plugins/ContentEditor/content-editor.style.ts b/src/components/rich-text-editor/plugins/ContentEditor/content-editor.style.ts new file mode 100644 index 0000000000..1b4ebe56bd --- /dev/null +++ b/src/components/rich-text-editor/plugins/ContentEditor/content-editor.style.ts @@ -0,0 +1,41 @@ +import styled, { css } from "styled-components"; + +import { ContentEditorProps } from "./content-editor.component"; + +const DEFAULT_EDITOR_HEIGHT = 210; +const FIXED_LINE_HEIGHT = 21; + +interface StyledContentEditableProps extends ContentEditorProps { + showBorders?: boolean; +} + +const StyledContentEditable = styled.div` + ${({ error, namespace, rows, warning }) => css` + .${namespace}-editable { + min-height: ${rows && rows > 2 + ? rows * FIXED_LINE_HEIGHT + : DEFAULT_EDITOR_HEIGHT}px; + background-color: var(--colorsUtilityYang100); + border-top: 1px solid var(--colorsUtilityMajor200); + border-left: 1px solid var(--colorsUtilityMajor200); + border-right: 1px solid var(--colorsUtilityMajor200); + margin: 0; + padding: 2px 8px; + border-top-left-radius: var(--borderWidth600); + border-top-right-radius: var(--borderWidth600); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + ${(error || warning) && + css` + border: none; + `} + + :focus { + outline: none; + } + } + `} +`; + +export default StyledContentEditable; diff --git a/src/components/rich-text-editor/plugins/ContentEditor/content-editor.test.tsx b/src/components/rich-text-editor/plugins/ContentEditor/content-editor.test.tsx new file mode 100644 index 0000000000..d17c24ce92 --- /dev/null +++ b/src/components/rich-text-editor/plugins/ContentEditor/content-editor.test.tsx @@ -0,0 +1,44 @@ +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import ContentEditor from "./content-editor.component"; + +test("previews are rendered correctly if provided", () => { + const previews = [
    Preview 1
    ]; + render( + {}, + namespace: "test", + }} + > + + , + ); + + const preview = screen.getByText("Preview 1"); + + // expect the preview to be rendered + expect(preview).toBeInTheDocument(); +}); + +test("no previews are rendered if the prop is not provided", () => { + render( + {}, + namespace: "test", + }} + > + + , + ); + + const preview = screen.queryByText("Preview 1"); + + // expect the preview not to be rendered + expect(preview).not.toBeInTheDocument(); +}); diff --git a/src/components/rich-text-editor/plugins/ContentEditor/index.ts b/src/components/rich-text-editor/plugins/ContentEditor/index.ts new file mode 100644 index 0000000000..5a2c527d7c --- /dev/null +++ b/src/components/rich-text-editor/plugins/ContentEditor/index.ts @@ -0,0 +1 @@ +export { default } from "./content-editor.component"; diff --git a/src/components/rich-text-editor/plugins/LinkMonitor/index.ts b/src/components/rich-text-editor/plugins/LinkMonitor/index.ts new file mode 100644 index 0000000000..93ceb24141 --- /dev/null +++ b/src/components/rich-text-editor/plugins/LinkMonitor/index.ts @@ -0,0 +1 @@ +export { default } from "./link-monitor.plugin"; diff --git a/src/components/rich-text-editor/plugins/LinkMonitor/link-monitor.plugin.ts b/src/components/rich-text-editor/plugins/LinkMonitor/link-monitor.plugin.ts new file mode 100644 index 0000000000..1a4a21e78b --- /dev/null +++ b/src/components/rich-text-editor/plugins/LinkMonitor/link-monitor.plugin.ts @@ -0,0 +1,67 @@ +/* istanbul ignore file */ +/** + * Owing to the nature of how this plugin runs, it is not possible to test it in isolation. + * It is tested as part of the RichTextEditor Playwright tests. + * + * The purpose of this plugin is to monitor the editor for any changes that result in the + * creation of AutoLinkNodes, and report these changes to the customer (e.g. to then + * generate link previews). + */ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { AutoLinkNode } from "@lexical/link"; + +import { useContext, useEffect } from "react"; + +import { validateUrl } from "../../helpers"; +import RichTextEditorContext from "../../rich-text-editor.context"; + +const LinkMonitorPlugin = () => { + // Get the editor instance + const [editor] = useLexicalComposerContext(); + // Get the onLinkAdded function from the context + const { onLinkAdded } = useContext(RichTextEditorContext); + + useEffect(() => { + // Register a mutation listener for AutoLinkNodes + const removeAutoLinkMutationListener = editor.registerMutationListener( + AutoLinkNode, + (mutatedNodes, { prevEditorState }) => { + const isEditable = editor.isEditable(); + if (!isEditable) return; + + // For each AutoLinkNode, check if the text content is present and also a valid URL + for (const [nodeKey, mutation] of mutatedNodes) { + const node = editor.getElementByKey(nodeKey); + const textContent = node?.innerText; + + if (textContent) { + const linkValid = validateUrl(textContent); + if (linkValid) { + // Assume link has been created, notify user + onLinkAdded?.(textContent, mutation); + } + } else { + // Assume link has been destroyed, notify user + const deletedData = prevEditorState?._nodeMap.get( + nodeKey, + ) as AutoLinkNode; + if (deletedData) { + const { __url } = deletedData; + onLinkAdded?.(__url, mutation); + } + } + } + }, + { skipInitialization: false }, + ); + + // Remove the mutation listener when the component is unmounted + return () => { + removeAutoLinkMutationListener(); + }; + }, [editor, onLinkAdded]); + + return null; +}; + +export default LinkMonitorPlugin; diff --git a/src/components/rich-text-editor/plugins/LinkPreviewer/index.ts b/src/components/rich-text-editor/plugins/LinkPreviewer/index.ts new file mode 100644 index 0000000000..c1023a0371 --- /dev/null +++ b/src/components/rich-text-editor/plugins/LinkPreviewer/index.ts @@ -0,0 +1 @@ +export { default } from "./link-previewer.component"; diff --git a/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.component.tsx b/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.component.tsx new file mode 100644 index 0000000000..4056141b03 --- /dev/null +++ b/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.component.tsx @@ -0,0 +1,34 @@ +import React, { useContext } from "react"; + +import StyledLinkPreviewer from "./link-previewer.style"; + +import RichTextEditorContext from "../../rich-text-editor.context"; +import createGuid from "../../../../__internal__/utils/helpers/guid"; + +export interface LinkPreviewerProps { + /** The active error message of the editor */ + error?: string; + /** The link previews to render at the foot of the editor */ + previews?: React.JSX.Element[]; + /** The active warning message of the editor */ + warning?: string; +} + +const LinkPreviewer = ({ + error, + previews = [], + warning, +}: LinkPreviewerProps) => { + const { readOnly } = useContext(RichTextEditorContext); + + return ( + + {previews.map((preview) => { + const key = createGuid(); + return
    {preview}
    ; + })} +
    + ); +}; + +export default LinkPreviewer; diff --git a/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.style.ts b/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.style.ts new file mode 100644 index 0000000000..ffceb7bc1f --- /dev/null +++ b/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.style.ts @@ -0,0 +1,34 @@ +import styled, { css } from "styled-components"; + +import { LinkPreviewerProps } from "./link-previewer.component"; + +interface StyledLinkPreviewerProps extends LinkPreviewerProps { + readOnly?: boolean; +} + +const StyledLinkPreviewer = styled.div` + ${({ error, readOnly, warning }) => css` + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--colorsUtilityYang100); + border-bottom: 1px solid var(--colorsUtilityMajor200); + border-left: 1px solid var(--colorsUtilityMajor200); + border-right: 1px solid var(--colorsUtilityMajor200); + margin: 0; + padding: 2px 8px; + + ${(error || warning) && + css` + border-left: none; + border-right: none; + border-bottom: none; + `} + + ${readOnly && + css` + border-bottom-left-radius: var(--borderWidth600); + border-bottom-right-radius: var(--borderWidth600); + `} + `} +`; +export default StyledLinkPreviewer; diff --git a/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.test.tsx b/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.test.tsx new file mode 100644 index 0000000000..e75b565cfd --- /dev/null +++ b/src/components/rich-text-editor/plugins/LinkPreviewer/link-previewer.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import LinkPreviewer from "./link-previewer.component"; + +test("renders the link previewer component", () => { + const previews = [
    Preview 1
    ,
    Preview 2
    ]; + render(); + expect(screen.getByText("Preview 1")).toBeInTheDocument(); + expect(screen.getByText("Preview 2")).toBeInTheDocument(); +}); + +test("renders an empty link previewer component if no previews are provided", () => { + render(); + expect(screen.queryByText("Preview 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Preview 2")).not.toBeInTheDocument(); +}); diff --git a/src/components/rich-text-editor/plugins/OnChange/index.ts b/src/components/rich-text-editor/plugins/OnChange/index.ts new file mode 100644 index 0000000000..7995c698af --- /dev/null +++ b/src/components/rich-text-editor/plugins/OnChange/index.ts @@ -0,0 +1 @@ +export { default } from "./on-change.plugin"; diff --git a/src/components/rich-text-editor/plugins/OnChange/on-change.plugin.ts b/src/components/rich-text-editor/plugins/OnChange/on-change.plugin.ts new file mode 100644 index 0000000000..af51357717 --- /dev/null +++ b/src/components/rich-text-editor/plugins/OnChange/on-change.plugin.ts @@ -0,0 +1,29 @@ +/** + * This plugin listens to changes in the editor and calls the `onChange` prop with the new editor state. + */ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; + +import { EditorState } from "lexical"; + +import { useEffect } from "react"; + +const OnChangePlugin = (props: { + onChange: (editorState: EditorState) => void; +}) => { + // Get the editor instance + const [editor] = useLexicalComposerContext(); + const { onChange } = props; + + // Register an update listener to call the `onChange` prop + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + const isEditable = editor.isEditable(); + /* istanbul ignore else */ + if (isEditable) onChange(editorState); + }); + }, [editor, onChange]); + + return null; +}; + +export default OnChangePlugin; diff --git a/src/components/rich-text-editor/plugins/OnChange/on-change.test.tsx b/src/components/rich-text-editor/plugins/OnChange/on-change.test.tsx new file mode 100644 index 0000000000..ebe8b59fba --- /dev/null +++ b/src/components/rich-text-editor/plugins/OnChange/on-change.test.tsx @@ -0,0 +1,45 @@ +/** The OnChangePlugin is a plugin for the LexicalComposer that calls a callback function when the content of the editor changes. + * As this functionality is directly related to the LexicalComposer, it is not possible to test it in isolation. The unit tests + * below are testing the OnChangePlugin in the context of the LexicalComposer to ensure that invoking the callback function when + * the content of the editor changes behaves as expected. Actual content of the OnChangePlugin is not here. + * + * Content-based tests should/will be handled by the Playwright tests. + */ +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import OnChangePlugin from "./on-change.plugin"; + +describe("OnChangePlugin", () => { + test("should handle changes correctly", async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + expect(mockOnChange).not.toHaveBeenCalled(); + const tb = screen.getByRole("textbox"); + await user.click(tb); + await user.keyboard("abcdefghijklmnopqrstuvwxyz0123456789"); + expect(mockOnChange).toHaveBeenCalled(); + expect(tb).toHaveTextContent("abcdefghijklmnopqrstuvwxyz0123456789"); + }); +}); diff --git a/src/components/rich-text-editor/plugins/Placeholder/index.ts b/src/components/rich-text-editor/plugins/Placeholder/index.ts new file mode 100644 index 0000000000..8cf5212060 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Placeholder/index.ts @@ -0,0 +1 @@ +export { default } from "./placeholder.component"; diff --git a/src/components/rich-text-editor/plugins/Placeholder/placeholder.component.tsx b/src/components/rich-text-editor/plugins/Placeholder/placeholder.component.tsx new file mode 100644 index 0000000000..aead9a8e64 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Placeholder/placeholder.component.tsx @@ -0,0 +1,25 @@ +/** + * The placeholder component does not reside in the editor in a traditional sense, but is instead rendered + * in a separate component which is then styled to appear as if it is part of the editor. This is by design + * in the Lexical project to allow for greater flexibility in the design of the editor (apparently). + */ +import React from "react"; + +import StyledPlaceholder from "./placeholder.style"; + +interface PlaceholderProps { + /** The namespace of the editor that this placeholder belongs to */ + namespace: string; + /** The text to display in the placeholder */ + text: string | undefined; +} + +const Placeholder = ({ namespace, text = "" }: PlaceholderProps) => { + return ( + + {text} + + ); +}; + +export default Placeholder; diff --git a/src/components/rich-text-editor/plugins/Placeholder/placeholder.style.ts b/src/components/rich-text-editor/plugins/Placeholder/placeholder.style.ts new file mode 100644 index 0000000000..6084d15335 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Placeholder/placeholder.style.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +const StyledPlaceholder = styled.div` + position: absolute; + top: 16px; + color: var(--colorsUtilityYin055); + left: 10px; +`; + +export default StyledPlaceholder; diff --git a/src/components/rich-text-editor/plugins/Placeholder/placeholder.test.tsx b/src/components/rich-text-editor/plugins/Placeholder/placeholder.test.tsx new file mode 100644 index 0000000000..c646ccd7f4 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Placeholder/placeholder.test.tsx @@ -0,0 +1,20 @@ +/** + * Placeholder can be tested in isolation as it is a simple component with no + * depencies on the parent RichTextEditor. + */ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import Placeholder from "./placeholder.component"; + +describe("Placeholder", () => { + test("should render the placeholder text", () => { + render(); + const placeholder = screen.getByText("This is a placeholder"); + expect(placeholder).toBeInTheDocument(); + }); + + test("should not render the placeholder text if nothing is provided", () => { + render(); + expect(screen.queryByText("This is a placeholder")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/bold.component.tsx b/src/components/rich-text-editor/plugins/Toolbar/buttons/bold.component.tsx new file mode 100644 index 0000000000..832c07f658 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/bold.component.tsx @@ -0,0 +1,44 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; + +import { FORMAT_TEXT_COMMAND } from "lexical"; + +import React from "react"; + +import { FormattingButton } from "../toolbar.style"; +import { RichTextEditorActionTypes } from "../../../constants"; +import useLocale from "../../../../../hooks/__internal__/useLocale"; + +import { FormattingButtonProps } from "./common"; + +// The `BoldButton` component is a button that applies bold formatting to the selected text in the editor. +const BoldButton = ({ isActive, namespace }: FormattingButtonProps) => { + // Get the locale to enable translations + const locale = useLocale(); + // Get the editor instance + const [editor] = useLexicalComposerContext(); + + // When the button is clicked, dispatch the `FORMAT_TEXT_COMMAND` with the `Bold` action + const handleClick = () => { + const isEditable = editor.isEditable(); + /* istanbul ignore else */ + if (isEditable) + editor.dispatchCommand( + FORMAT_TEXT_COMMAND, + RichTextEditorActionTypes.Bold, + ); + }; + + return ( + handleClick()} + iconType="bold" + buttonType={isActive ? "primary" : "tertiary"} + isActive={isActive} + data-role={`${namespace}-bold-button`} + /> + ); +}; + +export default BoldButton; diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/buttons.test.tsx b/src/components/rich-text-editor/plugins/Toolbar/buttons/buttons.test.tsx new file mode 100644 index 0000000000..c26fb2d9e6 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/buttons.test.tsx @@ -0,0 +1,225 @@ +/** + * Button state tests only. Functionality tests are in the ../toolbar.test.tsx file. + * The Save button functionality is tested here to make use of Jest's function mocking. + */ +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import { BoldButton, ItalicButton, ListControls, SaveButton } from "."; + +const mockedSerializeRespone = { + htmlString: "


    ", + json: { + root: { + children: [], + direction: null, + format: "", + indent: 0, + type: "root", + version: 1, + }, + }, +}; + +describe("Toolbar buttons", () => { + describe("Bold button", () => { + test("should render the bold button correctly if inactive", () => { + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const boldButton = screen.getByRole("button"); + expect(boldButton).toBeInTheDocument(); + expect(boldButton).toHaveStyleRule("background-color", "transparent"); + }); + + test("should render the bold button correctly if active", () => { + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const boldButton = screen.getByRole("button"); + expect(boldButton).toBeInTheDocument(); + expect(boldButton).toHaveStyleRule( + "background-color", + "var(--colorsActionMajor600)", + ); + }); + }); + + describe("Italic button", () => { + test("should render the italic button correctly if inactive", () => { + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const italicButton = screen.getByRole("button"); + expect(italicButton).toBeInTheDocument(); + expect(italicButton).toHaveStyleRule("background-color", "transparent"); + }); + + test("should render the bold button correctly if active", () => { + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const italicButton = screen.getByRole("button"); + expect(italicButton).toBeInTheDocument(); + expect(italicButton).toHaveStyleRule( + "background-color", + "var(--colorsActionMajor600)", + ); + }); + }); + + describe("List controls", () => { + test("should render the ordered list control correctly", async () => { + const user = userEvent.setup(); + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const olButton = screen.getByTestId(`test-ordered-list-button`); + expect(olButton).toBeInTheDocument(); + expect(olButton).toHaveStyleRule("background-color", "transparent"); + + await user.click(olButton); + expect(olButton).toHaveStyleRule( + "background-color", + "var(--colorsActionMajor600)", + ); + }); + + test("should render the unordered list control correctly", async () => { + const user = userEvent.setup(); + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const ulButton = screen.getByTestId(`test-unordered-list-button`); + expect(ulButton).toBeInTheDocument(); + expect(ulButton).toHaveStyleRule("background-color", "transparent"); + + await user.click(ulButton); + expect(ulButton).toHaveStyleRule( + "background-color", + "var(--colorsActionMajor600)", + ); + }); + }); +}); + +describe("Command buttons", () => { + describe("Save button", () => { + test("invokes the onSave callback with the serialized editor value", () => { + const mockSerialize = jest.fn(() => mockedSerializeRespone); + jest.mock("../../../helpers", () => ({ + SerializeLexical: mockSerialize, + })); + const onSave = jest.fn(); + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const saveButton = screen.getByRole("button"); + saveButton.click(); + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave.mock.calls[0][0].htmlString).toEqual("


    "); + }); + }); +}); diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/common.ts b/src/components/rich-text-editor/plugins/Toolbar/buttons/common.ts new file mode 100644 index 0000000000..8de38f8546 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/common.ts @@ -0,0 +1,6 @@ +export interface FormattingButtonProps { + /** Whether the button is active or not, relative to the text at the current cursor position */ + isActive: boolean; + /** The namespace of the editor that this button belongs to */ + namespace: string; +} diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/index.ts b/src/components/rich-text-editor/plugins/Toolbar/buttons/index.ts new file mode 100644 index 0000000000..01265a2afa --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/index.ts @@ -0,0 +1,4 @@ +export { default as BoldButton } from "./bold.component"; +export { default as ItalicButton } from "./italic.component"; +export { default as ListControls } from "./list.component"; +export { default as SaveButton } from "./save.component"; diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/italic.component.tsx b/src/components/rich-text-editor/plugins/Toolbar/buttons/italic.component.tsx new file mode 100644 index 0000000000..c9353724ed --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/italic.component.tsx @@ -0,0 +1,42 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { FORMAT_TEXT_COMMAND } from "lexical"; +import React from "react"; + +import { FormattingButton } from "../toolbar.style"; +import { FormattingButtonProps } from "./common"; + +import { RichTextEditorActionTypes } from "../../../constants"; +import useLocale from "../../../../../hooks/__internal__/useLocale"; + +// The `ItalicButton` component is a button that applies italic formatting to the selected text in the editor. +const ItalicButton = ({ isActive, namespace }: FormattingButtonProps) => { + // Get the editor instance + const [editor] = useLexicalComposerContext(); + // Get the locale to enable translations + const locale = useLocale(); + + // When the button is clicked, dispatch the `FORMAT_TEXT_COMMAND` with the `Italic` action + const handleClick = () => { + const isEditable = editor.isEditable(); + /* istanbul ignore else */ + if (isEditable) + editor.dispatchCommand( + FORMAT_TEXT_COMMAND, + RichTextEditorActionTypes.Italic, + ); + }; + + return ( + handleClick()} + iconType="italic" + buttonType={isActive ? "primary" : "tertiary"} + isActive={isActive} + data-role={`${namespace}-italic-button`} + /> + ); +}; + +export default ItalicButton; diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/list.component.tsx b/src/components/rich-text-editor/plugins/Toolbar/buttons/list.component.tsx new file mode 100644 index 0000000000..32dbb4904f --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/list.component.tsx @@ -0,0 +1,113 @@ +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, + insertList, + removeList, +} from "@lexical/list"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { COMMAND_PRIORITY_LOW } from "lexical"; +import React, { useState } from "react"; + +import { FormattingButton } from "../toolbar.style"; + +import { RichTextEditorActionTypes } from "../../../constants"; +import useLocale from "../../../../../hooks/__internal__/useLocale"; + +// The `ListControls` component is a set of buttons that allow the user to insert ordered and unordered lists into the editor. +const ListControls = ({ namespace }: { namespace: string }) => { + // Get the editor instance + const [editor] = useLexicalComposerContext(); + // Get the locale to enable translations + const locale = useLocale(); + // Set the initial state of the list buttons + const [isOLActive, setIsOLActive] = useState(false); + const [isULActive, setIsULActive] = useState(false); + + // Register the commands for inserting and removing lists + editor.registerCommand( + INSERT_ORDERED_LIST_COMMAND, + () => { + insertList(editor, RichTextEditorActionTypes.OrderedList); + return true; + }, + COMMAND_PRIORITY_LOW, + ); + + editor.registerCommand( + INSERT_UNORDERED_LIST_COMMAND, + () => { + insertList(editor, RichTextEditorActionTypes.UnorderedList); + return true; + }, + COMMAND_PRIORITY_LOW, + ); + + editor.registerCommand( + REMOVE_LIST_COMMAND, + () => { + removeList(editor); + return true; + }, + COMMAND_PRIORITY_LOW, + ); + + // When the ordered list button is clicked, insert or remove an ordered list + const handleOLClick = () => { + const isEditable = editor.isEditable(); + /* istanbul ignore if */ + if (!isEditable) return; + + if (isOLActive) { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + setIsOLActive(false); + } else { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + setIsOLActive(true); + } + // If the unordered list button is active, deactivate it + setIsULActive(false); + }; + + // When the unordered list button is clicked, insert or remove an unordered list + const handleULClick = () => { + const isEditable = editor.isEditable(); + /* istanbul ignore if */ + if (!isEditable) return; + + if (isULActive) { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + setIsULActive(false); + } else { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + setIsULActive(true); + } + // If the ordered list button is active, deactivate it + setIsOLActive(false); + }; + + return ( + <> + handleOLClick()} + iconType="bullet_list_numbers" + buttonType={isOLActive ? "primary" : "tertiary"} + isActive={isOLActive} + data-role={`${namespace}-ordered-list-button`} + /> + handleULClick()} + iconType="bullet_list_dotted" + buttonType={isULActive ? "primary" : "tertiary"} + isActive={isULActive} + data-role={`${namespace}-unordered-list-button`} + /> + + ); +}; + +export default ListControls; diff --git a/src/components/rich-text-editor/plugins/Toolbar/buttons/save.component.tsx b/src/components/rich-text-editor/plugins/Toolbar/buttons/save.component.tsx new file mode 100644 index 0000000000..8137be7ba0 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/buttons/save.component.tsx @@ -0,0 +1,70 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; + +import React from "react"; + +import { SerializeLexical } from "../../../helpers"; +import Button from "../../../../button"; +import useLocale from "../../../../../hooks/__internal__/useLocale"; + +interface SaveObjectProps { + detail: number; + format: number; + mode: string; + style: string; + text: string; + type: string; + version: number; +} + +interface SaveProps { + children: SaveObjectProps[]; +} + +export interface SaveCallbackProps { + htmlString?: string; + json?: { + root: { + children: SaveProps[]; + direction: string; + format: string; + indent: number; + type: string; + version: string; + }; + }; +} + +interface SaveButtonProps { + /** The namespace of the editor that this button belongs to */ + namespace: string; + /** The callback to call when the save button is clicked */ + onSave: (value: SaveCallbackProps) => void; +} + +// The `SaveButton` component is a button that saves the current state of the editor +const SaveButton = ({ namespace, onSave }: SaveButtonProps) => { + // Get the editor instance + const [editor] = useLexicalComposerContext(); + // Get the locale to enable translations + const locale = useLocale(); + + return ( + + ); +}; + +export default SaveButton; diff --git a/src/components/rich-text-editor/plugins/Toolbar/index.ts b/src/components/rich-text-editor/plugins/Toolbar/index.ts new file mode 100644 index 0000000000..2b47c10be6 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/index.ts @@ -0,0 +1 @@ +export { default } from "./toolbar.component"; diff --git a/src/components/rich-text-editor/plugins/Toolbar/toolbar.component.tsx b/src/components/rich-text-editor/plugins/Toolbar/toolbar.component.tsx new file mode 100644 index 0000000000..82eff76345 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/toolbar.component.tsx @@ -0,0 +1,91 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { mergeRegister } from "@lexical/utils"; +import { $getSelection, $isRangeSelection } from "lexical"; +import React, { useCallback, useEffect, useState } from "react"; + +import { + StyledToolbar, + FormattingButtons, + CommandButtons, +} from "./toolbar.style"; +import { RichTextEditorActionTypes } from "../../constants"; +import Button from "../../../button"; +import useLocale from "../../../../hooks/__internal__/useLocale"; + +import { BoldButton, ItalicButton, ListControls } from "./buttons"; + +import SaveButton, { SaveCallbackProps } from "./buttons/save.component"; + +interface ToolbarProps { + /** The namespace of the editor that this toolbar belongs to */ + namespace: string; + /** The callback to call when the cancel button is clicked */ + onCancel?: () => void; + /** The callback to call when the save button is clicked */ + onSave?: (value: SaveCallbackProps) => void; +} + +const Toolbar = ({ namespace, onCancel, onSave }: ToolbarProps) => { + // Get the editor instance + const [editor] = useLexicalComposerContext(); + // Set the initial state of the formatting buttons + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + + // Get the locale to enable translations + const locale = useLocale(); + + // Update the toolbar based on the current selection + const updateToolbar = useCallback(() => { + // Get the current selection + const selection = $getSelection(); + // If the selection is a range selection, update the formatting buttons + if ($isRangeSelection(selection)) { + setIsBold(selection.hasFormat(RichTextEditorActionTypes.Bold)); + setIsItalic(selection.hasFormat(RichTextEditorActionTypes.Italic)); + } + }, []); + + // Register an update listener to update the toolbar when the editor state changes + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const isEditable = editor.isEditable(); + /* istanbul ignore else */ + if (isEditable) updateToolbar(); + }); + }), + ); + }, [updateToolbar, editor]); + + return ( + e.stopPropagation()} + > + + + + + + + {onCancel && ( + + )} + + {onSave && } + + + ); +}; + +export default Toolbar; diff --git a/src/components/rich-text-editor/plugins/Toolbar/toolbar.style.ts b/src/components/rich-text-editor/plugins/Toolbar/toolbar.style.ts new file mode 100644 index 0000000000..c843d4a080 --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/toolbar.style.ts @@ -0,0 +1,52 @@ +import styled, { css } from "styled-components"; + +import Button, { ButtonProps } from "../../../../components/button"; + +interface FormattingButtonProps extends ButtonProps { + isActive?: boolean; +} + +const StyledToolbar = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + background-color: var(--colorsUtilityMajor025); + outline: 1px solid var(--colorsUtilityMajor200); + padding: 12px; + border-radius: var(--borderRadius100); + border-top-left-radius: 0; + border-top-right-radius: 0; + justify-content: space-between; + align-items: center; + margin-left: 1px; + margin-right: 1px; +`; + +const FormattingButtons = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + +const CommandButtons = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + +const FormattingButton = styled(Button)` + display: inline-flex; + justify-content: center; + align-items: center; + padding: 6px; + border-radius: var(--borderRadius100); + border: medium; + cursor: pointer; + ${({ isActive }) => css` + background-color: ${isActive + ? "var(--colorsActionMajor600)" + : "transparent"}; + `} +`; + +export { StyledToolbar, FormattingButtons, CommandButtons, FormattingButton }; diff --git a/src/components/rich-text-editor/plugins/Toolbar/toolbar.test.tsx b/src/components/rich-text-editor/plugins/Toolbar/toolbar.test.tsx new file mode 100644 index 0000000000..2f96f8c6ba --- /dev/null +++ b/src/components/rich-text-editor/plugins/Toolbar/toolbar.test.tsx @@ -0,0 +1,210 @@ +/** + * Functional toolbar tests. For button state tests, see the buttons/buttons.test.tsx file. + */ +import { createHeadlessEditor } from "@lexical/headless"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { render, screen } from "@testing-library/react"; +import { FORMAT_TEXT_COMMAND } from "lexical"; +import React from "react"; + +import userEvent from "@testing-library/user-event"; +import { ToolbarPlugin } from ".."; + +function headlessEditor() { + const editor = createHeadlessEditor({ + nodes: [], + onError: () => {}, + }); + return editor; +} + +/** Owing to the nature of the Lexical structure, the toolbar is mocked below with default buttons. + * This allows us to test the toolbar's functionality without needing to test the buttons themselves; + * all we care about here is that the buttons correctly dispatch their commands when clicked, and with the + * correct parameters. + */ +const MockToolbar = ({ + editor, + namespace, +}: { + editor: any; + namespace: string; +}) => { + return ( +
    + + + + +
    + ); +}; + +describe("Toolbar", () => { + describe("Rendering", () => { + /** This test renders the actual toolbar instead of using the mocked one to ensure + * that the toolbar renders correctly with the default buttons. + */ + test("renders the toolbar", () => { + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const toolbar = screen.getByTestId("test-toolbar"); + expect(toolbar).toBeInTheDocument(); + }); + }); + + describe("Events", () => { + /** Using the mocked toolbar, test that clicking the bold button fires the correct event */ + test("dispatches the 'bold' event when the bold button is clicked", async () => { + const user = userEvent.setup(); + const editor = headlessEditor(); + const dispatchSpy = jest.spyOn(editor, "dispatchCommand"); + + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const boldButton = screen.getByRole("button", { name: "Bold" }); + await user.click(boldButton); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "bold"); + }); + + /** Using the mocked toolbar, test that clicking the italic button fires the correct event */ + test("dispatches the 'italic' event when the italic button is clicked", async () => { + const user = userEvent.setup(); + const editor = headlessEditor(); + const dispatchSpy = jest.spyOn(editor, "dispatchCommand"); + + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const italicButton = screen.getByRole("button", { name: "Italic" }); + await user.click(italicButton); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "italic"); + }); + + /** Using the mocked toolbar, test that clicking the ordered list button fires the correct event */ + test("dispatches the 'ordered list' event when the ordered list button is clicked", async () => { + const user = userEvent.setup(); + const editor = headlessEditor(); + const dispatchSpy = jest.spyOn(editor, "dispatchCommand"); + + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const olButton = screen.getByRole("button", { name: "Ordered List" }); + await user.click(olButton); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "number"); + }); + + /** Using the mocked toolbar, test that clicking the unordered list button fires the correct event */ + test("dispatches the 'unordered list' event when the unordered list button is clicked", async () => { + const user = userEvent.setup(); + const editor = headlessEditor(); + const dispatchSpy = jest.spyOn(editor, "dispatchCommand"); + + render( + {}, + namespace: "test", + }} + > + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + , + ); + const ulButton = screen.getByRole("button", { name: "Unordered List" }); + await user.click(ulButton); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "bullet"); + }); + }); +}); diff --git a/src/components/rich-text-editor/plugins/index.ts b/src/components/rich-text-editor/plugins/index.ts new file mode 100644 index 0000000000..118e791ae9 --- /dev/null +++ b/src/components/rich-text-editor/plugins/index.ts @@ -0,0 +1,8 @@ +export { default as AutoLinkerPlugin } from "./AutoLinker"; +export { default as CharacterCounterPlugin } from "./CharacterCounter"; +export { default as ContentEditor } from "./ContentEditor"; +export { default as LinkMonitorPlugin } from "./LinkMonitor"; +export { default as LinkPreviewerPlugin } from "./LinkPreviewer"; +export { default as OnChangePlugin } from "./OnChange"; +export { default as Placeholder } from "./Placeholder"; +export { default as ToolbarPlugin } from "./Toolbar"; diff --git a/src/components/rich-text-editor/rich-text-editor-test.stories.tsx b/src/components/rich-text-editor/rich-text-editor-test.stories.tsx new file mode 100644 index 0000000000..95e5af011a --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor-test.stories.tsx @@ -0,0 +1,120 @@ +/* eslint-disable no-console */ +import { Meta, StoryObj } from "@storybook/react"; +import React, { useCallback, useEffect, useState } from "react"; + +import RichTextEditor, { + CreateFromHTML, + RichTextEditorProps, +} from "./rich-text-editor.component"; +import Box from "../box"; +import Button from "../button"; +import Typography from "../typography"; + +import useDebounce from "../../hooks/__internal__/useDebounce"; +import ReadOnlyEditor from "./__internal__"; + +const meta: Meta = { + title: "Rich Text Editor/Test", + component: RichTextEditor, +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + characterLimit: 3000, + error: "", + inputHint: "", + isOptional: false, + labelText: "Rich Text Editor", + namespace: "carbon-storybook-rte", + placeholder: "Enter text here", + readOnly: false, + required: false, + resetOnCancel: false, + rows: 10, + warning: "", + }, +}; + +export const Functions = ({ ...props }: Partial) => { + const initialValue = `

    This is a HTML example.

    1. Look, it has lists!
    `; + const defaultValue = CreateFromHTML(initialValue); + + const [resetOnCancel, setResetOnCancel] = useState(false); + const [debouncedValue, setDebouncedValue] = useState(null); + const debounceWaitTime = 2000; + + const handleChange = useDebounce((newValue) => { + setDebouncedValue(newValue); + }, debounceWaitTime); + + const handleCancel = useCallback(() => { + console.log("Cancel"); + }, []); + const handleSave = useCallback(({ htmlString, json }) => { + console.log("Save", { htmlString, json }); + }, []); + const handleLinkAdded = useCallback((value: string) => { + console.log("Link Added", value); + }, []); + + useEffect(() => { + console.log("Debounced Value (via onChange)", debouncedValue); + }, [debouncedValue]); + + return ( + + + + Reset On Cancel: {resetOnCancel ? "true" : "false"} + + + + ); +}; + +Functions.storyName = "Functions"; + +export const ReadOnlyEditorForNotes = () => { + const defaultValue = `This is a plain text example`; + + const htmlValue = CreateFromHTML( + `

    This is a HTML example.

    1. Look, it has lists and formatting!
    `, + ); + + return ( + + + This version of the editor is provided exclusively for use in the `Note` + component and as such is not available to consumers. It is + stripped-down, simplified implementation akin to Lexical's most basic + editor, and it's sole purpose is to display the content of `Note` in the + correct display format. The light gray background is used to indicate + the position of the editor, and is purely decorative for this story; it + will not appear in the actual component. + + + + + + + + + + + ); +}; diff --git a/src/components/rich-text-editor/rich-text-editor.component.tsx b/src/components/rich-text-editor/rich-text-editor.component.tsx new file mode 100644 index 0000000000..381c394a4a --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.component.tsx @@ -0,0 +1,258 @@ +/* eslint-disable no-console */ +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ClickableLinkPlugin } from "@lexical/react/LexicalClickableLinkPlugin"; +import { EditorRefPlugin } from "@lexical/react/LexicalEditorRefPlugin"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; + +import { EditorState, $getRoot, LexicalEditor } from "lexical"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { MarginProps } from "styled-system"; + +import Label from "../../__internal__/label"; +import useLocale from "../../hooks/__internal__/useLocale"; + +import { componentPrefix, markdownNodes, theme } from "./constants"; +import { DeserializeHTML, validateUrl } from "./helpers"; +import { + AutoLinkerPlugin, + CharacterCounterPlugin, + ContentEditor, + LinkMonitorPlugin, + OnChangePlugin, + Placeholder, + ToolbarPlugin, +} from "./plugins"; +import RichTextEditorContext from "./rich-text-editor.context"; +import StyledRichTextEditor, { + StyledEditorToolbarWrapper, + StyledHintText, + StyledValidationMessage, + StyledWrapper, +} from "./rich-text-editor.style"; +import { SaveCallbackProps } from "./plugins/Toolbar/buttons/save.component"; + +export interface RichTextEditorProps extends MarginProps { + /** The maximum number of characters allowed in the editor */ + characterLimit?: number; + /** The message to be shown when the editor is in an error state */ + error?: string; + /** A hint string rendered before the editor but after the label. Intended to describe the purpose or content of the input. */ + inputHint?: string; + /** Whether the content of the editor can be empty */ + isOptional?: boolean; + /** The label to display above the editor */ + labelText: string; + /** The identifier for the Rich Text Editor. This allows for the using of multiple Rich Text Editors on a screen */ + namespace?: string; + /** The callback to fire when the Cancel button within the editor is pressed */ + onCancel?: () => void; + /** The callback to fire when a change is registered within the editor */ + onChange?: (value: string) => void; + /** The callback to fire when a link is added into the editor */ + onLinkAdded?: (link: string, state: string) => void; + /** The callback to fire when the Save button within the editor is pressed */ + onSave?: (value: SaveCallbackProps) => void; + /** The placeholder to display when the editor is empty */ + placeholder?: string; + /** An array of link preview nodes to render in the editor */ + previews?: React.JSX.Element[]; + /** Whether the editor is read-only */ + readOnly?: boolean; + /** Whether the content of the editor is required to have a value */ + required?: boolean; + /** Whether to reset the editor to the default state when pressing the Cancel button */ + resetOnCancel?: boolean; + /** Number greater than 2 multiplied to override the default min-height of the editor */ + rows?: number; + /** The message to be shown when the editor is in an warning state */ + warning?: string; + /** The initial value of the editor, as a HTML string, or JSON */ + value?: string | undefined; +} + +export const CreateFromHTML = (html: string) => { + // DeserializeHTML is tested as part of the helper tests + /* istanbul ignore next */ + return DeserializeHTML(html); +}; + +export const RichTextEditor = ({ + characterLimit = 3000, + error, + inputHint, + isOptional = false, + labelText, + namespace = componentPrefix, + onCancel, + onChange, + onLinkAdded, + onSave, + placeholder, + previews = [], + readOnly = false, + required = false, + resetOnCancel = false, + rows, + warning, + value, +}: RichTextEditorProps) => { + const editorRef = useRef(undefined); + const locale = useLocale(); + const [editorState, setEditorState] = useState( + undefined, + ); + const [characterLimitWarning, setCharacterLimitWarning] = useState< + string | undefined + >(undefined); + const [isFocused, setIsFocused] = useState(false); + + const initialConfig = useMemo(() => { + return { + namespace, + nodes: markdownNodes, + onError: console.error, + theme, + editorState: value, + editable: !readOnly, + }; + }, [namespace, readOnly, value]); + + // OnChangePlugin is tested separately + /* istanbul ignore next */ + const handleChange = useCallback( + (newState) => { + setEditorState(newState); + const currentTextContent = newState.read(() => + $getRoot().getTextContent(), + ); + + if (onChange) { + onChange?.(currentTextContent); + } + + if (characterLimit > 0) { + const currentDiff = characterLimit - currentTextContent.length; + setCharacterLimitWarning( + currentDiff < 0 + ? locale.richTextEditor.characterLimit(Math.abs(currentDiff)) + : undefined, + ); + } + }, + [characterLimit, locale, onChange], + ); + + const handleCancel = useCallback(() => { + const editor = editorRef.current; + /* istanbul ignore next */ + const isEditable = editor?.isEditable() || false; + /* istanbul ignore if */ + if (!isEditable) return; + + /* istanbul ignore else */ + if (onCancel) { + onCancel(); + } + + /* istanbul ignore else */ + if (resetOnCancel && value) { + /* istanbul ignore else */ + if (editor) { + const newEditorState = editor.parseEditorState(value); + editor.setEditorState(newEditorState); + } + } + }, [onCancel, resetOnCancel, value]); + + const toolbarProps = useMemo( + () => ({ + namespace, + onCancel: onCancel ? handleCancel : undefined, + onSave, + }), + [handleCancel, namespace, onCancel, onSave], + ); + + return ( + + + + {inputHint && ( + + {inputHint} + + )} + + + + {(error || characterLimitWarning || warning) && ( + + {error || characterLimitWarning || warning} + + )} + setIsFocused(false)} + onFocus={() => setIsFocused(true)} + focused={isFocused} + > + + + } + placeholder={ + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + {!readOnly && } + + + + {characterLimit > 0 && !readOnly && ( + + )} + + + + ); +}; + +export default RichTextEditor; diff --git a/src/components/rich-text-editor/rich-text-editor.context.ts b/src/components/rich-text-editor/rich-text-editor.context.ts new file mode 100644 index 0000000000..6c5d68337d --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.context.ts @@ -0,0 +1,7 @@ +import React from "react"; + +// This is the context that is used to enable cross-component communication +export default React.createContext<{ + onLinkAdded?: (link: string, state: string) => void; + readOnly?: boolean; +}>({}); diff --git a/src/components/rich-text-editor/rich-text-editor.mdx b/src/components/rich-text-editor/rich-text-editor.mdx new file mode 100644 index 0000000000..101239bd4e --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.mdx @@ -0,0 +1,296 @@ +import { Meta, ArgTypes, Canvas } from "@storybook/blocks"; + +import * as RichTextEditorStories from "./rich-text-editor.stories"; +import TranslationKeysTable from "../../../.storybook/utils/translation-keys-table"; + + + +# Rich Text Editor + + + Product Design System component + + +Provides an interactive rich text editor that allows users to format text with various styles and save the content as JSON or HTML. For further documentation on this component, please read [our documentation regarding Lexical](../?path=/docs/documentation-using-lexical--docs) + + +## Contents + +- [Quick Start](#quick-start) +- [Examples](#examples) +- [Props](#props) +- [Translation keys](#translation-keys) + +## Quick Start + +```javascript +import RichTextEditor, { CreateFromHTML } from "carbon-react/lib/components/rich-text-editor"; +``` + +To use the rich text editor, import the `RichTextEditor` component. If your content is stored as HTML, you can use the `CreateFromHTML` +function to convert it to a format that the editor can understand. If you do not work with HTML, the `CreateFromHTML` function is not required +and can be omitted from the imports. + +## Examples + +### Default + +All instances of the Rich Text Editor should have a label that describes the purpose of the editor; this can be set via the `labelText` property. + + + +### Required + +You can set the `required` property to `true` to indicate that the content of the editor is mandatory. + + + +### Optional + +You can set the `isOptional` property to `true` to indicate that the content of the editor is optional. + + + +### Character Limit + +You can set the `characterLimit` property to limit the number of characters that can be entered into the editor. Note that the character limit +is not a hard limit, and the user can still enter more characters than the limit. However, the editor will show a warning to indicate that the +limit has been exceeded. + + + +### Command Buttons + +You can add `Save` and `Cancel` buttons to the editor by setting the `onSave` and `onCancel` properties. Both properties should be functions. +Omitting the `onCancel` property will hide the `Cancel` button, whereas omitting the `onSave` property will hide the `Save` button. + +The `onSave` function callback will be called when the user clicks the `Save` button, and contains a single argument with two values: +the JSON and HTML content of the editor. The `onCancel` function callback will be called when the user clicks the `Cancel` button. + + + +### onChange Handler + +To handle changes to the content of the editor, you can set the `onChange` property to a function. The function will be called whenever the +editor state changes, and provides a string representation of the editor content. + + + +Please note that this function will be called frequently, so it is recommended to avoid computationally expensive operations within it. You +may also wish to debounce the function to avoid excessive calls; an example of this is provided in the example below. The `useDebounce` hook +is an internal utility function that is not exported from the library; you will need to provide your own implementation of this function. + + + +### onSave Handler + +To handle the content of the editor when the `Save` button is clicked, you can set the `onSave` property to a function. The callback function +returns two values: the JSON and HTML content of the editor. The value of `json` reflects the structure that the editor understands/uses +internally; the value of `htmlString` is the raw content of the editor in HTML format (note that the HTML returned is not complete HTML - only +the content of the editor is converted). + +Type into the editor and the click the **Show Data Formats** button in the example below to see the JSON and HTML content of the editor. + + + +### Resetting On Cancel + +You can reset the editor to its initial state when the `Cancel` button is clicked by setting the `resetOnCancel` property to `true`. + + + +### With Error + +When the editor is in an error state, you can use the `error` property to display messages to the user. + + + +### With Warning + +When the editor is in a warning state, you can use the `warning` property to display messages to the user. Exceeding the character limit will +trigger a warning message automatically. + + + +### With HTML As Initial Value + +In order to set the initial value of the editor as HTML, you can use the `CreateFromHTML` function to convert the HTML content to a format that +the editor can understand. The `CreateFromHTML` function should be called with the HTML content as an argument, and the result should be passed +to the `value` property of the editor. + + + +### With JSON As Initial Value + +You can set the initial value of the editor by passing a JSON object to the `value` property. The JSON object provided should be in the format +returned by the `onSave` function callback, an example of which can be seen below. + + + +### Input Hint + +You can use the `inputHint` property to provide additional information to the user about the expected content or usage of the editor. + + + +### With Row Count + +You can customise the height of the text editor by setting the `rows` property. The `rows` property should be a number greater than `2`, and will +determine the minimium height of the editor by multplying the number of rows provided by the height of a single row (21px). If `rows` is not provided, +the editor will default to a height of `210px`, equivalent to 10 rows. + + + +### With Placeholder + +By default, the editor will render with an empty placeholder; you can override this by setting the `placeholder` property. + + + +### Link Support + +The editor supports adding links to text. To add a link, you can: + + - type the link directly into the editor; + - select the text you want to link, and paste the URL directly into the editor. The editor will automatically convert the URL into a link, wrapping the selected text. + +Clicking the link will open the URL in a new tab. + + + +### With Link Added Callback + +There may be times when you want to perform an action when a link is added to the editor. You can use the `onLinkAdded` callback to obtain a +string representation of the link that was added, updated or removed. The function will be called whenever a link is added to the editor. +In the example below, the most recently-added link will be displayed in the `Link` section below the editor; adding a new link will replace +the previous one. + +Note that this usage is for demonstration purposes only; you should maintain the list of URLs added to the editor in your application state. + + + +### With Link Previews + +You can use the `prevews` property to render previews of links added to the editor. The `previews` property should be an array of React JSX objects. Note +that the following examples are merely a suggested implementation; the only requirement is that the `previews` array contains one or more React JSX +objects. + + + +You can provide more complex previews by providing custom components to the `previews` array. + + + +You can also provide multiple previews, and can mix and match the styles as desired. + + + +### Translations + +You can override the default translations for the Rich Text Editor by passing a custom locale object to the `i18nProvider`. Consult the [translation keys](#translation-keys) section for a list of available keys. + + + +### Read-Only Mode + +You can specify that the editor should be read-only by setting the `readOnly` property to `true`. In read-only mode, the editor will not allow any changes to be made to the content. + + + +## Props + +### Rich Text Editor + + + +## Translation keys + +The following keys are available to override the translations for this component by passing in a custom locale object to the +[i18nProvider](../?path=/docs/documentation-i18n--docs). + + diff --git a/src/components/rich-text-editor/rich-text-editor.pw.tsx b/src/components/rich-text-editor/rich-text-editor.pw.tsx new file mode 100644 index 0000000000..9cb6fad2ca --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.pw.tsx @@ -0,0 +1,672 @@ +import React from "react"; +import { test, expect } from "@playwright/experimental-ct-react17"; +import { checkAccessibility } from "../../../playwright/support/helper"; + +import { + RichTextEditorDefaultComponent, + RichTextEditorWithUnformattedValue, + RichTextEditorWithValue, +} from "./components.test-pw"; + +test.describe("Prop tests", () => { + test.describe("characterLimit", () => { + test(`value of 0`, async ({ mount, page }) => { + await mount(); + + const displayedLimit = await page + .locator("div[data-role='pw-rte-character-limit']") + .isVisible(); + expect(displayedLimit).toBe(false); + }); + + [100, 500, 3000].forEach((characterLimit) => { + test(`value of ${characterLimit}`, async ({ mount, page }) => { + await mount( + , + ); + let displayedLimit = await page + .locator("div[data-role='pw-rte-character-limit']") + .textContent(); + expect(displayedLimit).toBe(`${characterLimit} characters remaining`); + + const stringToType = "a".repeat(characterLimit); + + const textbox = page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.fill(stringToType); + displayedLimit = await page + .locator("div[data-role='pw-rte-character-limit']") + .textContent(); + expect(displayedLimit).toBe(`0 characters remaining`); + + await textbox.fill(`${stringToType}1`); + + displayedLimit = await page + .locator("div[data-role='pw-rte-character-limit']") + .textContent(); + expect(displayedLimit).toBe(`0 characters remaining`); + + const displayedWarning = await page + .locator("div[data-role='pw-rte-validation-message']") + .textContent(); + expect(displayedWarning).toBe( + `You are 1 character(s) over the character limit`, + ); + await expect( + await page.locator("div[data-role='pw-rte-validation-message']"), + ).toHaveCSS("color", "rgb(191, 82, 0)"); + }); + }); + }); + + test.describe("inputHint", () => { + test(`value of 'hint'`, async ({ mount, page }) => { + await mount(); + const hint = await page + .locator(`div[data-role='pw-rte-input-hint']`) + .textContent(); + expect(hint).toBe("hint"); + }); + + test(`value not provided`, async ({ mount, page }) => { + await mount(); + const hint = await page + .locator(`div[data-role='pw-rte-input-hint']`) + .count(); + expect(hint).toBe(0); + }); + }); + + test.describe("isOptional", () => { + [true, false].forEach((isOptional) => { + test(`value of ${isOptional}`, async ({ mount, page }) => { + await mount(); + const content = await page.evaluate( + "window.getComputedStyle(document.getElementById('label-container-pw-rte-label'), '::after').getPropertyValue('content')", + ); + expect(content).toBe(isOptional ? '"(optional)"' : "none"); + }); + }); + }); + + test.describe("labelText", () => { + [{ value: "Rich Text Editor" }].forEach(({ value }) => { + test(`value of ${value}`, async ({ mount, page }) => { + await mount(); + const editorLabel = await page + .locator("label[data-element='label']") + .textContent(); + expect(editorLabel).toBe(value); + }); + }); + }); + + test.describe("placeholder", () => { + [undefined, "Enter text here"].forEach((placeholder) => { + test(`value of ${placeholder}`, async ({ mount, page }) => { + await mount( + , + ); + const displayedPlaceholder = await page + .locator("div[data-role='pw-rte-placeholder']") + .textContent(); + expect(displayedPlaceholder).toBe(placeholder || ""); + }); + }); + }); + + test.describe("previews", () => { + test("simple", async ({ mount, page }) => { + const previews = [ + Sage, + Carbon, + ]; + await mount(); + expect(await page.locator("a").count()).toBe(2); + }); + + test("complex", async ({ mount, page }) => { + const samplePreview = (key: number) => { + const _id = `preview-${key}`; + return ( +
    +

    Heading

    +

    Paragraph

    + +
    + ); + }; + const previews = []; + for (let i = 0; i < 10; i++) { + previews.push(samplePreview(i)); + } + await mount(); + expect(await page.locator("div[data-role='preview']").count()).toBe(10); + }); + }); + + test.describe("required", () => { + [true, false].forEach((required) => { + test(`value of ${required}`, async ({ mount, page }) => { + await mount(); + const content = await page.evaluate( + "window.getComputedStyle(document.getElementById('pw-rte-label'), '::after').getPropertyValue('content')", + ); + expect(content).toBe(required ? '"*"' : "none"); + }); + }); + }); + + test.describe("value", () => { + test("renders with a default value and can be interacted with", async ({ + mount, + page, + }) => { + await mount(); + const defaultText = await page.locator("p").textContent(); + expect(defaultText).toBe("Sample text with some formatting applied."); + + const normalText = await page.locator("p> span").nth(0).textContent(); + expect(normalText).toBe("Sample text with "); + const boldText = await page.locator("p > strong").textContent(); + expect(boldText).toBe("some formatting"); + const italicText = await page.locator("p > em").textContent(); + expect(italicText).toBe("applied"); + + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially(" This is added text", { delay: 100 }); + + const updatedText = await page.locator("p").textContent(); + expect(updatedText).toBe( + "Sample text with some formatting applied. This is added text", + ); + + await textbox.press("Enter"); + await textbox.pressSequentially("New line text", { delay: 100 }); + + const newParagraph = await page.locator("p").nth(1).textContent(); + expect(newParagraph).toBe("New line text"); + }); + }); +}); + +test.describe("Functionality tests", () => { + test.describe("onCancel", () => { + test("resets the content of the editor when resetOnCancel is set to true", async ({ + mount, + page, + }) => { + await mount( + {}} resetOnCancel />, + ); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially(" This is some text", { delay: 100 }); + const cancelButton = page.locator( + "button[data-role='pw-rte-cancel-button']", + ); + await cancelButton.click(); + const displayedText = await textbox.textContent(); + expect(displayedText).toBe("Sample text with some formatting applied."); + }); + }); + + test.describe("onChange", () => { + test("fires when the content of the editor changes", async ({ + mount, + page, + }) => { + let callbackFired = false; + await mount( + { + callbackFired = true; + }} + />, + ); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially(" This is some text", { delay: 100 }); + expect(callbackFired).toBe(true); + }); + }); + + test.describe("onSave", () => { + test("fires when the save button is clicked", async ({ mount, page }) => { + let _htmlString = null; + let _json = null; + await mount( + { + _htmlString = htmlString; + _json = json; + }} + />, + ); + + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially("This is some text", { delay: 100 }); + + const saveButton = await page.locator( + "button[data-role='pw-rte-save-button']", + ); + await saveButton.click(); + expect(_htmlString).not.toBeNull(); + expect(_json).not.toBeNull(); + expect(_htmlString).toBe( + '

    This is some text

    ', + ); + + expect(_json).toEqual({ + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "This is some text", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + textFormat: 0, + textStyle: "", + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + }); + }); + }); + + test.describe("Bold", () => { + test("applies and removes bold formatting to selected text", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.selectText(); + const boldButton = await page.locator( + "button[data-role='pw-rte-bold-button']", + ); + await boldButton.click(); + const boldText = await page.locator("strong").textContent(); + expect(boldText).toBe("This text needs formatting"); + await boldButton.click(); + expect(await page.locator("strong").count()).toBe(0); + }); + }); + + test.describe("Italic", () => { + test("applies and removes italic formatting to selected text", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.selectText(); + const italicButton = await page.locator( + "button[data-role='pw-rte-italic-button']", + ); + await italicButton.click(); + const italicText = await page.locator("em").textContent(); + expect(italicText).toBe("This text needs formatting"); + await italicButton.click(); + expect(await page.locator("em").count()).toBe(0); + }); + }); + + test.describe("Ordered List", () => { + test("applies and removes ordered list formatting", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.selectText(); + const orderedListButton = await page.locator( + "button[data-role='pw-rte-ordered-list-button']", + ); + await orderedListButton.click(); + const orderedList = await page.locator("ol").textContent(); + expect(orderedList).toBe("This text needs formatting"); + await orderedListButton.click(); + expect(await page.locator("ol").count()).toBe(0); + }); + }); + + test.describe("Unordered List", () => { + test("applies and removes unordered list formatting", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.selectText(); + const unorderedListButton = await page.locator( + "button[data-role='pw-rte-unordered-list-button']", + ); + await unorderedListButton.click(); + const unorderedList = await page.locator("ul").textContent(); + expect(unorderedList).toBe("This text needs formatting"); + await unorderedListButton.click(); + expect(await page.locator("ul").count()).toBe(0); + }); + }); +}); + +test.describe("Events tests", () => { + test.describe("onLinkAdded", () => { + test("fires when a link is added to the editor", async ({ + mount, + page, + }) => { + let _link = null; + await mount( + { + _link = link; + }} + />, + ); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.fill("https://www."); + await textbox.press("g"); + expect(_link).toBe("https://www.g"); + await textbox.pressSequentially("oogle.com", { delay: 100 }); + expect(_link).toBe("https://www.google.com"); + for (let i = 0; i < 10; i++) { + // eslint-disable-next-line no-await-in-loop + await textbox.press("Backspace"); + } + expect(await page.locator("a").count()).toBe(0); + }); + }); + + test.describe("Shortcut keys", () => { + test.describe("Bold", () => { + test("pressing Meta + B toggles bold text", async ({ mount, page }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.selectText(); + await page.keyboard.press("ControlOrMeta+B"); + expect(await page.locator("strong").count()).toBe(1); + await page.keyboard.press("ControlOrMeta+B"); + expect(await page.locator("strong").count()).toBe(0); + }); + + test("surrounding text with double asterisks sets text to bold", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially("**", { delay: 100 }); + expect(await page.locator("strong").count()).toBe(0); + await textbox.pressSequentially("Bold text", { delay: 100 }); + expect(await page.locator("strong").count()).toBe(0); + await textbox.pressSequentially("**", { delay: 100 }); + expect(await page.locator("strong").count()).toBe(1); + }); + }); + + test.describe("Italic", () => { + test("pressing Meta + I toggles italic text", async ({ mount, page }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.selectText(); + await page.keyboard.press("ControlOrMeta+I"); + expect(await page.locator("em").count()).toBe(1); + await page.keyboard.press("ControlOrMeta+I"); + expect(await page.locator("em").count()).toBe(0); + }); + + test("surrounding text with single asterisks sets text to italic", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially("*", { delay: 100 }); + expect(await page.locator("em").count()).toBe(0); + await textbox.pressSequentially("Italic text", { delay: 100 }); + expect(await page.locator("em").count()).toBe(0); + await textbox.pressSequentially("*", { delay: 100 }); + expect(await page.locator("em").count()).toBe(1); + }); + }); + + ["*", "-", "+"].forEach((ulChar) => { + test(`inserting/removing a "${ulChar}" character at the start of a line toggles an unordered list`, async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.press("Home"); + expect(await page.locator("ul").count()).toBe(0); + await textbox.pressSequentially(`${ulChar} `, { delay: 100 }); + expect(await page.locator("ul").count()).toBe(1); + for (let i = 0; i < 2; i++) { + // eslint-disable-next-line no-await-in-loop + await textbox.press("Backspace"); + } + expect(await page.locator("ul").count()).toBe(0); + }); + }); + + test(`inserting/removing the number 1 followed by a "." character at the start of a line toggles an ordered list`, async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.press("Home"); + expect(await page.locator("ol").count()).toBe(0); + await textbox.pressSequentially(`1. `, { delay: 100 }); + expect(await page.locator("ol").count()).toBe(1); + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + await textbox.press("Backspace"); + } + expect(await page.locator("ol").count()).toBe(0); + }); + + test('beginning a line of text with the ">" character followed by a space inserts a quote', async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially(">", { delay: 100 }); + expect(await page.locator("blockquote").count()).toBe(0); + await textbox.pressSequentially(" ", { delay: 100 }); + expect(await page.locator("blockquote").count()).toBe(1); + await textbox.press("Backspace"); + expect(await page.locator("blockquote").count()).toBe(0); + }); + + [ + { char: "#", tag: "h1" }, + { char: "##", tag: "h2" }, + { char: "###", tag: "h3" }, + { char: "####", tag: "h4" }, + ].forEach(({ char: headingChar, tag }) => { + test(`beginning a line of text with the "${headingChar}" character followed by a space inserts a "${tag}" heading`, async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + expect(await page.locator(tag).count()).toBe(0); + await textbox.pressSequentially(`${headingChar} `, { delay: 100 }); + expect(await page.locator(tag).count()).toBe(1); + for (let i = 0; i < headingChar.length + 1; i++) { + // eslint-disable-next-line no-await-in-loop + await textbox.press("Backspace"); + } + expect(await page.locator(tag).count()).toBe(0); + }); + }); + + test("allows the standard markdown link format to be used", async ({ + mount, + page, + }) => { + await mount(); + const textbox = await page.locator("div[role='textbox']"); + await textbox.click(); + await textbox.pressSequentially("[Link text](https://www.sage.com)", { + delay: 100, + }); + expect(await page.locator("a").count()).toBe(1); + }); + }); +}); + +test.describe("Styling tests", () => { + test.describe("error", () => { + test(`value of 'Error message'`, async ({ mount, page }) => { + await mount(); + const displayedError = await page + .locator("div[data-role='pw-rte-validation-message']") + .textContent(); + expect(displayedError).toBe("Error message"); + await expect( + await page.locator("div[data-role='pw-rte-validation-message']"), + ).toHaveCSS("color", "rgb(203, 55, 74)"); + await expect( + await page.locator("div[data-role='pw-rte-validation-message']"), + ).toHaveCSS("font-weight", "500"); + + await expect( + await page.locator("div[data-role='pw-rte-editor-toolbar-wrapper']"), + ).toHaveCSS("border", "2px solid rgb(203, 55, 74)"); + await expect( + await page.locator("div[data-role='pw-rte-editor-toolbar-wrapper']"), + ).toHaveCSS("border-radius", "8px"); + + await expect( + await page.locator("div[data-role='pw-rte-wrapper']"), + ).toHaveCSS("border-left", "2px solid rgb(203, 55, 74)"); + await expect( + await page.locator("div[data-role='pw-rte-wrapper']"), + ).toHaveCSS("padding-left", "8px"); + }); + + test(`value not provided`, async ({ mount, page }) => { + await mount(); + const displayedError = await page + .locator("div[data-role='pw-rte-validation-message']") + .count(); + expect(displayedError).toBe(0); + }); + }); + + test.describe("rows", () => { + [ + { rows: 1, expectedHeight: 210 }, + { rows: 2, expectedHeight: 210 }, + { rows: 3, expectedHeight: 63 }, + { rows: 5, expectedHeight: 105 }, + { rows: 10, expectedHeight: 210 }, + { rows: 20, expectedHeight: 420 }, + ].forEach(({ expectedHeight, rows }) => { + test(`has the correct height with a rows property of ${rows}`, async ({ + mount, + page, + }) => { + await mount(); + await expect( + await page.locator("div[data-role='pw-rte-editable']"), + ).toHaveCSS("min-height", `${expectedHeight}px`); + }); + }); + }); + + test.describe("warning", () => { + test(`value of 'Warning message'`, async ({ mount, page }) => { + await mount(); + const displayedWarning = await page + .locator("div[data-role='pw-rte-validation-message']") + .textContent(); + expect(displayedWarning).toBe("Warning message"); + await expect( + await page.locator("div[data-role='pw-rte-validation-message']"), + ).toHaveCSS("color", "rgb(191, 82, 0)"); + await expect( + await page.locator("div[data-role='pw-rte-validation-message']"), + ).toHaveCSS("font-weight", "400"); + + await expect( + await page.locator("div[data-role='pw-rte-editor-toolbar-wrapper']"), + ).toHaveCSS("border", "2px solid rgb(239, 103, 0)"); + await expect( + await page.locator("div[data-role='pw-rte-editor-toolbar-wrapper']"), + ).toHaveCSS("border-radius", "8px"); + + await expect( + await page.locator("div[data-role='pw-rte-wrapper']"), + ).toHaveCSS("border-left", "2px solid rgb(239, 103, 0)"); + await expect( + await page.locator("div[data-role='pw-rte-wrapper']"), + ).toHaveCSS("padding-left", "8px"); + }); + + test(`value not provided`, async ({ mount, page }) => { + await mount(); + const displayedWarning = await page + .locator("div[data-role='pw-rte-validation-message']") + .count(); + expect(displayedWarning).toBe(0); + }); + }); +}); + +test.describe("Accessibility tests", () => { + test(`should pass for default component`, async ({ mount, page }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass for default component in error state`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass for default component in warning state`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); +}); diff --git a/src/components/rich-text-editor/rich-text-editor.stories.tsx b/src/components/rich-text-editor/rich-text-editor.stories.tsx new file mode 100644 index 0000000000..b1b53572cc --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.stories.tsx @@ -0,0 +1,509 @@ +/* eslint-disable no-alert */ +import { Meta, StoryObj } from "@storybook/react"; + +import React, { useCallback, useRef, useState } from "react"; + +import Box from "../box"; +import Button from "../button"; +import I18nProvider from "../i18n-provider"; +import EditorLinkPreview from "../link-preview"; +import Typography from "../typography"; + +import useDebounce from "../../hooks/__internal__/useDebounce"; +import enGB from "../../locales/en-gb"; + +import RichTextEditor, { CreateFromHTML } from "./rich-text-editor.component"; +import { SaveCallbackProps } from "./plugins/Toolbar/buttons/save.component"; +import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; + +const styledSystemProps = generateStyledSystemProps({ + margin: true, +}); + +const meta: Meta = { + title: "Rich Text Editor", + component: RichTextEditor, + argTypes: { + ...styledSystemProps, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = () => { + return ; +}; +Default.storyName = "Default"; + +export const Required: Story = () => { + return ; +}; +Required.storyName = "Required"; + +export const Optional: Story = () => { + return ; +}; +Optional.storyName = "Optional"; + +export const CharacterLimit: Story = () => { + return ; +}; +CharacterLimit.storyName = "Character Limit"; + +export const CommandButtons: Story = () => { + return ( + alert("Cancelled")} + onSave={(values) => alert(values)} + /> + ); +}; +CommandButtons.storyName = "Command Buttons"; + +export const OnChange: Story = () => { + const [state, setState] = React.useState(undefined); + return ( + <> + +
    Content: {state || "No content"}
    + + ); +}; +OnChange.storyName = "onChange Handler"; + +export const OnChangeDebounced: Story = () => { + const [state, setState] = React.useState(undefined); + const debounceWaitTime = 2000; + + const handleChange = useDebounce((newValue) => { + setState(newValue); + }, debounceWaitTime); + + return ( + <> + +
    Content: {state || "No content"}
    + + ); +}; +OnChangeDebounced.storyName = "onChange Handler with Debounce"; + +export const OnSave: Story = () => { + const [data, setData] = useState({ + htmlString: "


    ", + json: undefined, + }); + const [showData, setShowData] = useState(false); + return ( + <> + <> + setData({ htmlString, json })} + /> + + + {showData && ( + + + + HTML + + {data?.htmlString || "No content"} + + + + JSON + + {JSON.stringify(data?.json, null, 2) || "No content"} + + + )} + + ); +}; +OnSave.storyName = "onSave Handler"; + +export const ResetToDefault: Story = () => { + const initialValue = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Make changes to this text and then press the ", + type: "text", + version: 1, + }, + { + detail: 0, + format: 1, + mode: "normal", + style: "", + text: "Cancel", + type: "text", + version: 1, + }, + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: " button to reset it to this default state.", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + textFormat: 0, + textStyle: "", + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + }; + const value = JSON.stringify(initialValue); + return ( + {}} + resetOnCancel + /> + ); +}; +ResetToDefault.storyName = "Resetting On Cancel"; + +export const WithError: Story = () => { + return ( + + ); +}; +WithError.storyName = "Error"; + +export const WithWarning: Story = () => { + return ( + + ); +}; +WithWarning.storyName = "Warning"; + +export const WithHTMLValue: Story = () => { + const initialValue = `

    This is a HTML example.

    1. Look, it has lists!
    `; + const value = CreateFromHTML(initialValue); + return ; +}; +WithHTMLValue.storyName = "HTML As Initial Value"; + +export const WithJSONValue: Story = () => { + const initialValue = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Sample text with ", + type: "text", + version: 1, + }, + { + detail: 0, + format: 1, + mode: "normal", + style: "", + text: "some formatting", + type: "text", + version: 1, + }, + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: " ", + type: "text", + version: 1, + }, + { + detail: 0, + format: 2, + mode: "normal", + style: "", + text: "applied", + type: "text", + version: 1, + }, + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: ".", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + textFormat: 0, + textStyle: "", + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + }; + const value = JSON.stringify(initialValue); + return ; +}; +WithJSONValue.storyName = "JSON As Initial Value"; + +export const InputHint: Story = () => { + return ( + + ); +}; +InputHint.storyName = "Input Hint"; + +export const Rows: Story = () => { + return ; +}; +Rows.storyName = "Row Count"; + +export const WithPlaceholder: Story = () => { + return ( + + ); +}; +WithPlaceholder.storyName = "Placeholder"; + +export const WithCustomTranslations: Story = () => { + return ( + "Make text bold", + cancelButton: () => "No", + cancelButtonAria: () => "Cancel the current content", + characterCounter: (count: number) => + `You've got ${count} characters left`, + characterLimit: (count: number) => + `Please delete the last ${count} characters`, + contentEditorAria: () => "Rich text content editor", + italicAria: () => "Make text italic", + orderedListAria: () => "Ordered list", + saveButton: () => "Yes", + saveButtonAria: () => "Save the current content", + unorderedListAria: () => "Unordered list", + }, + }} + > + {}} + onSave={() => {}} + /> + + ); +}; +WithCustomTranslations.storyName = "Translations"; + +export const Links: Story = () => { + const defaultHTML = `Carbon`; + const value = CreateFromHTML(defaultHTML); + return ; +}; +Links.storyName = "Link Support"; + +export const WithLinkAddedCallback: Story = () => { + const [options, setOptions] = useState<{ url: string; state: string }>({ + url: "", + state: "", + }); + + const handleLinkAdded = useCallback((link, state) => { + setOptions({ url: link, state }); + }, []); + + return ( + <> + + + Link: {options.url || "No link added"} +
    + Mutation: {options.state || "None"} +
    + + ); +}; +WithLinkAddedCallback.storyName = "Link Added Callback"; + +export const WithLinkPreviews: Story = () => { + const initialValue = `

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc nisi ipsum, facilisis ut luctus non, gravida in orci. Aliquam risus massa, consequat non facilisis vel, bibendum quis nunc. Cras sit amet velit vel libero molestie accumsan. Integer id ipsum nec nunc porta bibendum. Aenean ut porta risus, eget dignissim felis. Praesent vitae tempus ante. Mauris nibh risus, congue ac augue ac, congue auctor metus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas vitae enim arcu. Integer quis mattis nunc, in porta neque. Proin sit amet purus congue, faucibus mauris id, consectetur justo. Vestibulum odio nisi, vehicula at odio ut, dapibus scelerisque tortor. Etiam vulputate massa orci, porttitor sollicitudin odio sollicitudin vitae. Mauris et eleifend dolor. Curabitur luctus lacinia sagittis. Interdum et malesuada fames ac ante ipsum primis in faucibus.

    `; + const value = CreateFromHTML(initialValue); + const previews = [ + + Carbon + , + ]; + + return ( + <> + + + ); +}; +WithLinkPreviews.storyName = "Link Previews"; + +export const WithComplexLinkPreviews: Story = () => { + const initialValue = `

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc nisi ipsum, facilisis ut luctus non, gravida in orci. Aliquam risus massa, consequat non facilisis vel, bibendum quis nunc. Cras sit amet velit vel libero molestie accumsan. Integer id ipsum nec nunc porta bibendum. Aenean ut porta risus, eget dignissim felis. Praesent vitae tempus ante. Mauris nibh risus, congue ac augue ac, congue auctor metus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas vitae enim arcu. Integer quis mattis nunc, in porta neque. Proin sit amet purus congue, faucibus mauris id, consectetur justo. Vestibulum odio nisi, vehicula at odio ut, dapibus scelerisque tortor. Etiam vulputate massa orci, porttitor sollicitudin odio sollicitudin vitae. Mauris et eleifend dolor. Curabitur luctus lacinia sagittis. Interdum et malesuada fames ac ante ipsum primis in faucibus.

    `; + const value = CreateFromHTML(initialValue); + + const firstRender = useRef(false); + const previews = useRef([]); + const removeUrl = (reportedUrl: string | undefined) => { + previews.current = previews.current.filter( + (preview) => reportedUrl !== preview.props.url, + ); + }; + + if (!firstRender.current) { + firstRender.current = true; + previews.current.push( + removeUrl(urlString)} + title="Han Shot First" + url="https://en.wikipedia.org/wiki/Han_shot_first" + description="Had a slight weapons malfunction but, uh everything's perfectly all right now. We're fine. We're all fine here now. Thank you. How are you?" + key="key - 1" + />, + ); + } + + return ( + <> + + + ); +}; +WithComplexLinkPreviews.storyName = "Complex Link Previews"; + +export const WithMultipleLinkPreviews: Story = () => { + const initialValue = `

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc nisi ipsum, facilisis ut luctus non, gravida in orci. Aliquam risus massa, consequat non facilisis vel, bibendum quis nunc. Cras sit amet velit vel libero molestie accumsan. Integer id ipsum nec nunc porta bibendum. Aenean ut porta risus, eget dignissim felis. Praesent vitae tempus ante. Mauris nibh risus, congue ac augue ac, congue auctor metus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas vitae enim arcu. Integer quis mattis nunc, in porta neque. Proin sit amet purus congue, faucibus mauris id, consectetur justo. Vestibulum odio nisi, vehicula at odio ut, dapibus scelerisque tortor. Etiam vulputate massa orci, porttitor sollicitudin odio sollicitudin vitae. Mauris et eleifend dolor. Curabitur luctus lacinia sagittis. Interdum et malesuada fames ac ante ipsum primis in faucibus.

    `; + const value = CreateFromHTML(initialValue); + + const firstRender = useRef(false); + const previews = useRef([]); + const removeUrl = (reportedUrl: string | undefined) => { + previews.current = previews.current.filter( + (preview) => reportedUrl !== preview.props.url, + ); + }; + + if (!firstRender.current) { + firstRender.current = true; + previews.current.push( + removeUrl(urlString)} + title="Han Shot First" + url="https://en.wikipedia.org/wiki/Han_shot_first" + description="Had a slight weapons malfunction but, uh everything's perfectly all right now. We're fine. We're all fine here now. Thank you. How are you?" + key="key - 1" + />, + ); + previews.current.push( + + Carbon + , + ); + } + + return ( + <> + + + ); +}; +WithMultipleLinkPreviews.storyName = "Multiple Link Previews"; + +export const ReadOnly: Story = () => { + const initialValue = `

    This is a HTML example.

    `; + const value = CreateFromHTML(initialValue); + return ; +}; +ReadOnly.storyName = "Read-Only Mode"; diff --git a/src/components/rich-text-editor/rich-text-editor.style.ts b/src/components/rich-text-editor/rich-text-editor.style.ts new file mode 100644 index 0000000000..157905091f --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.style.ts @@ -0,0 +1,91 @@ +import styled, { css } from "styled-components"; + +import Box from "../box"; +import addFocusStyling from "../../style/utils/add-focus-styling"; + +interface StyledWrapperProps { + error?: string; + namespace: string; + warning?: string; +} + +interface StyledValidationMessageProps { + error?: string; +} + +interface StyledEditorToolbarWrapperProps { + focused?: boolean; +} + +export const StyledRichTextEditor = styled(Box)` + position: relative; +`; + +export const StyledHintText = styled.div` + ::after { + content: " "; + } + + margin-top: var(--spacing000); + margin-bottom: var(--spacing150); + color: var(--colorsUtilityYin055); + font-size: 14px; +`; + +export const StyledWrapper = styled.div` + ${({ error, namespace, warning }) => css` + min-height: 120px; + min-width: 300px; + + ${(error || warning) && + css` + padding-left: 8px; + border-left: 2px solid + var( + ${error + ? "--colorsSemanticNegative500" + : "--colorsSemanticCaution500"} + ); + + #${namespace}-editor-toolbar-wrapper { + border-radius: var(--borderRadius100); + border: 2px solid + var( + ${error + ? "--colorsSemanticNegative500" + : "--colorsSemanticCaution500"} + ); + + .${namespace}-editable, #${namespace}-toolbar { + outline: none; + } + + #${namespace}-toolbar { + border-top: 1px solid var(--colorsUtilityMajor200); + } + } + `} + `}; +`; + +export const StyledEditorToolbarWrapper = styled.div` + ${({ focused }) => css` + border-radius: var(--borderRadius100); + outline: none; + + ${focused && addFocusStyling()} + `} +`; + +export const StyledValidationMessage = styled.div` + ${({ error }) => css` + color: var( + ${error ? "--colorsSemanticNegative500" : "--colorsSemanticCaution600"} + ); + font-weight: ${error ? 500 : "normal"}; + margin-top: 0px; + margin-bottom: 8px; + `} +`; + +export default StyledRichTextEditor; diff --git a/src/components/rich-text-editor/rich-text-editor.test.tsx b/src/components/rich-text-editor/rich-text-editor.test.tsx new file mode 100644 index 0000000000..71b9c4434a --- /dev/null +++ b/src/components/rich-text-editor/rich-text-editor.test.tsx @@ -0,0 +1,377 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import RichTextEditor, { CreateFromHTML } from "."; +import { componentPrefix } from "./constants"; + +/** + * Mock the OnChangePlugin whilst testing the full editor. This is to prevent + * the editor from attempting to repeatedly create update listeners when the + * tests are run, which causes errors to be thrown by Jest. + * + * The onChange prop is tested in the OnChangePlugin tests. + */ +jest.mock("./plugins/OnChange/on-change.plugin", () => { + return jest.fn().mockReturnValue(null); +}); + +// Reusable JSON object for testing the default state +const initialValue = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Sample text", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + textFormat: 0, + textStyle: "", + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, +}; + +test("rendering and basic functionality", async () => { + const user = userEvent.setup(); + const mockCancel = jest.fn(); + const mockSave = jest.fn(); + const value = JSON.stringify(initialValue); + + // render the RichTextEditor component + render( + mockCancel()} + onSave={() => mockSave()} + value={value} + />, + ); + + // expect the editor to be rendered with the default value + expect(screen.getByText("Sample text")).toBeInTheDocument(); + + // Click the editor space and send a few key presses + const editor = screen.getByRole(`textbox`); + await user.click(editor); + await user.keyboard(" abc"); + + // expect the edited value to be visible + expect(screen.getByText("Sample text abc")).toBeInTheDocument(); + + // expect the label to be rendered + expect(screen.getByText("Example")).toBeInTheDocument(); + + // expect the toolbar to be rendered + expect(screen.getByTestId(`${componentPrefix}-toolbar`)).toBeInTheDocument(); + + // expect the character counter to be rendered + const characterCounter = screen.getByTestId( + `${componentPrefix}-character-limit`, + ); + expect(characterCounter).toBeInTheDocument(); + expect(characterCounter).toHaveTextContent("3000 characters remaining"); + + // get both command buttons + const cancelButton = screen.getByText("Cancel"); + const saveButton = screen.getByText("Save"); + + // click each button and expect the respective callback to be called + await user.click(saveButton); + expect(mockSave).toHaveBeenCalledTimes(1); + await user.click(cancelButton); + expect(mockCancel).toHaveBeenCalledTimes(1); + + // highlight all of the text and click the bold button + await user.tripleClick(editor); + const boldButton = screen.getByTestId(`${componentPrefix}-bold-button`); + await user.click(boldButton); + + // expect the text to be bold + expect(screen.getByText("Sample text abc")).toHaveStyle("font-weight: bold"); + + // click the bold button again and expect the text to be normal + await user.click(boldButton); + expect(screen.getByText("Sample text abc")).not.toHaveStyle( + "font-weight: bold", + ); + + // click the italic button + const italicButton = screen.getByTestId(`${componentPrefix}-italic-button`); + await user.click(italicButton); + + // expect the text to be italic + expect(screen.getByText("Sample text abc")).toHaveStyle("font-style: italic"); + + // click the italic button again and expect the text to be normal + await user.click(italicButton); + expect(screen.getByText("Sample text abc")).not.toHaveStyle( + "font-style: italic", + ); + + // click the ordered list button + const olButton = screen.getByTestId(`${componentPrefix}-ordered-list-button`); + await user.click(olButton); + + // Variable to store list for checks + let list; + + // expect the text to be in an ordered list + list = screen.getByRole("list"); + expect(list).toBeInstanceOf(HTMLOListElement); + expect(within(list).getByText("Sample text abc")).toBeInTheDocument(); + + // click the ordered list button again and expect the text to be normal + await user.click(olButton); + expect(list).not.toBeInTheDocument(); + + // click the unordered list button + const ulButton = screen.getByTestId( + `${componentPrefix}-unordered-list-button`, + ); + await user.click(ulButton); + + // expect the text to be in an unordered list + list = screen.getByRole("list"); + expect(list).toBeInstanceOf(HTMLUListElement); + expect(within(list).getByText("Sample text abc")).toBeInTheDocument(); + + // click the unordered list button again and expect the text to be normal + await user.click(ulButton); + expect(list).not.toBeInTheDocument(); +}); + +test("input hint renders correctly when inputHint prop is provided", () => { + // render the RichTextEditor component with an input hint + render( + , + ); + + // expect the input hint to be rendered + expect(screen.getByText("This is an input hint")).toBeInTheDocument(); +}); + +test("character limit renders correctly when characterLimit prop is provided", () => { + // render the RichTextEditor component with a character limit + render(); + + // expect the character counter to be rendered + const characterCounter = screen.getByTestId( + `${componentPrefix}-character-limit`, + ); + expect(characterCounter).toBeInTheDocument(); + expect(characterCounter).toHaveTextContent("100 characters remaining"); +}); + +test("character limit is not rendered when characterLimit prop is provided with a value of 0", () => { + // render the RichTextEditor component with a character limit + render(); + + // expect the character counter to be rendered + const characterCounter = screen.queryByTestId( + `${componentPrefix}-character-limit`, + ); + expect(characterCounter).not.toBeInTheDocument(); +}); + +test("required prop renders correctly when required prop is provided", () => { + // render the RichTextEditor component with the required prop + render(); + + const label = screen.getByText("Example"); + // expect the required indicator to be rendered + expect(label).toHaveStyleRule("content", '"*"', { + modifier: "::after", + }); +}); + +test("optional prop renders correctly when optional prop is provided", () => { + // render the RichTextEditor component with the optional prop + render(); + + const label = screen.getByTestId("label-container"); + + // expect the optional indicator to be rendered + expect(label).toHaveStyleRule("content", '"(optional)"', { + modifier: "::after", + }); +}); + +test("placeholder prop renders correctly when placeholder prop is provided", () => { + // render the RichTextEditor component with a placeholder + render( + , + ); + + // expect the placeholder to be rendered + expect(screen.getByText("This is a nice placeholder")).toBeInTheDocument(); +}); + +test("rows prop renders correctly when rows prop is provided", () => { + // render the RichTextEditor component with a rows prop + render(); + + // expect the editor to have the correct number of rows + const editor = screen.getByTestId(`${componentPrefix}-editable`); + expect(editor).toHaveStyle("min-height: 420px"); +}); + +test("validation renders correctly when error prop is provided", () => { + // render the RichTextEditor component with an error + render(); + + const errorMessage = screen.getByText("This is an error"); + + // expect the error message to be rendered + expect(errorMessage).toBeInTheDocument(); + expect(errorMessage).toHaveStyle("color: var(--colorsSemanticNegative500)"); +}); + +test("validation renders correctly when warning prop is provided", () => { + // render the RichTextEditor component with an error + render(); + + const warningMessage = screen.getByText("This is a warning"); + + // expect the warning message to be rendered + expect(warningMessage).toBeInTheDocument(); + expect(warningMessage).toHaveStyle("color: var(--colorsSemanticCaution500)"); +}); + +test("serialisation of editor", async () => { + const user = userEvent.setup(); + const mockSave = jest.fn(); + render( + mockSave(values)} + value={JSON.stringify(initialValue)} + />, + ); + + const saveButton = screen.getByText("Save"); + + // click the save button and expect the callback to be called + await user.click(saveButton); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockSave.mock.calls[0][0]).toMatchSnapshot(); +}); + +test("valid data is parsed when HTML is passed into the CreateFromHTML function", async () => { + const html = `

    This is a HTML example.

    1. Look, it has lists!
    `; + const value = CreateFromHTML(html); + expect(value).toMatchSnapshot(); +}); + +test("previews are rendered correctly if provided", () => { + const previews = [
    Preview 1
    ]; + render(); + + const preview = screen.getByText("Preview 1"); + + // expect the preview to be rendered + expect(preview).toBeInTheDocument(); +}); + +test("no previews are rendered if the prop is not provided", () => { + render(); + + const preview = screen.queryByText("Preview 1"); + + // expect the preview not to be rendered + expect(preview).not.toBeInTheDocument(); +}); + +test("should reset the content to default if resetOnCancel is true", async () => { + const user = userEvent.setup(); + const mockCancel = jest.fn(); + const mockSave = jest.fn(); + const value = JSON.stringify(initialValue); + + // render the RichTextEditor component + render( + mockCancel()} + onSave={() => mockSave()} + resetOnCancel + value={value} + />, + ); + + // Click the editor space and send a few key presses + const editor = screen.getByRole(`textbox`); + await user.click(editor); + await user.keyboard(" abc"); + + // expect the edited value to be visible + expect(screen.getByText("Sample text abc")).toBeInTheDocument(); + + // Click the cancel button + const cancelButton = screen.getByText("Cancel"); + await user.click(cancelButton); + + // expect the editor to be rendered with the default value + expect(screen.getByText("Sample text")).toBeInTheDocument(); +}); + +test("should not reset the content to default if resetOnCancel is false", async () => { + const user = userEvent.setup(); + const mockCancel = jest.fn(); + const mockSave = jest.fn(); + const value = JSON.stringify(initialValue); + + // render the RichTextEditor component + render( + mockCancel()} + onSave={() => mockSave()} + resetOnCancel={false} + value={value} + />, + ); + + // Click the editor space and send a few key presses + const editor = screen.getByRole(`textbox`); + await user.click(editor); + await user.keyboard(" abc"); + + // expect the edited value to be visible + expect(screen.getByText("Sample text abc")).toBeInTheDocument(); + + // Click the cancel button + const cancelButton = screen.getByText("Cancel"); + await user.click(cancelButton); + + // expect the editor to be rendered with the default value + expect(screen.getByText("Sample text abc")).toBeInTheDocument(); +}); + +test("readOnly prop renders correctly when readOnly prop is provided", () => { + // render the RichTextEditor component with the readOnly prop + render(); + + // expect the editor to be read-only + const editor = screen.getByTestId(`${componentPrefix}-editable`); + expect(editor).toHaveAttribute("contenteditable", "false"); +}); diff --git a/src/components/text-editor/__internal__/decorators/index.ts b/src/components/text-editor/__internal__/decorators/index.ts deleted file mode 100644 index 8eb35a4563..0000000000 --- a/src/components/text-editor/__internal__/decorators/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { CompositeDecorator } from "draft-js"; -import linkDecorator from "./link-decorator"; - -export default new CompositeDecorator([linkDecorator]); diff --git a/src/components/text-editor/__internal__/decorators/link-decorator.ts b/src/components/text-editor/__internal__/decorators/link-decorator.ts deleted file mode 100644 index 2ace79d307..0000000000 --- a/src/components/text-editor/__internal__/decorators/link-decorator.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ContentBlock } from "draft-js"; -import EditorLink from "../editor-link"; - -type StrategyCallback = (start: number, end: number) => void; - -function findWithRegex( - regex: RegExp, - contentBlock: ContentBlock, - callback: StrategyCallback, -) { - const text = contentBlock.getText(); - let matchArr; - let start = 0; - let candidates: string[][] = []; - - text.split(" ").forEach((chars) => { - candidates = [...candidates, [...chars]]; - }); - - candidates.forEach((candidate) => { - // eslint-disable-next-line no-cond-assign - while ((matchArr = regex.exec(candidate.join(""))) !== null) { - start += matchArr.index; - callback(start, start + candidate.length); - } - start += candidate.length + 1; - }); -} - -const linkStrategy = ( - contentBlock: ContentBlock, - callback: StrategyCallback, -) => { - const combineRegex = (...regex: RegExp[]) => - new RegExp(regex.map((r) => r.source).join(""), "g"); - const urlRegex = combineRegex( - /\b/, - /(http:\/\/|https:\/\/|www\.)/, // prefix - /([\w-]+:([\w-]+@))?/, // userinfo - /([\w-]+\.)+\w+/, // domain - /(:\d+)?/, // port - /(\/[\w#!:.?+=&%@!-/]+)?/, // paths, queries, fragments - /\b/, - ); - - findWithRegex(urlRegex, contentBlock, callback); -}; - -export default { - strategy: linkStrategy, - component: EditorLink, -}; diff --git a/src/components/text-editor/__internal__/editor-link/editor-link.component.tsx b/src/components/text-editor/__internal__/editor-link/editor-link.component.tsx deleted file mode 100644 index 5e36cd8477..0000000000 --- a/src/components/text-editor/__internal__/editor-link/editor-link.component.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useContext, useEffect } from "react"; -import { ContentState } from "draft-js"; -import StyledLink from "./editor-link.style"; -import EditorContext from "../../__internal__/editor.context"; - -export interface EditorLinkProps { - children: React.ReactElement[]; - contentState?: ContentState; - entityKey?: string; -} - -const EditorLink = ({ - children, - contentState, - entityKey, - ...rest -}: EditorLinkProps) => { - const url = - !!contentState && !!entityKey - ? contentState.getEntity(entityKey).getData() - : children[0].props.text; - - const buildValidUrl = () => { - const candidateUrl = url.url || url; - const regex = /(http:\/\/|https:\/\/)+/g; - - return regex.test(candidateUrl) ? candidateUrl : `https://${candidateUrl}`; - }; - - const validUrl = buildValidUrl(); - - const { onLinkAdded, editMode } = useContext(EditorContext); - - useEffect(() => { - if (onLinkAdded) { - onLinkAdded(validUrl); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [validUrl]); - - return ( - - {children} - - ); -}; - -export default EditorLink; diff --git a/src/components/text-editor/__internal__/editor-link/editor-link.style.ts b/src/components/text-editor/__internal__/editor-link/editor-link.style.ts deleted file mode 100644 index 0784cc7c52..0000000000 --- a/src/components/text-editor/__internal__/editor-link/editor-link.style.ts +++ /dev/null @@ -1,15 +0,0 @@ -import styled from "styled-components"; -import Link from "../../../link"; - -const StyledEditorLink = styled(Link)` - max-width: 100%; - - a { - max-width: 100% span span span { - font-weight: 500; - font-style: normal; - } - } -`; - -export default StyledEditorLink; diff --git a/src/components/text-editor/__internal__/editor-link/editor-link.test.tsx b/src/components/text-editor/__internal__/editor-link/editor-link.test.tsx deleted file mode 100644 index 394a89d859..0000000000 --- a/src/components/text-editor/__internal__/editor-link/editor-link.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { ContentState } from "draft-js"; -import EditorLink from "./editor-link.component"; -import EditorContext from "../editor.context"; - -test("derives the url from the`contentState` and `entityKey`", () => { - render( - - ({ getData: () => entityKey }), - } as ContentState - } - > -
    foo
    -
    bar
    -
    -
    , - ); - - const link = screen.getByRole("link"); - expect(link).toHaveAttribute("href", "https://baz"); - expect(link).toHaveTextContent("foobar"); -}); - -test("derives the url from the first child element when no `contentState` is provided", () => { - const ChildComponent = ({ text }: { text: string }) =>
    {text}
    ; - render( - - - - - - , - ); - - const link = screen.getByRole("link"); - expect(link).toHaveAttribute("href", "https://foo"); - expect(link).toHaveTextContent("foobar"); -}); - -test("derives the url from the first child element when no `entityKey` is provided", () => { - const ChildComponent = ({ text }: { text: string }) =>
    {text}
    ; - render( - - ({ getData: () => entityKey }), - } as ContentState - } - > - - - - , - ); - - const link = screen.getByRole("link"); - expect(link).toHaveAttribute("href", "https://foo"); - expect(link).toHaveTextContent("foobar"); -}); - -test("does not append `https://` to the url when it contains `http://`", () => { - const ChildComponent = ({ text }: { text: string }) =>
    {text}
    ; - render( - - - - - - , - ); - - const link = screen.getByRole("link"); - expect(link).toHaveAttribute("href", "http://foo"); - expect(link).toHaveTextContent("http://foobar"); -}); - -test("the `onLinkAdded` callback prop is called with the computed url when the component mounts", () => { - const onLinkAdded = jest.fn(); - const ChildComponent = ({ text }: { text: string }) =>
    {text}
    ; - render( - - - - - - , - ); - - expect(onLinkAdded).toHaveBeenCalledWith("https://foo"); - expect(onLinkAdded).toHaveBeenCalledTimes(1); -}); - -test("when the `editMode` prop is `true`, the anchor element should not have a `href` prop", () => { - const ChildComponent = ({ text }: { text: string }) =>
    {text}
    ; - render( - - - - - - , - ); - - expect(screen.queryByRole("link")).not.toBeInTheDocument(); -}); diff --git a/src/components/text-editor/__internal__/editor-link/index.ts b/src/components/text-editor/__internal__/editor-link/index.ts deleted file mode 100644 index 09cde5930f..0000000000 --- a/src/components/text-editor/__internal__/editor-link/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./editor-link.component"; -export type { EditorLinkProps } from "./editor-link.component"; diff --git a/src/components/text-editor/__internal__/editor-validation-wrapper/editor-validation-wrapper.component.tsx b/src/components/text-editor/__internal__/editor-validation-wrapper/editor-validation-wrapper.component.tsx deleted file mode 100644 index d771b38c08..0000000000 --- a/src/components/text-editor/__internal__/editor-validation-wrapper/editor-validation-wrapper.component.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; - -import StyledValidationWrapper from "./editor-validation-wrapper.style"; -import ValidationIcon from "../../../../__internal__/validations"; - -export interface EditorValidationWrapperProps { - /** Message to be displayed when there is an error */ - error?: string; - /** Message to be displayed when there is a warning */ - warning?: string; - /** Message to be displayed when there is an info */ - info?: string; -} - -const ValidationWrapper = ({ - error, - warning, - info, -}: EditorValidationWrapperProps) => ( - - - -); - -export default ValidationWrapper; diff --git a/src/components/text-editor/__internal__/editor-validation-wrapper/editor-validation-wrapper.style.ts b/src/components/text-editor/__internal__/editor-validation-wrapper/editor-validation-wrapper.style.ts deleted file mode 100644 index 043876c5d4..0000000000 --- a/src/components/text-editor/__internal__/editor-validation-wrapper/editor-validation-wrapper.style.ts +++ /dev/null @@ -1,13 +0,0 @@ -import styled from "styled-components"; - -const StyledValidationWrapper = styled.div` - margin: var(--spacing200) var(--spacing200) var(--spacing000) - var(--spacing050); - min-width: var(--sizing500); - height: var(--sizing275); - display: flex; - float: right; - align-items: center; -`; - -export default StyledValidationWrapper; diff --git a/src/components/text-editor/__internal__/editor-validation-wrapper/index.ts b/src/components/text-editor/__internal__/editor-validation-wrapper/index.ts deleted file mode 100644 index c700373c38..0000000000 --- a/src/components/text-editor/__internal__/editor-validation-wrapper/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./editor-validation-wrapper.component"; -export type { EditorValidationWrapperProps } from "./editor-validation-wrapper.component"; diff --git a/src/components/text-editor/__internal__/editor.context.ts b/src/components/text-editor/__internal__/editor.context.ts deleted file mode 100644 index 29bf922921..0000000000 --- a/src/components/text-editor/__internal__/editor.context.ts +++ /dev/null @@ -1,6 +0,0 @@ -import React from "react"; - -export default React.createContext<{ - onLinkAdded?: (url: string) => void; - editMode?: boolean; -}>({}); diff --git a/src/components/text-editor/__internal__/label-wrapper/index.ts b/src/components/text-editor/__internal__/label-wrapper/index.ts deleted file mode 100644 index 364c1f493b..0000000000 --- a/src/components/text-editor/__internal__/label-wrapper/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./label-wrapper.component"; -export type { LabelWrapperProps } from "./label-wrapper.component"; diff --git a/src/components/text-editor/__internal__/label-wrapper/label-wrapper.component.tsx b/src/components/text-editor/__internal__/label-wrapper/label-wrapper.component.tsx deleted file mode 100644 index 0bde5ae2f3..0000000000 --- a/src/components/text-editor/__internal__/label-wrapper/label-wrapper.component.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import React from "react"; -/** - * TextEditor component is composed with divs and spans. - * We have to manually trigger focus on TextEditor by clicking on label component. - * This wrapper allows us to trigger focus on TextEditor - */ - -export interface LabelWrapperProps { - children: React.ReactNode; - onClick: (event: React.MouseEvent) => void; -} - -const LabelWrapper = ({ onClick, children }: LabelWrapperProps) => { - return {children} ; -}; - -export default LabelWrapper; diff --git a/src/components/text-editor/__internal__/label-wrapper/label.test.tsx b/src/components/text-editor/__internal__/label-wrapper/label.test.tsx deleted file mode 100644 index 4f0ac5f7ed..0000000000 --- a/src/components/text-editor/__internal__/label-wrapper/label.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import Label from "../../../../__internal__/label"; -import LabelWrapper from "./label-wrapper.component"; - -test("should render children", () => { - render( - {}}> - - , - ); - - expect(screen.getByText("Test Children")).toBeVisible(); -}); - -test("should call the `onClick` function prop when clicked", async () => { - const user = userEvent.setup(); - const onClick = jest.fn(); - render( - - - , - ); - expect(onClick).not.toHaveBeenCalled(); - - await user.click(screen.getByText("Test Children")); - expect(onClick).toHaveBeenCalledTimes(1); -}); diff --git a/src/components/text-editor/__internal__/toolbar/index.ts b/src/components/text-editor/__internal__/toolbar/index.ts deleted file mode 100644 index 919496d85b..0000000000 --- a/src/components/text-editor/__internal__/toolbar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./toolbar.component"; -export type { ToolbarProps } from "./toolbar.component"; diff --git a/src/components/text-editor/__internal__/toolbar/toolbar-button/index.ts b/src/components/text-editor/__internal__/toolbar/toolbar-button/index.ts deleted file mode 100644 index 3b02a3fcb0..0000000000 --- a/src/components/text-editor/__internal__/toolbar/toolbar-button/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./toolbar-button.component"; -export type { ToolbarButtonProps } from "./toolbar-button.component"; diff --git a/src/components/text-editor/__internal__/toolbar/toolbar-button/toolbar-button.component.tsx b/src/components/text-editor/__internal__/toolbar/toolbar-button/toolbar-button.component.tsx deleted file mode 100644 index 1885d8c76c..0000000000 --- a/src/components/text-editor/__internal__/toolbar/toolbar-button/toolbar-button.component.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from "react"; -import StyledToolbarButton from "./toolbar-button.style"; - -export interface ToolbarButtonProps { - /** Accessibility label for a button */ - ariaLabel: string; - /** The children for the button */ - children: React.ReactNode; - /** Used to control the button's active status */ - activated?: boolean; - /** Callback to handle any keydown events on a button */ - onKeyDown: (ev: React.KeyboardEvent) => void; - /** Callback to handle any mousedown events on a button */ - onMouseDown: (ev: React.MouseEvent) => void; - /** Callback to handle any mouseover events on a button */ - onMouseOver?: (ev: React.MouseEvent) => void; - /** Callback to handle any mouseleave events on a button */ - onMouseLeave?: (ev: React.MouseEvent) => void; - /** Callback to handle any focus events on a button */ - onFocus?: (ev: React.FocusEvent) => void; - /** Callback to handle any blur events on a button */ - onBlur?: (ev: React.FocusEvent) => void; - /** Controls whether the button can be tabbed to */ - tabbable?: boolean; -} - -export const ToolbarButton = React.forwardRef< - HTMLButtonElement, - ToolbarButtonProps ->( - ( - { - onKeyDown, - onMouseDown, - activated, - ariaLabel, - tabbable, - children, - onMouseOver, - onMouseLeave, - onFocus, - onBlur, - }: ToolbarButtonProps, - ref, - ) => { - return ( - - {children} - - ); - }, -); - -ToolbarButton.displayName = "ToolbarButton"; - -export default ToolbarButton; diff --git a/src/components/text-editor/__internal__/toolbar/toolbar-button/toolbar-button.style.ts b/src/components/text-editor/__internal__/toolbar/toolbar-button/toolbar-button.style.ts deleted file mode 100644 index 64d88e1acf..0000000000 --- a/src/components/text-editor/__internal__/toolbar/toolbar-button/toolbar-button.style.ts +++ /dev/null @@ -1,38 +0,0 @@ -import styled, { css } from "styled-components"; -import { baseTheme } from "../../../../../style/themes"; -import addFocusStyling from "../../../../../style/utils/add-focus-styling"; - -const StyledToolbarButton = styled.button.attrs({ type: "button" })<{ - isActive?: boolean; -}>` - display: inline-flex; - justify-content: center; - align-items: center; - padding: 6px; - background-color: inherit; - border-radius: var(--borderRadius100); - border: none; - cursor: pointer; - - ${({ isActive }) => css` - :focus, - :active { - z-index: 1; - position: relative; - ${addFocusStyling()} - } - - :hover { - background-color: ${!isActive && "var(--colorsActionMinor200)"}; - } - - ${isActive && - css` - background-color: var(--colorsActionMinor600); - `} - `} -`; - -StyledToolbarButton.defaultProps = { theme: baseTheme }; - -export default StyledToolbarButton; diff --git a/src/components/text-editor/__internal__/toolbar/toolbar.component.tsx b/src/components/text-editor/__internal__/toolbar/toolbar.component.tsx deleted file mode 100644 index ff1f73aeb2..0000000000 --- a/src/components/text-editor/__internal__/toolbar/toolbar.component.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { useCallback, useEffect, useState, useRef } from "react"; -import { - StyledToolbar, - StyledEditorStyleControls, - StyledEditorActionControls, -} from "./toolbar.style"; -import ToolbarButton from "./toolbar-button"; -import Events from "../../../../__internal__/utils/helpers/events"; -import Icon from "../../../icon"; -import Tooltip from "../../../tooltip"; -import useLocale from "../../../../hooks/__internal__/useLocale"; -import { BOLD, ITALIC, UNORDERED_LIST, ORDERED_LIST } from "../../types"; -import type { InlineStyleType, BlockType } from "../../types"; - -export interface ToolbarProps { - /** Used to override the active status of the inline controls */ - activeControls: Record; - /** Flag to trigger control focusing */ - canFocus?: boolean; - /** Callback to handle setting the inline styles */ - setInlineStyle: ( - ev: - | React.KeyboardEvent - | React.MouseEvent, - inlineType: InlineStyleType, - ) => void; - /** Callback to handle setting the block styles */ - setBlockStyle: ( - ev: - | React.KeyboardEvent - | React.MouseEvent, - blockType: BlockType, - ) => void; - /** Additional elements to be rendered in the Toolbar, e.g. Save and Cancel Button */ - toolbarElements?: React.ReactNode; -} - -const Toolbar = ({ - activeControls, - canFocus, - toolbarElements, - setBlockStyle, - setInlineStyle, -}: ToolbarProps) => { - const { textEditor } = useLocale(); - const { tooltipMessages, ariaLabels } = textEditor; - const controlRefs = useRef([ - React.createRef(), - React.createRef(), - React.createRef(), - React.createRef(), - ]); - - const [focusIndex, setFocusIndex] = useState(null); - const [tabbable, setTabbable] = useState(true); - const [activeTooltip, setActiveTooltip] = useState(""); - - const handleInlineStyleChange = useCallback( - ( - ev: - | React.MouseEvent - | React.KeyboardEvent, - inlineType: InlineStyleType, - ) => { - setInlineStyle(ev, inlineType); - }, - [setInlineStyle], - ); - - const handleBlockType = useCallback( - ( - ev: - | React.MouseEvent - | React.KeyboardEvent, - blockType: BlockType, - ) => { - setBlockStyle(ev, blockType); - }, - [setBlockStyle], - ); - - const handleKeyDown = useCallback( - ( - ev: React.KeyboardEvent, - type: InlineStyleType | BlockType, - ) => { - if (Events.isTabKey(ev)) { - setFocusIndex(null); - } else if (Events.isSpaceKey(ev) || Events.isEnterKey(ev)) { - if (type === BOLD || type === ITALIC) { - handleInlineStyleChange(ev, type); - } else { - handleBlockType(ev, type); - } - setFocusIndex(0); - setTabbable(true); - } else if (Events.isLeftKey(ev)) { - if (focusIndex === null || focusIndex === 0) { - controlRefs.current[3].current?.focus(); - setFocusIndex(3); - } else { - controlRefs.current[focusIndex - 1].current?.focus(); - setFocusIndex(focusIndex - 1); - } - setTabbable(false); - } else if (Events.isRightKey(ev)) { - if (focusIndex === 3) { - controlRefs.current[0].current?.focus(); - setFocusIndex(0); - } else { - const currentIndex = focusIndex === null ? 0 : focusIndex; - controlRefs.current[currentIndex + 1].current?.focus(); - setFocusIndex(currentIndex + 1); - } - setTabbable(false); - } - }, - [focusIndex, handleBlockType, handleInlineStyleChange], - ); - - useEffect(() => { - if (focusIndex === null) { - setTabbable(true); - } - }, [focusIndex]); - - useEffect(() => { - if (!canFocus) { - setFocusIndex(null); - } - }, [canFocus]); - - const isTabbable = (index: number) => { - if (!controlRefs.current[index] || !controlRefs.current[index].current) { - return false; - } - - return controlRefs.current[index].current === document.activeElement; - }; - - return ( - - - - handleKeyDown(ev, BOLD)} - onMouseDown={(ev) => handleInlineStyleChange(ev, BOLD)} - activated={activeControls.BOLD} - ref={controlRefs.current[0]} - tabbable={tabbable} - onMouseOver={() => setActiveTooltip("Bold")} - onMouseLeave={() => setActiveTooltip("")} - onFocus={() => setActiveTooltip("Bold")} - onBlur={() => setActiveTooltip("")} - > - - - - - handleKeyDown(ev, ITALIC)} - onMouseDown={(ev) => handleInlineStyleChange(ev, ITALIC)} - activated={activeControls.ITALIC} - ref={controlRefs.current[1]} - tabbable={isTabbable(1)} - onMouseOver={() => setActiveTooltip("Italic")} - onMouseLeave={() => setActiveTooltip("")} - onFocus={() => setActiveTooltip("Italic")} - onBlur={() => setActiveTooltip("")} - > - - - - - handleKeyDown(ev, UNORDERED_LIST)} - onMouseDown={(ev) => handleBlockType(ev, UNORDERED_LIST)} - activated={activeControls[UNORDERED_LIST]} - ref={controlRefs.current[2]} - tabbable={isTabbable(2)} - onMouseOver={() => setActiveTooltip("Bulleted List")} - onMouseLeave={() => setActiveTooltip("")} - onFocus={() => setActiveTooltip("Bulleted List")} - onBlur={() => setActiveTooltip("")} - > - - - - - handleKeyDown(ev, ORDERED_LIST)} - onMouseDown={(ev) => handleBlockType(ev, ORDERED_LIST)} - activated={activeControls[ORDERED_LIST]} - ref={controlRefs.current[3]} - tabbable={isTabbable(3)} - onMouseOver={() => setActiveTooltip("Numbered List")} - onMouseLeave={() => setActiveTooltip("")} - onFocus={() => setActiveTooltip("Numbered List")} - onBlur={() => setActiveTooltip("")} - > - - - - - - {toolbarElements && ( - - {toolbarElements} - - )} - - ); -}; - -export default Toolbar; diff --git a/src/components/text-editor/__internal__/toolbar/toolbar.style.ts b/src/components/text-editor/__internal__/toolbar/toolbar.style.ts deleted file mode 100644 index 4856caba60..0000000000 --- a/src/components/text-editor/__internal__/toolbar/toolbar.style.ts +++ /dev/null @@ -1,32 +0,0 @@ -import styled from "styled-components"; - -const StyledToolbar = styled.div` - display: inline-flex; - justify-content: space-between; - flex-flow: row wrap; - gap: 8px; - padding: 12px; - height: fit-content; - width: 100%; - box-sizing: border-box; - border: none; - border-top: 1px solid var(--colorsUtilityMajor200); - background-color: var(--colorsUtilityMajor025); - user-select: none; - z-index: 10; -`; - -const StyledEditorStyleControls = styled.div` - display: inline-flex; - gap: 8px; -`; - -const StyledEditorActionControls = styled.div` - flex-grow: 1; - display: inline-flex; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--spacing200); -`; - -export { StyledToolbar, StyledEditorActionControls, StyledEditorStyleControls }; diff --git a/src/components/text-editor/__internal__/toolbar/toolbar.test.tsx b/src/components/text-editor/__internal__/toolbar/toolbar.test.tsx deleted file mode 100644 index 0ce14083db..0000000000 --- a/src/components/text-editor/__internal__/toolbar/toolbar.test.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import React from "react"; -import { act, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import Toolbar from "./toolbar.component"; -import Button from "../../../button/button.component"; -import I18nProvider from "../../../i18n-provider"; - -// mock Tooltip with a "bare-bones" implementation as using the real component causes tests to flake and timeout when -// doing user actions that trigger tooltips -jest.mock("../../../tooltip", () => - jest.fn(({ children, message, isVisible }) => ( - <> - {children} - {isVisible ?
    {message}
    : null} - - )), -); - -test("when the `canFocus` prop is true, pressing the right arrow key cycles focus through all the buttons from left to right and then wraps back to the first", async () => { - const user = userEvent.setup(); - render( - {}} - setBlockStyle={() => {}} - />, - ); - act(() => { - screen.getByRole("button", { name: "bold" }).focus(); - }); - await user.keyboard("{ArrowRight}"); - expect(screen.getByRole("button", { name: "italic" })).toHaveFocus(); - - await user.keyboard("{ArrowRight}"); - expect(screen.getByRole("button", { name: "bullet-list" })).toHaveFocus(); - - await user.keyboard("{ArrowRight}"); - expect(screen.getByRole("button", { name: "number-list" })).toHaveFocus(); - - await user.keyboard("{ArrowRight}"); - expect(screen.getByRole("button", { name: "bold" })).toHaveFocus(); -}); - -test("when the `canFocus` prop is true, pressing the left arrow key wraps focus back to the last and cycles focus through all the buttons from right to left", async () => { - const user = userEvent.setup(); - render( - {}} - setBlockStyle={() => {}} - />, - ); - act(() => { - screen.getByRole("button", { name: "bold" }).focus(); - }); - await user.keyboard("{ArrowLeft}"); - expect(screen.getByRole("button", { name: "number-list" })).toHaveFocus(); - - await user.keyboard("{ArrowLeft}"); - expect(screen.getByRole("button", { name: "bullet-list" })).toHaveFocus(); - - await user.keyboard("{ArrowLeft}"); - expect(screen.getByRole("button", { name: "italic" })).toHaveFocus(); - - await user.keyboard("{ArrowLeft}"); - expect(screen.getByRole("button", { name: "bold" })).toHaveFocus(); -}); - -test.each(["bold", "italic", "bullet-list", "number-list"])( - "pressing the Tab key when the %s button is focused moves focus to the next focusable element after the toolbar", - async (buttonName) => { - const user = userEvent.setup(); - render( - <> - {}} - setBlockStyle={() => {}} - /> - - , - ); - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - - await user.tab(); - expect( - screen.getByRole("button", { name: "I will receive focus" }), - ).toHaveFocus(); - }, -); - -test.each(["bold", "italic"])( - "calls the `setInlineStyle` callback but not the `setBlockStyle` callback when the %s button is clicked", - async (buttonName) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - await user.click(screen.getByRole("button", { name: buttonName })); - - expect(setInlineStyle).toHaveBeenCalledWith( - expect.anything(), - buttonName.toUpperCase(), - ); - expect(setInlineStyle).toHaveBeenCalledTimes(1); - expect(setBlockStyle).not.toHaveBeenCalled(); - }, -); - -test.each([ - ["bullet-list", "unordered-list-item"], - ["number-list", "ordered-list-item"], -])( - "calls the `setBlockStyle` callback but not the `setInlineStyle` callback when the %s button is clicked", - async (buttonName, blockType) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - await user.click(screen.getByRole("button", { name: buttonName })); - - expect(setBlockStyle).toHaveBeenCalledWith(expect.anything(), blockType); - expect(setBlockStyle).toHaveBeenCalledTimes(1); - expect(setInlineStyle).not.toHaveBeenCalled(); - }, -); - -test.each(["bold", "italic"])( - "calls the `setInlineStyle` callback but not the `setBlockStyle` callback when Enter is pressed with the %s button focused", - async (buttonName) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - await user.keyboard("{Enter}"); - - expect(setInlineStyle).toHaveBeenCalledWith( - expect.anything(), - buttonName.toUpperCase(), - ); - expect(setInlineStyle).toHaveBeenCalledTimes(1); - expect(setBlockStyle).not.toHaveBeenCalled(); - }, -); - -test.each([ - ["bullet-list", "unordered-list-item"], - ["number-list", "ordered-list-item"], -])( - "calls the `setBlockStyle` callback but not the `setInlineStyle` callback when Enter is pressed with the %s button focused", - async (buttonName, blockType) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - await user.keyboard("{Enter}"); - - expect(setBlockStyle).toHaveBeenCalledWith(expect.anything(), blockType); - expect(setBlockStyle).toHaveBeenCalledTimes(1); - expect(setInlineStyle).not.toHaveBeenCalled(); - }, -); - -test.each(["bold", "italic"])( - "calls the `setInlineStyle` callback but not the `setBlockStyle` callback when Space is pressed with the %s button focused", - async (buttonName) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - await user.keyboard(" "); - - expect(setInlineStyle).toHaveBeenCalledWith( - expect.anything(), - buttonName.toUpperCase(), - ); - expect(setInlineStyle).toHaveBeenCalledTimes(1); - expect(setBlockStyle).not.toHaveBeenCalled(); - }, -); - -test.each([ - ["bullet-list", "unordered-list-item"], - ["number-list", "ordered-list-item"], -])( - "calls the `setBlockStyle` callback but not the `setInlineStyle` callback when Space is pressed with the %s button focused", - async (buttonName, blockType) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - await user.keyboard(" "); - - expect(setBlockStyle).toHaveBeenCalledWith(expect.anything(), blockType); - expect(setBlockStyle).toHaveBeenCalledTimes(1); - expect(setInlineStyle).not.toHaveBeenCalled(); - }, -); - -test.each(["bold", "italic", "bullet-list", "number-list"])( - "calls neither `setInlineStyle` nor `setBlockStyle` callback when a key other than Space or Enter is pressed with the %s button focused", - async (buttonName) => { - const user = userEvent.setup(); - const setInlineStyle = jest.fn(); - const setBlockStyle = jest.fn(); - render( - , - ); - - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - await user.keyboard("d"); - - expect(setInlineStyle).not.toHaveBeenCalled(); - expect(setBlockStyle).not.toHaveBeenCalled(); - }, -); - -test.each([ - ["bold", "Bold"], - ["italic", "Italic"], - ["bullet-list", "Bulleted List"], - ["number-list", "Numbered List"], -])( - "when the %s button is hovered over, a tooltip is displayed which is removed on mouse leave", - async (buttonName, tooltipText) => { - const user = userEvent.setup(); - render( - {}} - setBlockStyle={() => {}} - />, - ); - - await user.hover(screen.getByRole("button", { name: buttonName })); - expect( - await screen.findByRole("tooltip", { name: tooltipText }), - ).toBeVisible(); - - await user.unhover(screen.getByRole("button", { name: buttonName })); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }, -); - -test.each([ - ["bold", "Bold"], - ["italic", "Italic"], - ["bullet-list", "Bulleted List"], - ["number-list", "Numbered List"], -])( - "when the %s button is focused a tooltip is displayed which is removed on blur", - async (buttonName, tooltipText) => { - render( - {}} - setBlockStyle={() => {}} - />, - ); - - act(() => { - screen.getByRole("button", { name: buttonName }).focus(); - }); - - expect( - await screen.findByRole("tooltip", { name: tooltipText }), - ).toBeVisible(); - - act(() => { - screen.getByRole("button", { name: buttonName }).blur(); - }); - - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }, -); - -test("additional elements passed via the `toolbarElements` prop are rendered", () => { - render( - {}} - setBlockStyle={() => {}} - toolbarElements={[ - , - , - ]} - />, - ); - - expect( - screen.getByRole("button", { name: "Additional button" }), - ).toBeVisible(); - expect( - screen.getByRole("button", { name: "Yet another button" }), - ).toBeVisible(); -}); - -test.each(["bold", "italic", "bulletList", "numberList"] as const)( - "overrides the tooltip message text and aria label for the %s button based on the `locale` object passed in", - async (localeProperty) => { - const user = userEvent.setup(); - const locale = { - locale: () => "mock-Locale", - textEditor: { - tooltipMessages: { - bold: () => "Foo Bold", - italic: () => "Foo Italic", - bulletList: () => "Foo Bulleted List", - numberList: () => "Foo Numbered List", - }, - ariaLabels: { - bold: () => "foo-bold", - italic: () => "foo-italic", - bulletList: () => "foo-bullet-list", - numberList: () => "foo-number-list", - }, - }, - }; - render( - - {}} - setBlockStyle={() => {}} - /> - , - ); - - const buttonName = locale.textEditor.ariaLabels[localeProperty](); - const tooltipText = locale.textEditor.tooltipMessages[localeProperty](); - await user.hover(screen.getByRole("button", { name: buttonName })); - - expect( - await screen.findByRole("tooltip", { name: tooltipText }), - ).toBeVisible(); - }, -); diff --git a/src/components/text-editor/__internal__/utils/index.ts b/src/components/text-editor/__internal__/utils/index.ts deleted file mode 100644 index 42173b9a3d..0000000000 --- a/src/components/text-editor/__internal__/utils/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { - computeBlockType, - getContent, - getContentInfo, - getDecoratedValue, - getSelection, - getSelectionInfo, - getSelectedLength, - moveSelectionToEnd, - resetBlockType, - isASCIIChar, - replaceText, - hasBlockStyle, - hasInlineStyle, - blockStyleFn, -} from "./utils"; diff --git a/src/components/text-editor/__internal__/utils/utils.test.ts b/src/components/text-editor/__internal__/utils/utils.test.ts deleted file mode 100644 index 18bbe4a66e..0000000000 --- a/src/components/text-editor/__internal__/utils/utils.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ContentBlock, ContentState, EditorState, genKey } from "draft-js"; -import { List } from "immutable"; -import { computeBlockType, resetBlockType, getSelectedLength } from "./utils"; - -describe("computeBlockType", () => { - it('returns "unstyled" as default when char is not "." or "*"', () => { - expect(computeBlockType("@", "ordered-list-item")).toEqual("unstyled"); - }); - - it('returns "unstyled" as default when char is "*" and type is "unordered-list-item"', () => { - expect(computeBlockType("*", "unordered-list-item")).toEqual("unstyled"); - }); - - it('returns "unstyled" as default when char is "." and type is "ordered-list-item"', () => { - expect(computeBlockType(".", "ordered-list-item")).toEqual("unstyled"); - }); - - it('returns "ordered-list-item" as default when char is "." and type is not "ordered-list-item"', () => { - expect(computeBlockType(".", "unordered-list-item")).toEqual( - "ordered-list-item", - ); - }); - - it('returns "unordered-list-item" as default when char is "*" and type is not "unordered-list-item"', () => { - expect(computeBlockType("*", "ordered-list-item")).toEqual( - "unordered-list-item", - ); - }); -}); - -describe("resetBlockType", () => { - it.each(["ordered-list-item", "unordered-list-item"] as const)( - "returns editorState with block replaced with given %s type", - (type) => { - const value = EditorState.createEmpty(); - const blocks = resetBlockType(value, type) - .getCurrentContent() - .getBlocksAsArray(); - expect(blocks.length).toEqual(1); - blocks.forEach((block) => expect(block.getType()).toEqual(type)); - }, - ); - - it("returns editorState with default unstyled block when no type provided", () => { - const value = EditorState.createEmpty(); - const blocks = resetBlockType(value).getCurrentContent().getBlocksAsArray(); - expect(blocks.length).toEqual(1); - blocks.forEach((block) => expect(block.getType()).toEqual("unstyled")); - }); -}); - -describe("getSelectedLength", () => { - const currentSelectionInfo = (value: EditorState) => { - const content = value.getCurrentContent(); - - return { - anchorKey: content.getFirstBlock().getKey(), - focusKey: content.getLastBlock().getKey(), - focusOffset: content.getLastBlock().getText().length, - }; - }; - - const makeBlock = (value: EditorState) => { - const content = value.getCurrentContent(); - - const newBlock = new ContentBlock({ - key: genKey(), - type: "unstyled", - text: "foo", - characterList: List(), - }); - const newBlockMap = content.getBlockMap().set(newBlock.getKey(), newBlock); - - return EditorState.push( - value, - ContentState.createFromBlockArray(newBlockMap.toArray()) - .set("selectionBefore", content.getSelectionBefore()) - .set("selectionAfter", content.getSelectionAfter()) as ContentState, - "insert-fragment", - ); - }; - - it("returns 0 when there is not content", () => { - const editorState = EditorState.createEmpty(); - expect(getSelectedLength(editorState)).toEqual(0); - }); - - it("returns 0 when the selection is collapsed", () => { - const editorState = EditorState.createWithContent( - ContentState.createFromText("foo"), - ); - const { anchorKey, focusKey } = currentSelectionInfo(editorState); - const newValue = EditorState.acceptSelection( - editorState, - editorState.getSelection().merge({ - anchorKey, - anchorOffset: 1, - focusOffset: 1, - focusKey, - isBackward: false, - hasFocus: false, - }), - ); - expect(getSelectedLength(newValue)).toEqual(0); - }); - - it("returns the difference between the anchor and focus offsets if there is only one block of content", () => { - const editorState = EditorState.createWithContent( - ContentState.createFromText("foo"), - ); - const { anchorKey, focusKey } = currentSelectionInfo(editorState); - const newValue = EditorState.acceptSelection( - editorState, - editorState.getSelection().merge({ - anchorKey, - anchorOffset: 0, - focusOffset: 1, - focusKey, - isBackward: false, - hasFocus: false, - }), - ); - expect(getSelectedLength(newValue)).toEqual(1); - }); - - it("returns the difference between the anchor and focus offsets plus any initial content", () => { - const editorState = makeBlock( - EditorState.createWithContent(ContentState.createFromText("foo")), - ); - const { anchorKey, focusKey, focusOffset } = - currentSelectionInfo(editorState); - const newValue = EditorState.acceptSelection( - editorState, - editorState.getSelection().merge({ - anchorKey, - anchorOffset: 0, - focusOffset, - focusKey, - isBackward: false, - hasFocus: false, - }), - ); - expect(getSelectedLength(newValue)).toEqual(7); - }); - - it("returns the difference between the anchor and focus offsets plus the other blocks", () => { - let editorState = makeBlock( - EditorState.createWithContent(ContentState.createFromText("foo")), - ); - editorState = makeBlock(editorState); - const { anchorKey, focusKey, focusOffset } = - currentSelectionInfo(editorState); - - const newValue = EditorState.acceptSelection( - editorState, - editorState.getSelection().merge({ - anchorKey, - anchorOffset: 0, - focusOffset, - focusKey, - isBackward: false, - hasFocus: false, - }), - ); - expect(getSelectedLength(newValue)).toEqual(11); - }); -}); diff --git a/src/components/text-editor/__internal__/utils/utils.ts b/src/components/text-editor/__internal__/utils/utils.ts deleted file mode 100644 index 9ae9f0475d..0000000000 --- a/src/components/text-editor/__internal__/utils/utils.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { - EditorState, - Modifier, - ContentBlock, - DraftInlineStyle, - ContentState, -} from "draft-js"; -import decorators from "../decorators"; - -import { - BlockType, - InlineStyleType, - ORDERED_LIST, - UNORDERED_LIST, -} from "../../types"; - -export const computeBlockType = (char: string, type: string) => { - if (char === "." && type !== ORDERED_LIST) { - return ORDERED_LIST; - } - if (char === "*" && type !== UNORDERED_LIST) { - return UNORDERED_LIST; - } - return "unstyled"; -}; - -/* -Returns default block-level metadata for various block type. Empty object otherwise. -*/ -const getDefaultBlockData = ( - blockType: BlockType | "unstyled", - initialData = {}, -) => { - switch (blockType) { - case ORDERED_LIST: - return {}; - case UNORDERED_LIST: - return {}; - default: - return initialData; - } -}; - -/* -Changes the block type of the current block. -*/ -export const resetBlockType = ( - value: EditorState, - newType: BlockType | "unstyled" = "unstyled", -) => { - const contentState = value.getCurrentContent(); - const selectionState = value.getSelection(); - const key = selectionState.getStartKey(); - const blockMap = contentState.getBlockMap(); - const block = blockMap.get(key); - - const newBlock = block.merge({ - text: "", - type: newType, - data: getDefaultBlockData(newType), - }); - - const newContentState = contentState.merge({ - blockMap: blockMap.set(key, newBlock as ContentBlock), - selectionAfter: selectionState.merge({ - anchorOffset: 0, - focusOffset: 0, - }), - }); - - return EditorState.push( - value, - newContentState as ContentState, - "change-block-type", - ); -}; - -export function blockStyleFn(block: ContentBlock) { - switch (block.getType()) { - case "unordered-list-item": - return "text-editor-block-unordered"; - case "ordered-list-item": - return "text-editor-block-ordered"; - default: - return ""; - } -} - -/* - Return mutated editorState with decorators added -*/ -export const getDecoratedValue = (value: EditorState) => - EditorState.set(value, { decorator: decorators }); - -/* - Get the current Content State -*/ -export const getContent = (value: EditorState) => value.getCurrentContent(); - -/* - Get the current selection State -*/ -export const getSelection = (value: EditorState) => value.getSelection(); - -/* - Get the current Content and Block information -*/ -export const getContentInfo = (value: EditorState) => { - const content = getContent(value); - const currentBlock = content.getBlockForKey( - getSelection(value).getStartKey(), - ); - const blockType = currentBlock.getType(); - const blockLength = currentBlock.getLength(); - const blockText = currentBlock.getText(); - const blockMap = content.getBlockMap(); - - return { - content, - currentBlock, - blockType, - blockLength, - blockText, - blockMap, - }; -}; - -/* - Get the current Selection information -*/ -export const getSelectionInfo = (value: EditorState) => { - const selection = getSelection(value); - const startKey = selection.getStartKey(); - const endKey = selection.getEndKey(); - const startOffset = selection.getStartOffset(); - const endOffset = selection.getEndOffset(); - - return { - selection, - startKey, - endKey, - startOffset, - endOffset, - }; -}; - -/* - Move cursor to end of Content -*/ -export const moveSelectionToEnd = (value: EditorState) => - EditorState.forceSelection(value, getContent(value).getSelectionAfter()); - -/* - Returns the current Selection length -*/ -export const getSelectedLength = (value: EditorState) => { - const selection = getSelection(value); - - let length = 0; - - if (!selection.isCollapsed()) { - const { startKey, endKey, startOffset, endOffset } = - getSelectionInfo(value); - const { content, blockLength } = getContentInfo(value); - const startLength = blockLength - startOffset; - const keyAfterEnd = content.getKeyAfter(endKey); - - if (startKey === endKey) { - length += endOffset - startOffset; - } else { - let currentKey = startKey; - - while (currentKey && currentKey !== keyAfterEnd) { - if (currentKey === startKey) { - length += startLength + 1; - } else if (currentKey === endKey) { - length += endOffset; - } else { - length += content.getBlockForKey(currentKey).getLength() + 1; - } - - currentKey = content.getKeyAfter(currentKey); - } - } - } - - return length; -}; - -export function hasBlockStyle(value: EditorState, type: BlockType) { - const { blockType } = getContentInfo(value); - return blockType === type; -} - -export function hasInlineStyle(value: EditorState, style: InlineStyleType) { - return value.getCurrentInlineStyle().has(style); -} - -export function isASCIIChar(str: string) { - return /^\S+$/.test(str); -} - -export function replaceText( - editorState: EditorState, - text: string, - inlineStyle: DraftInlineStyle, -) { - const contentState = Modifier.replaceText( - editorState.getCurrentContent(), - editorState.getSelection(), - text, - inlineStyle, - ); - - return EditorState.push(editorState, contentState, "insert-characters"); -} diff --git a/src/components/text-editor/components.test-pw.tsx b/src/components/text-editor/components.test-pw.tsx deleted file mode 100644 index adfad18821..0000000000 --- a/src/components/text-editor/components.test-pw.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, { useState } from "react"; -import TextEditor, { - TextEditorState as EditorState, - TextEditorContentState as ContentState, - TextEditorProps, -} from "./text-editor.component"; -import Button from "../button"; -import CarbonProvider from "../carbon-provider"; -import Box from "../box"; - -const createContent = (text?: string) => { - if (text) { - return EditorState.createWithContent(ContentState.createFromText(text)); - } - return EditorState.createEmpty(); -}; - -export const TextEditorCustom = ({ - onChange, - onLinkAdded, - ...props -}: Partial) => { - const [value, setValue] = React.useState(createContent()); - const ref = React.useRef(null); - - return ( -
    - { - if (onChange) { - onChange(newValue); - } - setValue(newValue); - }} - value={value} - ref={ref} - labelText="Text Editor Label" - onLinkAdded={onLinkAdded} - toolbarElements={[ - , - , - ]} - {...props} - /> -
    - ); -}; - -export const TextEditorCustomValidation = (props: Partial) => { - const [value, setValue] = React.useState( - EditorState.createWithContent(ContentState.createFromText("Add content")), - ); - const limit = 16; - const contentLength = value.getCurrentContent().getPlainText().length; - const ref = React.useRef(null); - - return ( -
    - { - setValue(newValue); - }} - value={value} - ref={ref} - labelText="Text Editor Label" - characterLimit={limit} - error={limit - contentLength <= 5 ? "There is an error" : undefined} - warning={limit - contentLength <= 10 ? "There is a warning" : undefined} - info={limit - contentLength <= 15 ? "There is an info" : undefined} - {...props} - /> -
    - ); -}; - -export const TextEditorNewValidation = () => { - const [value, setValue] = useState( - EditorState.createWithContent(ContentState.createFromText("Add content")), - ); - const limit = 16; - const contentLength = value.getCurrentContent().getPlainText().length; - return ( - - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - characterLimit={limit} - error={limit - contentLength <= 5 ? "There is an error" : undefined} - warning={ - limit - contentLength <= 10 ? "There is a warning" : undefined - } - inputHint="Some additional hint text" - /> - - - ); -}; - -export const TextEditorCharacterCount = ({ - onChange, - onLinkAdded, -}: Partial) => { - const [value, setValue] = React.useState(createContent()); - const ref = React.useRef(null); - - return ( -
    - { - if (onChange) { - onChange(newValue); - } - setValue(newValue); - }} - value={value} - ref={ref} - labelText="Text Editor Label" - onLinkAdded={onLinkAdded} - toolbarElements={[ - , - , - ]} - characterLimit={69} - /> - -
    - ); -}; diff --git a/src/components/text-editor/index.ts b/src/components/text-editor/index.ts deleted file mode 100644 index e2969d73c5..0000000000 --- a/src/components/text-editor/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - default, - TextEditorState as EditorState, - TextEditorContentState as ContentState, -} from "./text-editor.component"; -export type { TextEditorProps } from "./text-editor.component"; diff --git a/src/components/text-editor/text-editor-test.stories.tsx b/src/components/text-editor/text-editor-test.stories.tsx deleted file mode 100644 index 33128fe3b8..0000000000 --- a/src/components/text-editor/text-editor-test.stories.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState } from "react"; -import TextEditor, { - TextEditorState as EditorState, - TextEditorProps, -} from "./text-editor.component"; -import Button from "../button"; -import Box from "../box"; - -export default { - title: "Text Editor/Test", - excludeStories: ["TextEditorCustom", "TextEditorCustomValidation"], - parameters: { - info: { disable: true }, - chromatic: { - disableSnapshot: true, - }, - }, - argTypes: { - labelText: { - control: { - type: "text", - }, - }, - characterLimit: { - control: { - type: "number", - }, - }, - rows: { - control: { - type: "number", - }, - }, - error: { - control: { - type: "text", - }, - }, - warning: { - control: { - type: "text", - }, - }, - info: { - control: { - type: "text", - }, - }, - previews: { - control: { - type: "text", - }, - }, - }, -}; - -export const Default = ({ onChange, ...props }: Partial) => { - const [value, setValue] = useState(EditorState.createEmpty()); - return ( - - { - if (onChange) { - onChange(newValue); - } - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - {...props} - /> - - ); -}; - -Default.storyName = "default"; - -export const WithCustomToolbarContent = () => { - const [value, setValue] = useState(EditorState.createEmpty()); - return ( - - { - setValue(newValue); - }} - value={value} - toolbarElements={[ - , - , - ]} - labelText="Text Editor Label" - /> - - ); -}; - -WithCustomToolbarContent.storyName = "with custom toolbar content"; diff --git a/src/components/text-editor/text-editor.component.tsx b/src/components/text-editor/text-editor.component.tsx deleted file mode 100644 index 5134e2cba8..0000000000 --- a/src/components/text-editor/text-editor.component.tsx +++ /dev/null @@ -1,537 +0,0 @@ -import React, { - useCallback, - useEffect, - useRef, - useState, - useContext, -} from "react"; -import { MarginProps } from "styled-system"; -import { - ContentState, - EditorState, - EditorCommand, - RichUtils, - getDefaultKeyBinding, - Modifier, - Editor, - DraftHandleValue, -} from "draft-js"; -import { - computeBlockType, - getContent, - getContentInfo, - getDecoratedValue, - getSelectedLength, - moveSelectionToEnd, - resetBlockType, - isASCIIChar, - replaceText, - hasInlineStyle, - hasBlockStyle, - blockStyleFn, -} from "./__internal__/utils"; -import { - StyledEditorWrapper, - StyledEditorOutline, - StyledEditorContainer, -} from "./text-editor.style"; -import ValidationWrapper from "./__internal__/editor-validation-wrapper"; -import Toolbar from "./__internal__/toolbar"; -import Label from "../../__internal__/label"; -import Events from "../../__internal__/utils/helpers/events"; -import guid from "../../__internal__/utils/helpers/guid"; -import LabelWrapper from "./__internal__/label-wrapper"; -import { - BOLD, - ITALIC, - UNORDERED_LIST, - ORDERED_LIST, - InlineStyleType, - BlockType, -} from "./types"; -import { LinkPreviewProps } from "../link-preview"; -import NewValidationContext from "../carbon-provider/__internal__/new-validation.context"; -import { ErrorBorder, StyledHintText } from "../textbox/textbox.style"; -import ValidationMessage from "../../__internal__/validation-message"; -import useInputAccessibility from "../../hooks/__internal__/useInputAccessibility"; -import Box from "../box"; -import useCharacterCount from "../../hooks/__internal__/useCharacterCount"; -import EditorContext from "./__internal__/editor.context"; - -const NUMBERS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; -const INLINE_STYLES = [BOLD, ITALIC] as const; - -export interface TextEditorProps extends MarginProps { - /** The maximum characters that the input will accept */ - characterLimit?: number; - /** The text for the editor's label */ - labelText: string; - /** onChange callback to control value updates */ - onChange: (event: EditorState) => void; - /** Additional elements to be rendered in the Editor Toolbar, e.g. Save and Cancel Button */ - toolbarElements?: React.ReactNode; - /** The value of the input, this is an EditorState immutable object */ - value: EditorState; - /** Flag to configure component as mandatory. */ - required?: boolean; - /** Flag to configure component as optional. */ - isOptional?: boolean; - /** Message to be displayed when there is an error */ - error?: string; - /** Message to be displayed when there is a warning */ - warning?: string; - /** [Legacy] Message to be displayed when there is an info */ - info?: string; - /** Number greater than 2 multiplied by line-height (21px) to override the default min-height of the editor */ - rows?: number; - /** The previews to display of any links added to the Editor */ - previews?: React.ReactNode; - /** Callback to report a url when a link is added */ - onLinkAdded?: (url: string) => void; - /** Hint text to be rendered when validationRedesignOptIn flag is set */ - inputHint?: string; -} - -export const TextEditor = React.forwardRef( - ( - { - characterLimit = 3000, - labelText, - onChange, - value, - required, - error, - warning, - info, - toolbarElements, - rows, - previews, - onLinkAdded, - inputHint, - isOptional, - ...rest - }: TextEditorProps, - ref, - ) => { - const { validationRedesignOptIn } = useContext(NewValidationContext); - const [isFocused, setIsFocused] = useState(false); - const [inlines, setInlines] = useState([]); - const [activeInlines, setActiveInlines] = useState< - Partial> - >({}); - const [focusToolbar, setFocusToolbar] = useState(false); - - const editorRef = useRef(null); - const wrapper = useRef(null); - const editor = ref || editorRef; - const contentLength = getContent(value).getPlainText("").length; - const moveCursor = useRef(contentLength > 0); - const lastKeyPressed = useRef(); - const inputHintId = useRef(`${guid()}-hint`); - const { current: id } = useRef(guid()); - - const { labelId, validationId, ariaDescribedBy } = useInputAccessibility({ - id, - validationRedesignOptIn, - error, - warning, - info, - label: labelText, - }); - - const [characterCount, visuallyHiddenHintId] = useCharacterCount( - getContent(value).getPlainText(""), - characterLimit, - isFocused ? "polite" : "off", - ); - - const combinedAriaDescribedBy = [ - ariaDescribedBy, - inputHint ? inputHintId.current : undefined, - visuallyHiddenHintId, - ] - .filter(Boolean) - .join(" "); - - if (rows && (typeof rows !== "number" || rows < 2)) { - // eslint-disable-next-line no-console - console.warn( - `Prop rows must be a number value that is 2 or greater to override the min-height of the \`${TextEditor.displayName}\``, - ); - } - - const keyBindingFn = (ev: React.KeyboardEvent) => { - if (Events.isTabKey(ev) && !Events.isShiftKey(ev)) { - setFocusToolbar(true); - } - - return getDefaultKeyBinding(ev); - }; - - const BLOCK_TYPES = ["unordered-list-item", "ordered-list-item"]; - - const handleKeyCommand = (command: EditorCommand) => { - // bail out if the enter is pressed and limit has been reached - if (command.includes("split-block") && contentLength === characterLimit) { - return "handled"; - } - - // if the backspace or enter is pressed get block type and text - if (command.includes("backspace") || command.includes("split-block")) { - const { blockLength, blockType } = getContentInfo(value); - - // if a block control is active and there is no text, deactivate it and reset the block - if (BLOCK_TYPES.includes(blockType) && !blockLength) { - onChange(resetBlockType(value, "unstyled")); - - return "handled"; - } - } - - const style = command.toUpperCase(); - - // if formatting shortcut used eg. command is "bold" or "italic" - if (style === BOLD || style === ITALIC) { - const update = RichUtils.handleKeyCommand(value, command); - - // istanbul ignore else - if (update) { - onChange(update); - setActiveInlines({ - ...activeInlines, - [style]: !hasInlineStyle(value, style), - }); - - return "handled"; - } - } - - return "not-handled"; - }; - - const handleBeforeInput = (str: string, newState: EditorState) => { - // short circuit if exceeds character limit - if (contentLength >= characterLimit) { - return "handled"; - } - - setActiveInlines({}); - - // there is a bug in how DraftJS handles the macOS double-space-period feature, this is added to catch this and - // prevent the editor from crashing until a fix can be added to their codebase - if (lastKeyPressed.current === " " && !isASCIIChar(str)) { - lastKeyPressed.current = null; - onChange(replaceText(newState, " ", newState.getCurrentInlineStyle())); - - return "handled"; - } - - if (str === " ") { - lastKeyPressed.current = str; - - return "not-handled"; - } - - lastKeyPressed.current = null; - // short circuit if str does not match expected chars - if (![".", "*"].includes(str)) { - return "not-handled"; - } - - const { blockType, blockLength, blockText } = getContentInfo(value); - - if ( - (blockLength === 1 && NUMBERS.includes(blockText) && str === ".") || - (blockLength === 0 && str === "*") - ) { - const newBlockType = computeBlockType(str, blockType); - const hasNumberList = hasBlockStyle(value, ORDERED_LIST); - const hasBulletList = hasBlockStyle(value, UNORDERED_LIST); - - if ( - BLOCK_TYPES.includes(newBlockType) && - !hasNumberList && - !hasBulletList - ) { - onChange(resetBlockType(value, newBlockType)); - setActiveInlines({ - BOLD: hasInlineStyle(value, BOLD), - ITALIC: hasInlineStyle(value, ITALIC), - }); - - return "handled"; - } - } - onChange(value); - - return "not-handled"; - }; - - const handlePastedText = (pastedText: string) => { - const selectedTextLength = getSelectedLength(value); - const newLength = contentLength + pastedText?.length - selectedTextLength; - // if the pastedText will exceed the limit trim the excess - if (newLength > characterLimit) { - const newContentState = Modifier.insertText( - getContent(value), - value.getSelection(), - pastedText.substring(0, characterLimit - contentLength), - ); - const newState = EditorState.push( - value, - newContentState, - "insert-fragment", - ); - - onChange(newState); - - return "handled"; - } - - setActiveInlines({}); - - return "not-handled"; - }; - - const getEditorState = () => { - let editorState = getDecoratedValue(value); - - // should the cursor position be forced to the end of the content - if (contentLength > 0 && moveCursor.current && isFocused) { - editorState = moveSelectionToEnd(editorState); - moveCursor.current = false; - } - - return editorState; - }; - - const editorState = getEditorState(); - - const activeControls = { - BOLD: - activeInlines.BOLD !== undefined - ? activeInlines.BOLD - : hasInlineStyle(editorState, BOLD), - ITALIC: - activeInlines.ITALIC !== undefined - ? activeInlines.ITALIC - : hasInlineStyle(editorState, ITALIC), - "unordered-list-item": hasBlockStyle(editorState, UNORDERED_LIST), - "ordered-list-item": hasBlockStyle(editorState, ORDERED_LIST), - }; - - const handleEditorFocus = useCallback( - (focusValue: boolean) => { - moveCursor.current = true; - - if ( - focusValue && - typeof editor === "object" && - editor.current !== document.activeElement - ) { - editor.current?.focus(); - setFocusToolbar(false); - } - setIsFocused(focusValue); - }, - [editor], - ); - - const handleInlineStyleChange = ( - ev: - | React.MouseEvent - | React.KeyboardEvent, - style: InlineStyleType, - ) => { - ev.preventDefault(); - setActiveInlines({ - ...activeInlines, - [style]: !hasInlineStyle(value, style), - }); - handleEditorFocus(true); - setInlines([...inlines, style]); - }; - - const handleBlockStyleChange = ( - ev: - | React.MouseEvent - | React.KeyboardEvent, - newBlockType: BlockType, - ) => { - ev.preventDefault(); - handleEditorFocus(true); - onChange(RichUtils.toggleBlockType(value, newBlockType)); - const temp: InlineStyleType[] = []; - INLINE_STYLES.forEach((style) => { - if (activeInlines[style] !== undefined) { - temp.push(style); - } - }); - setInlines(temp); - }; - - useEffect(() => { - // apply the inline styling, having it run in as an effect ensures that styles can be added - // even when the editor is not focused - INLINE_STYLES.forEach((style) => { - const preserveStyle = - activeInlines[style] !== undefined && - activeInlines[style] !== hasInlineStyle(value, style); - - if ( - (preserveStyle && value.getSelection().isCollapsed()) || - (isFocused && inlines.includes(style)) - ) { - onChange(RichUtils.toggleInlineStyle(value, style)); - setInlines(inlines.filter((inline) => inline !== style)); - } - if (preserveStyle && !value.getSelection().isCollapsed()) { - setActiveInlines({ ...activeInlines, [style]: undefined }); - } - }); - }, [ - activeInlines, - contentLength, - editorState, - inlines, - isFocused, - onChange, - value, - ]); - - const handlePreviewClose = ( - onClose: (url: string) => void, - url?: string, - ) => { - // istanbul ignore else - if (url) onClose(url); - - // istanbul ignore else - if (typeof editor === "object") { - editor.current?.focus(); - } - }; - - useEffect(() => { - if (required) { - const editableElement = wrapper.current?.querySelector( - "div[contenteditable='true']", - ); - editableElement?.setAttribute("required", ""); - editableElement?.setAttribute("aria-required", "true"); - } - }, [required]); - - return ( - - - handleEditorFocus(true)}> - - - {inputHint && ( - - {inputHint} - - )} - - {validationRedesignOptIn && ( - <> - - {(error || warning) && ( - - )} - - )} - - - {!validationRedesignOptIn && (error || warning || info) && ( - - )} - handleEditorFocus(true)} - onBlur={() => handleEditorFocus(false)} - editorState={editorState} - onChange={onChange} - handleBeforeInput={ - handleBeforeInput as ( - chars: string, - state: EditorState, - ) => DraftHandleValue - } - handlePastedText={handlePastedText} - handleKeyCommand={ - handleKeyCommand as ( - command: EditorCommand, - ) => DraftHandleValue - } - ariaLabelledBy={labelId} - ariaDescribedBy={combinedAriaDescribedBy} - blockStyleFn={blockStyleFn} - keyBindingFn={keyBindingFn} - tabIndex={0} - /> - {React.Children.map(previews, (preview) => { - if (React.isValidElement(preview)) { - const { onClose } = preview?.props; - return React.cloneElement(preview, { - as: "div", - onClose: onClose - ? (url?: string) => handlePreviewClose(onClose, url) - : undefined, - }); - } - return null; - })} - - handleBlockStyleChange(ev, newBlockType) - } - setInlineStyle={(ev, inlineStyle) => - handleInlineStyleChange(ev, inlineStyle) - } - activeControls={activeControls} - canFocus={focusToolbar} - toolbarElements={toolbarElements} - /> - - - {characterCount} - - - - ); - }, -); - -export const TextEditorState = EditorState; -export const TextEditorContentState = ContentState; - -export default TextEditor; diff --git a/src/components/text-editor/text-editor.mdx b/src/components/text-editor/text-editor.mdx deleted file mode 100644 index 097c96259d..0000000000 --- a/src/components/text-editor/text-editor.mdx +++ /dev/null @@ -1,235 +0,0 @@ -import { Meta, ArgTypes, Canvas } from "@storybook/blocks"; -import TranslationKeysTable from "../../../.storybook/utils/translation-keys-table"; - -import * as TextEditorStories from "./text-editor.stories"; - - - -# Text Editor - -The `TextEditor` was created using the `draftjs` framework. It requires consuming projects to install `draftjs` as a -peer-dependency to enable it to work. - -## Contents - -- [Quick Start](#quick-start) -- [Examples](#examples) -- [Props](#props) -- [Translation keys](#translation-keys) - -## Quick Start - -```javascript -import TextEditor, { - EditorState, - ContentState, -} from "carbon-react/lib/components/text-editor"; -``` - -To use the text editor , import the `TextEditor` and pass the content as as an immutable `EditorState` object. - -It can be used as a controlled component where the content of the input is controlled externally, as such both -`onChange` and `value` props are required. The `labelText` and `labelId` props are also required in order to ensure -accessibility requirements are met. - -In order to capture any changes to the editor's state that may not be reflected in any content being added/removed, -`onChange` is called whenever the editor container is focused or blurred. This allows it to capture things like when -the content has been highlighted to apply an inline style (Bold/Italic). - - -## Examples - -### Default - -In its basic format the `TextEditor` requires three props; `value`, `onChange` and `label` props. The initial -editorState can be created empty using `EditorState.createEmpty()`, as it is below. It is also possible to render links -in the input, this can be done by manually typing or pasting a valid url into the editor. Another feature of the -component is that it supports a wide range of keyboard shortcuts to apply the various styling options: `cmd/ctrl+b` -toggles `bold`; `cmd/ctrl+i` toggles `italic`; and inputting a `*` or `1.` on a new line will render a -`unordered-list` or `ordered-list` respectively. -You can use the `required` prop to indicate if the field is mandatory. - - - -### With content - -The initial editorState can also be created with content using -`EditorState.createWithContent(ContentState.createFromText(''))`, as it is below. Other options available for -populating the content that can be found https://draftjs.org/docs/api-reference-content-state#static-methods. -It is also possible to initialise the editor with content in other formats, such as `html` or `markdown` through use of -other packages; using the methods for data conversion provided by `draftjs` -(https://draftjs.org/docs/api-reference-data-conversion/), enables the parsing of these formats into something the -editor can expects. - - - -### With optional Save / Cancel buttons - -By passing the `onSave` callback prop it is possible to render the form control buttons as seen below. This callback -will be executed when the `Save` button is clicked and there is content in the editor input, the button is disabled -otherwise. Any `onCancel` callback prop passed will be called when the `Cancel` button is clicked. - - - -### With optional character limit - -It is possible to override the default value for the character limit via the `characterLimit` prop. Setting this prop -will prevent any input that would cause the content length to exceed it. - - - -### Character counter with translations - -Various translation keys are available to assist with translating the character counter below the editor into different languages. These keys allow you to amend the messages shown when the counter exceeds or is below the set character limit. - -For screen reader users, the designated `characterCount.visuallyHiddenHint` key can be used to override the message that is announced when the user stops typing. - - - -### Validation - -Validation status can be set by passing `error`, `warning` or `info` prop to the component. - -Passing a string to these props will display a properly colored border along with a validation icon and tooltip - string value will be displayed as the tooltip message. - -Passing a boolean to these props will display only a properly colored border. - -For more information check our [Validations](../?path=/docs/documentation-validations--docs) documentation page. - - - -With use of `template strings` it is possible to pass multiline validation messages to the component. - - - -#### New designs validation - -The following examples use the new validation pattern that is available by setting the `validationRedesignOptIn` flag on the `CarbonProvider` to true. - - - -### With custom row height - -The `rows` prop allows for overriding the default min-height of the `TextEditor`. It accepts any number greater than 2 -which is multiplied by the line-height (21px). - - - -### With link previews - -It is possible to render `EditorLinkPreview`s via the `previews` prop. The `onLinkAdded` prop provides a callback -that will allow any link added to report the url back to be used to make a call to whatever service or api you want. -Whilst in the `Editor`, these previews can be deleted by clicking or pressing the enter key, when focused, on the close -icon. This example has mocked some functionality: previews will display for any link that has a url ending in `.com`, -`.co.uk`, `.org` or `.net`. See the prop table below for the available props for the `EditorLinkPreview` component. - - - -### Required - -You can use the `required` prop to indicate if the field is mandatory. - - - -### isOptional - -You can use the `isOptional` prop to indicate if the field is optional. - - - -## Props - -### Text Editor - - - -## Translation keys - -The following keys are available to override the translations for this component by passing in a custom locale object to the -[i18nProvider](../?path=/docs/documentation-i18n--docs). - - diff --git a/src/components/text-editor/text-editor.pw.tsx b/src/components/text-editor/text-editor.pw.tsx deleted file mode 100644 index d6cc85659e..0000000000 --- a/src/components/text-editor/text-editor.pw.tsx +++ /dev/null @@ -1,1387 +0,0 @@ -/* eslint-disable no-await-in-loop */ -import React from "react"; -import { test, expect } from "@playwright/experimental-ct-react17"; -import CarbonProvider from "../carbon-provider"; -import { - TextEditorCharacterCount, - TextEditorCustom, - TextEditorCustomValidation, - TextEditorNewValidation, -} from "./components.test-pw"; -import { - textEditorInput, - textEditorCounter, - textEditorContainer, - textEditorToolbar, - innerText, - innerTextList, -} from "../../../playwright/components/text-editor"; -import { - getComponent, - getDataElementByValue, - visuallyHiddenCharacterCount, -} from "../../../playwright/components"; -import { - verifyRequiredAsteriskForLabel, - checkAccessibility, - getStyle, -} from "../../../playwright/support/helper"; -import { VALIDATION, CHARACTERS } from "../../../playwright/support/constants"; - -const testData = [CHARACTERS.DIACRITICS, CHARACTERS.SPECIALCHARACTERS]; -const buttonNames = ["bold", "italic", "bullet-list", "number-list"]; - -test.describe("Functionality tests", () => { - test(`should check the counter works properly`, async ({ mount, page }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.clear(); - await textInput.fill("Testing is awesome"); - - await expect(textEditorCounter(page)).toHaveText("2,982 characters left"); - }); - - test("renders text as bold when bold button is selected", async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - const boldButton = page.getByRole("button", { name: "bold" }); - await boldButton.click(); - - await textInput.fill("Testing"); - - await expect(page.getByText("Testing")).toHaveCSS("font-weight", "700"); - await expect(boldButton).toHaveCSS("background-color", "rgb(0, 50, 76)"); - }); - - test("renders text as italic when italic button is selected", async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - const italicButton = page.getByRole("button", { name: "italic" }); - await italicButton.click(); - - await textInput.fill("Testing"); - - await expect(page.getByText("Testing")).toHaveCSS("font-style", "italic"); - await expect(italicButton).toHaveCSS("background-color", "rgb(0, 50, 76)"); - }); - - test("renders text as an unordered list when bullet list button is selected", async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - const bulletListButton = page.getByRole("button", { name: "bullet-list" }); - await bulletListButton.click(); - - await textInput.fill("Testing"); - await page.keyboard.press("Enter"); - await textInput.pressSequentially("is"); - await page.keyboard.press("Enter"); - await textInput.pressSequentially("awesome"); - - await expect(textInput.locator("ul").locator("li")).toHaveText([ - "Testing", - "is", - "awesome", - ]); - await expect(bulletListButton).toHaveCSS( - "background-color", - "rgb(0, 50, 76)", - ); - }); - - test("renders text as an ordered list when number list button is selected", async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - const numberListButton = page.getByRole("button", { name: "number-list" }); - await numberListButton.click(); - - await textInput.fill("Testing"); - await page.keyboard.press("Enter"); - await textInput.pressSequentially("is"); - await page.keyboard.press("Enter"); - await textInput.pressSequentially("awesome"); - - await expect(textInput.locator("ol").locator("li")).toHaveText([ - "Testing", - "is", - "awesome", - ]); - await expect(numberListButton).toHaveCSS( - "background-color", - "rgb(0, 50, 76)", - ); - }); - - test(`all toolbar buttons can be focused when navigating with the right arrow key`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - await textInput.click(); - - await page.keyboard.press("Tab"); - await expect(page.getByRole("button", { name: "bold" })).toBeFocused(); - - await page.keyboard.press("ArrowRight"); - await expect(page.getByRole("button", { name: "italic" })).toBeFocused(); - - await page.keyboard.press("ArrowRight"); - await expect( - page.getByRole("button", { name: "bullet-list" }), - ).toBeFocused(); - - await page.keyboard.press("ArrowRight"); - await expect( - page.getByRole("button", { name: "number-list" }), - ).toBeFocused(); - }); - - test(`all toolbar buttons can be focused when navigating with the left arrow key`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - await textInput.click(); - - await page.keyboard.press("Tab"); - await expect(page.getByRole("button", { name: "bold" })).toBeFocused(); - - await page.keyboard.press("ArrowLeft"); - await expect( - page.getByRole("button", { name: "number-list" }), - ).toBeFocused(); - - await page.keyboard.press("ArrowLeft"); - await expect( - page.getByRole("button", { name: "bullet-list" }), - ).toBeFocused(); - - await page.keyboard.press("ArrowLeft"); - await expect(page.getByRole("button", { name: "italic" })).toBeFocused(); - }); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when bold button is selected using ${key} keyboard key`, async ({ - mount, - page, - }) => { - await mount(); - - const boldButton = page.getByRole("button", { name: "bold" }); - await boldButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when italic button is selected using ${key} keyboard key`, async ({ - mount, - page, - }) => { - await mount(); - - const italicButton = page.getByRole("button", { name: "italic" }); - await italicButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when bullet list button is selected using ${key} keyboard key`, async ({ - mount, - page, - }) => { - await mount(); - - const bulletListButton = page.getByRole("button", { - name: "bullet-list", - }); - await bulletListButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when number list button is selected using ${key} keyboard key`, async ({ - mount, - page, - }) => { - await mount(); - - const numberListButton = page.getByRole("button", { - name: "number-list", - }); - await numberListButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when bold button is deselected using the ${key} key`, async ({ - page, - mount, - }) => { - await mount(); - - const boldButton = page.getByRole("button", { name: "bold" }); - await boldButton.click(); - - await boldButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when italic button is deselected using the ${key} key`, async ({ - page, - mount, - }) => { - await mount(); - - const italicButton = page.getByRole("button", { name: "italic" }); - await italicButton.click(); - - await italicButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when bullet list button is deselected using the ${key} key`, async ({ - page, - mount, - }) => { - await mount(); - - const bulletListButton = page.getByRole("button", { - name: "bullet-list", - }); - await bulletListButton.click(); - - await bulletListButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - ["Space", "Enter"].forEach((key) => - test(`focuses text input when number list button is deselected using the ${key} key`, async ({ - page, - mount, - }) => { - await mount(); - - const numberListButton = page.getByRole("button", { - name: "number-list", - }); - await numberListButton.click(); - - await numberListButton.press(key); - - const textInput = page.getByRole("textbox"); - await expect(textInput).toBeFocused(); - }), - ); - - test(`should render formatted link`, async ({ mount, page }) => { - const linkText = "https://carbon.sage.com"; - await mount(); - - const textInput = page.getByRole("textbox"); - await textInput.fill(linkText); - - await expect(innerText(page)).toHaveText(linkText); - }); - - test(`should not allow user to type more than characterLimit`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = page.getByRole("textbox"); - - await textInput.fill("a".repeat(100)); - await textInput.press("a"); - - await expect(textInput).not.toHaveText("a".repeat(101)); - await expect(page.getByTestId("character-count")).toHaveText( - "0 characters left", - ); - }); - - test(`should focus the first button when focus is moved to the input from the toolbar and tab key pressed`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - await page.keyboard.press("Tab"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(textEditorToolbar(page, "italic")).toBeFocused(); - await textInput.focus(); - await page.keyboard.press("Tab"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - }); - - test(`should focus the first button when focus is moved beyond the toolbar and shift-tab key pressed`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - await page.keyboard.press("Tab"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(textEditorToolbar(page, "italic")).toBeFocused(); - await page.keyboard.press("Tab"); - await page.keyboard.press("Shift+Tab"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - }); - - (["Bold", "Italic", "Bulleted List", "Numbered List"] as const).forEach( - (tooltipText, buttonNumber) => { - test(`should render with ${tooltipText} button hovered over`, async ({ - mount, - page, - }) => { - await mount(); - - const stylingButton = getComponent( - page, - "text-editor-toolbar-button", - ).nth(buttonNumber); - await stylingButton.hover(); - await expect(stylingButton).toHaveCSS( - "background-color", - "rgb(204, 214, 219)", - ); - await expect(getDataElementByValue(page, "tooltip")).toHaveText( - tooltipText, - ); - }); - }, - ); - - test(`should focus optional buttons by tabbing from the input area`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - await page.keyboard.press("Tab"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - await page.keyboard.press("Tab"); - await expect( - page.getByRole("button").filter({ hasText: "Cancel" }), - ).toBeFocused(); - await page.keyboard.press("Tab"); - await expect( - page.getByRole("button").filter({ hasText: "Save" }), - ).toBeFocused(); - }); - - test(`should focus save button when focus is moved outside the component and shift+tab is pressed`, async ({ - mount, - page, - }) => { - await mount(); - - const saveButton = page.getByRole("button").filter({ hasText: "Save" }); - - await saveButton.press("Tab"); - await expect(saveButton).not.toBeFocused(); - - await page.keyboard.press("Shift+Tab"); - await expect(saveButton).toBeFocused(); - }); - - test(`should verify pressing right-arrow loops on toolbar buttons and does not move focus to optional buttons`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - await page.keyboard.press("Tab"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await page.keyboard.press("ArrowRight"); - await page.keyboard.press("ArrowRight"); - await expect(textEditorToolbar(page, "number-list")).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(textEditorToolbar(page, "bold")).toBeFocused(); - }); - - test(`should verify that pressing right and left arrow keys does not move focus from optional buttons`, async ({ - mount, - page, - }) => { - await mount(); - - const saveButton = page.getByRole("button").filter({ hasText: "Save" }); - const cancelButton = page.getByRole("button").filter({ hasText: "Cancel" }); - - await cancelButton.focus(); - await expect(cancelButton).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(cancelButton).toBeFocused(); - await page.keyboard.press("ArrowLeft"); - await expect(cancelButton).toBeFocused(); - - await page.keyboard.press("Tab"); - await expect(saveButton).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(saveButton).toBeFocused(); - await page.keyboard.press("ArrowLeft"); - await expect(saveButton).toBeFocused(); - }); -}); - -test.describe("Prop tests", () => { - testData.forEach((labelValue) => { - test(`should render TextEditor with ${labelValue} as a label`, async ({ - mount, - page, - }) => { - await mount(); - - const label = getDataElementByValue(page, "label"); - await expect(label).toHaveText(labelValue); - }); - }); - - test(`should render TextEditor with required prop`, async ({ - mount, - page, - }) => { - await mount(); - - await verifyRequiredAsteriskForLabel(page); - }); - - test(`should render with error validation state`, async ({ mount, page }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - await getComponent(page, "icon").first().hover(); - await expect(getDataElementByValue(page, "tooltip")).toHaveText( - "There is an error", - ); - - expect( - await getStyle(getDataElementByValue(page, "error"), "color", "before"), - ).toBe(VALIDATION.ERROR); - - const iconColor = await page.evaluate(() => { - const validationIcon = document.querySelector(`[data-element="error"]`); - if (!validationIcon) { - return null; - } - const beforePseudoElement = window.getComputedStyle( - validationIcon, - "::before", - ); - return beforePseudoElement ? beforePseudoElement.color : null; - }); - expect(iconColor).toBe(VALIDATION.ERROR); - }); - - test(`should render with warning validation state`, async ({ - mount, - page, - }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - for (let i = 0; i < 5; i++) { - await page.keyboard.press("Delete"); - } - - await getComponent(page, "icon").first().hover(); - await expect(getDataElementByValue(page, "tooltip")).toHaveText( - "There is a warning", - ); - - expect( - await getStyle(getDataElementByValue(page, "warning"), "color", "before"), - ).toBe(VALIDATION.WARNING); - - const iconColor = await page.evaluate(() => { - const validationIcon = document.querySelector(`[data-element="warning"]`); - if (!validationIcon) { - return null; - } - const beforePseudoElement = window.getComputedStyle( - validationIcon, - "::before", - ); - return beforePseudoElement ? beforePseudoElement.color : null; - }); - expect(iconColor).toBe(VALIDATION.WARNING); - }); - - test(`should render with info validation state`, async ({ mount, page }) => { - await mount(); - - const textInput = textEditorInput(page); - await textInput.focus(); - for (let i = 0; i < 10; i++) { - await page.keyboard.press("Delete"); - } - - await getComponent(page, "icon").first().hover(); - await expect(getDataElementByValue(page, "tooltip")).toHaveText( - "There is an info", - ); - - expect( - await getStyle(getDataElementByValue(page, "info"), "color", "before"), - ).toBe(VALIDATION.INFO); - - const iconColor = await page.evaluate(() => { - const validationIcon = document.querySelector(`[data-element="info"]`); - if (!validationIcon) { - return null; - } - const beforePseudoElement = window.getComputedStyle( - validationIcon, - "::before", - ); - return beforePseudoElement ? beforePseudoElement.color : null; - }); - expect(iconColor).toBe(VALIDATION.INFO); - }); - - ["error", "warning", "info"].forEach((validationType) => { - test(`has correct styles when there is ${validationType} validation and the editor is focused`, async ({ - mount, - page, - }) => { - await mount( - - - , - ); - await textEditorInput(page).focus(); - - await expect(textEditorContainer(page).locator("..")).toHaveCSS( - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", - ); - }); - }); - - [ - [2, 42], - [4, 84], - ].forEach(([rows, px]) => { - test(`should render TextEditor with set rows prop to ${rows}`, async ({ - mount, - page, - }) => { - await mount(); - - await expect(textEditorContainer(page)).toHaveCSS( - "min-height", - `${px}px`, - ); - }); - }); -}); - -test.describe("Events tests", () => { - // draft-js calls onChange whenever the focus state changes as it needs to poll - // for any changes to the EditorState that may not have resulted in the plain text - // of the content updating but that the state has still changed. - // For example a user has highlighted some text to apply inline styles. - test(`should call onChange callback when a type event is triggered`, async ({ - mount, - page, - }) => { - let callbackCount = 0; - await mount( - { - callbackCount += 1; - }} - />, - ); - - await textEditorInput(page).fill("t"); - - expect(callbackCount).toBeGreaterThanOrEqual(1); - }); - - test(`should call onLinkAdded callback when a valid url is detected by TextEditor`, async ({ - mount, - page, - }) => { - let callbackCount = 0; - await mount( - { - callbackCount += 1; - }} - />, - ); - - await textEditorInput(page).fill("https://carbon.s"); - - expect(callbackCount).toBe(1); - }); -}); - -test.describe("Styling tests", () => { - test(`should render with the expected border radius on the toolbar buttons`, async ({ - mount, - page, - }) => { - await mount(); - - await expect(textEditorToolbar(page, "bold")).toHaveCSS( - "border-radius", - "8px", - ); - await expect(textEditorToolbar(page, "italic")).toHaveCSS( - "border-radius", - "8px", - ); - await expect(textEditorToolbar(page, "bullet-list")).toHaveCSS( - "border-radius", - "8px", - ); - await expect(textEditorToolbar(page, "number-list")).toHaveCSS( - "border-radius", - "8px", - ); - }); - - test(`should render with the expected focus styling`, async ({ - mount, - page, - }) => { - await mount(); - - const editorParent = textEditorContainer(page).locator(".."); - const toolbarBold = textEditorToolbar(page, "bold"); - const toolbarItalic = textEditorToolbar(page, "italic"); - const toolbarBullet = textEditorToolbar(page, "bullet-list"); - const toolbarNumber = textEditorToolbar(page, "number-list"); - - await textEditorInput(page).focus(); - await expect(editorParent).toHaveCSS( - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", - ); - await expect(editorParent).toHaveCSS( - "outline", - "rgba(0, 0, 0, 0) solid 3px", - ); - - await toolbarBold.focus(); - await expect(toolbarBold).toHaveCSS( - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", - ); - await expect(toolbarBold).toHaveCSS( - "outline", - "rgba(0, 0, 0, 0) solid 3px", - ); - await expect(toolbarBold).toHaveCSS("position", "relative"); - - await toolbarItalic.focus(); - await expect(toolbarItalic).toHaveCSS( - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", - ); - await expect(toolbarItalic).toHaveCSS( - "outline", - "rgba(0, 0, 0, 0) solid 3px", - ); - await expect(toolbarItalic).toHaveCSS("position", "relative"); - - await toolbarBullet.focus(); - await expect(toolbarBullet).toHaveCSS( - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", - ); - await expect(toolbarBullet).toHaveCSS( - "outline", - "rgba(0, 0, 0, 0) solid 3px", - ); - await expect(toolbarBullet).toHaveCSS("position", "relative"); - - await toolbarNumber.focus(); - await expect(toolbarNumber).toHaveCSS( - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", - ); - await expect(toolbarNumber).toHaveCSS( - "outline", - "rgba(0, 0, 0, 0) solid 3px", - ); - await expect(toolbarNumber).toHaveCSS("position", "relative"); - }); -}); - -test.describe("Accessibility tests", () => { - test(`should pass accessibility tests for default component`, async ({ - mount, - page, - }) => { - await mount(); - - await checkAccessibility(page); - }); - - testData.forEach((labelValue) => { - test(`should pass accessibility tests with ${labelValue} as a label`, async ({ - mount, - page, - }) => { - await mount(); - - await checkAccessibility(page); - }); - }); - - test(`should pass accessibility tests with required prop`, async ({ - mount, - page, - }) => { - await mount(); - - await checkAccessibility(page); - }); - - test(`should pass accessibility tests for validation state`, async ({ - mount, - page, - }) => { - await mount(); - - await checkAccessibility(page); - }); - - [2, 4].forEach((rows) => { - test(`should pass accessibility tests with rows prop sets to ${rows}`, async ({ - mount, - page, - }) => { - await mount(); - - await checkAccessibility(page); - }); - }); - - test(`should pass accessibility tests with validation when opt in flag is set`, async ({ - mount, - page, - }) => { - await mount(); - - await checkAccessibility(page); - }); -}); - -test("should set aria-live attribute on Character Count to `polite` when component is focused and then change back to `off` when component is blurred", async ({ - mount, - page, -}) => { - await mount(); - - const CharacterCountElement = visuallyHiddenCharacterCount(page); - const textEditorInputElement = textEditorInput(page); - const buttonElement = page.getByRole("button", { name: "Click Me" }); - - await expect(CharacterCountElement).toHaveAttribute("aria-live", "off"); - - await textEditorInputElement.focus(); - await textEditorInputElement.fill("Foo"); - - await expect(CharacterCountElement).toHaveAttribute("aria-live", "polite"); - - await buttonElement.click(); - - await expect(CharacterCountElement).toHaveAttribute("aria-live", "off"); -}); - -buttonNames.forEach((buttonType) => { - test(`should set 'aria-pressed' to true when ${buttonType} is selected and then false when deselected`, async ({ - mount, - page, - }) => { - await mount(); - - const toolbarButton = textEditorToolbar(page, buttonType); - await expect(toolbarButton).toHaveAttribute("aria-pressed", "false"); - await toolbarButton.click(); - await expect(toolbarButton).toHaveAttribute("aria-pressed", "true"); - await toolbarButton.click(); - await expect(toolbarButton).toHaveAttribute("aria-pressed", "false"); - }); -}); - -/* -Unfortunately draftjs (on which TextEditor is based) does not interact well with jsdom, so testing most behavioural features in RTL tests is not possible. -(See https://github.com/testing-library/user-event/issues/858 for one of these - and note that the workaround suggested with textInput does not appear to work. -Nor is this the only issue.) -As a result, most tests for TextEditor are now written in Playwright. If we ever move away from draftjs we should revisit this and see if any of them can be -moved back to unit tests. -These "unit test substitutes" are all in the `test.describe` block below for easy identification. -Some are near-duplicates of pre-existing Playwright tests above, but have been put here for ease of tracking which tests should correspond to unit tests. -*/ -test.describe("Substitute unit tests", () => { - test("when neither the `bold` nor `italic` toolbar buttons are clicked, added text is neither bold nor italic", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).fill("foo"); - - const enteredText = page.getByText("foo"); - await expect(enteredText).toHaveCSS("font-weight", "400"); - await expect(enteredText).toHaveCSS("font-style", "normal"); - }); - - test("when the `bold` toolbar button is clicked, added text is in bold style", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bold").click(); - await textEditorInput(page).fill("foo"); - - const enteredText = page.getByText("foo"); - await expect(enteredText).toHaveCSS("font-weight", "700"); - await expect(enteredText).toHaveCSS("font-style", "normal"); - }); - - test("when the `italic` toolbar button is clicked, added text is in italic style", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "italic").click(); - await textEditorInput(page).fill("foo"); - - const enteredText = page.getByText("foo"); - await expect(enteredText).toHaveCSS("font-weight", "400"); - await expect(enteredText).toHaveCSS("font-style", "italic"); - }); - - test("when the `bold` and `italic` toolbar buttons are both clicked, added text is in both bold and italic style", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bold").click(); - await textEditorToolbar(page, "italic").click(); - await textEditorInput(page).fill("foo"); - - const enteredText = page.getByText("foo"); - await expect(enteredText).toHaveCSS("font-weight", "700"); - await expect(enteredText).toHaveCSS("font-style", "italic"); - }); - - test("when an inline style button is toggled on and off, new text is entered without the corresponding style, while the previously-entered text keeps the style", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bold").click(); - await textEditorToolbar(page, "italic").click(); - await textEditorInput(page).fill("foo"); - await textEditorToolbar(page, "italic").click(); - await textEditorInput(page).pressSequentially("bar"); // can't use .fill as that removes the previous contents - - const boldAndItalicText = page.getByText("foo"); - const justBoldText = page.getByText("bar"); - await expect(boldAndItalicText).toHaveCSS("font-weight", "700"); - await expect(boldAndItalicText).toHaveCSS("font-style", "italic"); - await expect(justBoldText).toHaveCSS("font-weight", "700"); - await expect(justBoldText).toHaveCSS("font-style", "normal"); - }); - - test("when an inline style button is pressed with selected text, the style is applied to that text", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).fill("foobar"); - // select just the "foo" part - await page.getByText("foobar").evaluate((textSpan) => { - const textNode = textSpan.childNodes[0]; - window.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 3); - }); - await textEditorToolbar(page, "bold").click(); - - await expect(page.getByText("foo")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("bar")).toHaveCSS("font-weight", "400"); - }); - - test("when neither of the `list` toolbar buttons are clicked, added text is not rendered in a list", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).fill("foo"); - await page.keyboard.press("Enter"); - await textEditorInput(page).fill("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).fill("baz"); - - await expect(innerTextList(page, "ul", 1)).toHaveCount(0); - await expect(innerTextList(page, "il", 1)).toHaveCount(0); - }); - - test("when the `bullet-list` toolbar button is clicked, added text is rendered in an unordered list, with enter separating list items", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bullet-list").click(); - await textEditorInput(page).fill("foo"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ol", 1)).toHaveCount(0); - await expect(innerTextList(page, "ul", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ul", 2)).toHaveText("bar"); - await expect(innerTextList(page, "ul", 3)).toHaveText("baz"); - }); - - test("when the `number-list` toolbar button is clicked, added text is rendered in an ordered list, with enter separating list items", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "number-list").click(); - await textEditorInput(page).fill("foo"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ul", 1)).toHaveCount(0); - await expect(innerTextList(page, "ol", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ol", 2)).toHaveText("bar"); - await expect(innerTextList(page, "ol", 3)).toHaveText("baz"); - }); - - test("when a `list` button is toggled on and off, new text is entered without the corresponding formatting, while the previously-entered text keeps the formatting", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bullet-list").click(); - await textEditorInput(page).fill("foo"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorToolbar(page, "bullet-list").click(); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ul", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ul", 2)).toHaveText("bar"); - await expect(innerTextList(page, "ul", 3)).toHaveCount(0); - await expect(textEditorInput(page).getByText("baz")).toBeVisible(); - }); - - test("when inline style buttons and the `bullet-list` button are both toggled on, entered text has all the selected styles and formatting applied", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bold").click(); - await textEditorToolbar(page, "italic").click(); - await textEditorToolbar(page, "bullet-list").click(); - await textEditorInput(page).fill("foo"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ul", 1)).toHaveText("foo"); - await expect(page.getByText("foo")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("foo")).toHaveCSS("font-style", "italic"); - await expect(innerTextList(page, "ul", 2)).toHaveText("bar"); - await expect(page.getByText("bar")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("bar")).toHaveCSS("font-style", "italic"); - await expect(innerTextList(page, "ul", 3)).toHaveText("baz"); - await expect(page.getByText("baz")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("baz")).toHaveCSS("font-style", "italic"); - }); - - test("when inline style buttons and the `number-list` button are both toggled on, entered text has all the selected styles and formatting applied", async ({ - mount, - page, - }) => { - await mount(); - await textEditorToolbar(page, "bold").click(); - await textEditorToolbar(page, "italic").click(); - await textEditorToolbar(page, "number-list").click(); - await textEditorInput(page).fill("foo"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ol", 1)).toHaveText("foo"); - await expect(page.getByText("foo")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("foo")).toHaveCSS("font-style", "italic"); - await expect(innerTextList(page, "ol", 2)).toHaveText("bar"); - await expect(page.getByText("bar")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("bar")).toHaveCSS("font-style", "italic"); - await expect(innerTextList(page, "ol", 3)).toHaveText("baz"); - await expect(page.getByText("baz")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("baz")).toHaveCSS("font-style", "italic"); - }); - - test("the control+b keyboard shortcut toggles bold styling", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).focus(); - await page.keyboard.down("Control"); - await page.keyboard.press("b"); - await page.keyboard.up("Control"); - await textEditorInput(page).fill("foo"); - await page.keyboard.down("Control"); - await page.keyboard.press("b"); - await page.keyboard.up("Control"); - await textEditorInput(page).pressSequentially("bar"); - - await expect(page.getByText("foo")).toHaveCSS("font-weight", "700"); - await expect(page.getByText("bar")).toHaveCSS("font-weight", "400"); - }); - - test("the control+i keyboard shortcut toggles italic styling", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).focus(); - await page.keyboard.down("Control"); - await page.keyboard.press("i"); - await page.keyboard.up("Control"); - await textEditorInput(page).fill("foo"); - await page.keyboard.down("Control"); - await page.keyboard.press("i"); - await page.keyboard.up("Control"); - await textEditorInput(page).pressSequentially("bar"); - - await expect(page.getByText("foo")).toHaveCSS("font-style", "italic"); - await expect(page.getByText("bar")).toHaveCSS("font-style", "normal"); - }); - - test("invalid keyboard shortcuts do not change any styling", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).focus(); - await page.keyboard.down("Control"); - await page.keyboard.press("k"); - await page.keyboard.up("Control"); - await textEditorInput(page).fill("foo"); - - await expect(page.getByText("foo")).toHaveCSS("font-weight", "400"); - await expect(page.getByText("foo")).toHaveCSS("font-style", "normal"); - }); - - test("typing the `*` character starts an unordered-list block", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("*foo"); - - await expect(innerTextList(page, "ul", 1)).toHaveText("foo"); - }); - - test("typing the `1.` characters starts an ordered-list block", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("1.foo"); - - await expect(innerTextList(page, "ol", 1)).toHaveText("foo"); - }); - - test("typing the `*` character does not start an unordered-list block when text has already been added", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("foo*bar"); - - await expect(innerTextList(page, "ul", 1)).toHaveCount(0); - await expect(innerText(page)).toHaveText("foo*bar"); - }); - - test("typing the `1.` characters does not start an ordered-list block when text has already been added", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("foo1.bar"); - - await expect(innerTextList(page, "ol", 1)).toHaveCount(0); - await expect(innerText(page)).toHaveText("foo1.bar"); - }); - - test("unordered-list styling is removed when the user presses backspace at the start of a list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("*foo"); - - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await expect(innerTextList(page, "ul", 1)).toHaveCount(0); - }); - - test("ordered-list styling is removed when the user presses backspace at the start of a list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("1.foo"); - - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await expect(innerTextList(page, "ol", 1)).toHaveCount(0); - }); - - test("unordered-list styling is removed when the user presses enter at the start of a list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("*foo"); - - await page.keyboard.press("Enter"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - - await expect(innerTextList(page, "ul", 1)).toHaveCount(1); - await expect(innerTextList(page, "ul", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ul", 2)).toHaveCount(0); - }); - - test("ordered-list styling is removed when the user presses enter at the start of a list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("1.foo"); - - await page.keyboard.press("Enter"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - - await expect(innerTextList(page, "ol", 1)).toHaveCount(1); - await expect(innerTextList(page, "ol", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ol", 2)).toHaveCount(0); - }); - - test("unordered-list styling is not removed when the user presses backspace with text in the current list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("*foo"); - - await page.keyboard.press("Backspace"); - await expect(innerTextList(page, "ul", 1)).toHaveCount(1); - await expect(innerTextList(page, "ul", 1)).toHaveText("fo"); - }); - - test("ordered-list styling is not removed when the user presses backspace with text in the current list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("1.foo"); - - await page.keyboard.press("Backspace"); - await expect(innerTextList(page, "ol", 1)).toHaveCount(1); - await expect(innerTextList(page, "ol", 1)).toHaveText("fo"); - }); - - test("unordered-list styling is not removed when the user presses enter with text in the current list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("*foo"); - - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ul", 1)).toHaveCount(1); - await expect(innerTextList(page, "ul", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ul", 2)).toHaveCount(1); - await expect(innerTextList(page, "ul", 2)).toHaveText("bar"); - await expect(innerTextList(page, "ul", 3)).toHaveCount(1); - await expect(innerTextList(page, "ul", 3)).toHaveText("baz"); - }); - - test("ordered-list styling is not removed when the user presses enter with text in the current list item", async ({ - mount, - page, - }) => { - await mount(); - await textEditorInput(page).pressSequentially("1.foo"); - - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("bar"); - await page.keyboard.press("Enter"); - await textEditorInput(page).pressSequentially("baz"); - - await expect(innerTextList(page, "ol", 1)).toHaveCount(1); - await expect(innerTextList(page, "ol", 1)).toHaveText("foo"); - await expect(innerTextList(page, "ol", 2)).toHaveCount(1); - await expect(innerTextList(page, "ol", 2)).toHaveText("bar"); - await expect(innerTextList(page, "ol", 3)).toHaveCount(1); - await expect(innerTextList(page, "ol", 3)).toHaveText("baz"); - }); - - test("when the `characterLimit` prop is specified, key presses have no effect when the length of entered text has reached the limit", async ({ - mount, - page, - }) => { - await mount(); - - await textEditorInput(page).fill("1234567890"); - await expect(innerText(page)).toHaveText("1234567890"); - await textEditorInput(page).pressSequentially("1"); - await expect(innerText(page)).toHaveText("1234567890"); - }); - - test("content can be pasted into the editor if it would not exceed the character limit", async ({ - mount, - page, - context, - }) => { - // grant access to clipboard - await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await mount(); - await textEditorInput(page).focus(); - // copy text to clipboard - await page.evaluate(() => navigator.clipboard.writeText("1234567890")); - - if (process.env.CI) { - // assume Ubuntu (which is the OS the Github actions run on), where Control+Shift+V is needed to paste - await page.keyboard.down("Control"); - await page.keyboard.down("Shift"); - await page.keyboard.press("v"); - await page.keyboard.up("Shift"); - await page.keyboard.up("Control"); - } else if (process.platform === "win32") { - // Windows: Control+V - await page.keyboard.down("Control"); - await page.keyboard.press("v"); - await page.keyboard.up("Control"); - } else { - // assuming MacOS, press Meta(Command)+V to paste - await page.keyboard.down("Meta"); - await page.keyboard.press("v"); - await page.keyboard.up("Meta"); - } - - await expect(innerText(page)).toHaveText("1234567890"); - }); - - test("content pasted into the editor whose length exceed the character limit is cut short", async ({ - mount, - page, - context, - }) => { - // grant access to clipboard - await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await mount(); - await textEditorInput(page).focus(); - // copy text to clipboard - await page.evaluate(() => - navigator.clipboard.writeText("12345678901234567890"), - ); - - if (process.env.CI) { - // assume Ubuntu (which is the OS the Github actions run on), where Control+Shift+V is needed to paste - await page.keyboard.down("Control"); - await page.keyboard.down("Shift"); - await page.keyboard.press("v"); - await page.keyboard.up("Shift"); - await page.keyboard.up("Control"); - } else if (process.platform === "win32") { - // Windows: Control+V - await page.keyboard.down("Control"); - await page.keyboard.press("v"); - await page.keyboard.up("Control"); - } else { - // assuming MacOS, press Meta(Command)+V to paste - await page.keyboard.down("Meta"); - await page.keyboard.press("v"); - await page.keyboard.up("Meta"); - } - - await expect(innerText(page)).toHaveText("1234567890"); - }); -}); diff --git a/src/components/text-editor/text-editor.stories.tsx b/src/components/text-editor/text-editor.stories.tsx deleted file mode 100644 index f004bc4d25..0000000000 --- a/src/components/text-editor/text-editor.stories.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import React, { useState, useRef } from "react"; -import { Meta, StoryObj } from "@storybook/react"; - -import CarbonProvider from "../carbon-provider"; -import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; - -import I18nProvider from "../i18n-provider"; -import Button from "../button"; -import EditorLinkPreview from "../link-preview"; -import Box from "../box"; -import TextEditor, { - TextEditorState as EditorState, - TextEditorContentState as ContentState, -} from "./text-editor.component"; - -const styledSystemProps = generateStyledSystemProps({ - margin: true, -}); - -const meta: Meta = { - title: "Text Editor", - component: TextEditor, - argTypes: { - ...styledSystemProps, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = () => { - const [value, setValue] = useState(EditorState.createEmpty()); - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - /> - - ); -}; -Default.storyName = "Default"; - -export const WithContent: Story = () => { - const [value, setValue] = useState( - EditorState.createWithContent( - ContentState.createFromText("Some initial content"), - ), - ); - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - /> - - ); -}; -WithContent.storyName = "With Content"; - -export const WithOptionalButtons: Story = () => { - const [value, setValue] = useState(EditorState.createEmpty()); - return ( - - { - setValue(newValue); - }} - value={value} - toolbarElements={[ - , - , - ]} - labelText="Text Editor Label" - /> - - ); -}; -WithOptionalButtons.storyName = "With Optional Buttons"; - -export const WithOptionalCharacterLimit: Story = () => { - const [value, setValue] = useState(EditorState.createEmpty()); - const limit = 100; - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - characterLimit={limit} - /> - - ); -}; -WithOptionalCharacterLimit.storyName = "With Optional Character Limit"; - -export const CharacterCounterTranslations: Story = () => { - const [value, setValue] = useState(EditorState.createEmpty()); - const limit = 100; - return ( - "fr-FR", - characterCount: { - charactersLeft: (count, formattedCount) => - count === 1 - ? `${formattedCount} caractère restant` - : `${formattedCount} caractères restants`, - tooManyCharacters: (count, formattedCount) => - count === 1 - ? `${formattedCount} caractère de trop` - : `${formattedCount} caractères de trop`, - visuallyHiddenHint: (formattedCount) => - `Vous pouvez saisir jusqu'à ${formattedCount} caractères`, - }, - }} - > - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - characterLimit={limit} - /> - - - ); -}; -CharacterCounterTranslations.storyName = "Character Counter Translations"; - -export const WithValidation: Story = () => { - const [value, setValue] = useState( - EditorState.createWithContent(ContentState.createFromText("Add content")), - ); - const limit = 16; - const contentLength = value.getCurrentContent().getPlainText().length; - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - characterLimit={limit} - error={limit - contentLength <= 5 ? "There is an error" : undefined} - warning={limit - contentLength <= 10 ? "There is a warning" : undefined} - info={limit - contentLength <= 15 ? "There is an info" : undefined} - inputHint="Some additional hint text" - /> - - ); -}; -WithValidation.storyName = "With Validation"; - -export const WithMultilineValidation: Story = () => { - const [value, setValue] = useState( - EditorState.createWithContent(ContentState.createFromText("Add content")), - ); - const limit = 16; - const contentLength = value.getCurrentContent().getPlainText().length; - const error = - limit - contentLength <= 5 - ? `There is an error. -The content is too long. -Maybe try writing a little bit less?` - : undefined; - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - characterLimit={limit} - error={error} - /> - - ); -}; -WithMultilineValidation.storyName = "With Multiline Validation"; - -export const WithNewValidation: Story = () => { - const [value, setValue] = useState( - EditorState.createWithContent(ContentState.createFromText("Add content")), - ); - const limit = 16; - const contentLength = value.getCurrentContent().getPlainText().length; - return ( - - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - characterLimit={limit} - error={limit - contentLength <= 5 ? "There is an error" : undefined} - warning={ - limit - contentLength <= 10 ? "There is a warning" : undefined - } - inputHint="Some additional hint text" - /> - - - ); -}; -WithNewValidation.storyName = "With New Validation"; - -export const WithCustomRowHeight: Story = () => { - const [value, setValue] = useState(EditorState.createEmpty()); - - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - rows={2} - /> - - ); -}; -WithCustomRowHeight.storyName = "With Custom Row Height"; - -export const WithLinkPreviews: Story = () => { - const [value, setValue] = useState( - EditorState.createWithContent(ContentState.createFromText("www.sage.com")), - ); - const firstRender = useRef(false); - const previews = useRef([]); - const removeUrl = (reportedUrl: string | undefined) => { - previews.current = previews.current.filter( - (preview) => reportedUrl !== preview.props.url, - ); - }; - if (!firstRender.current) { - firstRender.current = true; - previews.current.push( - removeUrl(urlString)} - title="This is an example of a title" - url="https://www.sage.com" - description="Captain, why are we out here chasing comets? I'd like to think that I haven't changed those things, sir. Computer, lights up! Not if I weaken first. Damage report! Yesterday I did not know how to eat gagh. The Federation's gone; the Borg is everywhere! We know you're dealing in stolen ore. But I wanna talk about the assassination attempt on Lieutenant Worf. Our neural pathways have become accustomed to your sensory input patterns. Wouldn't that bring about chaos?" - key="key - 1" - />, - ); - } - const checkValidDomain = (url: string) => { - const domainsWhitelist = [".com", ".co.uk", ".org", ".net"]; - const result = domainsWhitelist.filter((domain) => - url.endsWith(domain), - ).length; - return !!result; - }; - const addUrl = (reportedUrl: string) => { - if ( - !previews.current.some((preview) => reportedUrl === preview.props.url) && - checkValidDomain(reportedUrl) - ) { - const previewConfig = { - title: "This is an example of a title", - isLoading: false, - url: reportedUrl, - image: undefined, - description: - "Captain, why are we out here chasing comets? I'd like to think that I haven't changed those things, sir. Computer, lights up! Not if I weaken first. Damage report! Yesterday I did not know how to eat gagh. The Federation's gone; the Borg is everywhere! We know you're dealing in stolen ore. But I wanna talk about the assassination attempt on Lieutenant Worf. Our neural pathways have become accustomed to your sensory input patterns. Wouldn't that bring about chaos?", - }; - const preview = ( - removeUrl(urlString)} - {...previewConfig} - /> - ); - previews.current.push(preview); - } - }; - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - previews={previews.current} - onLinkAdded={addUrl} - /> - - ); -}; -WithLinkPreviews.storyName = "With Link Previews"; - -export const Required: Story = () => { - const [value, setValue] = useState( - EditorState.createWithContent( - ContentState.createFromText("Some initial content"), - ), - ); - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - required - /> - - ); -}; - -Required.storyName = "Required"; - -export const IsOptional = () => { - const [value, setValue] = useState( - EditorState.createWithContent( - ContentState.createFromText("Some initial content"), - ), - ); - return ( - - { - setValue(newValue); - }} - value={value} - labelText="Text Editor Label" - isOptional - /> - - ); -}; -IsOptional.storyName = "IsOptional"; diff --git a/src/components/text-editor/text-editor.style.ts b/src/components/text-editor/text-editor.style.ts deleted file mode 100644 index ef305f549d..0000000000 --- a/src/components/text-editor/text-editor.style.ts +++ /dev/null @@ -1,82 +0,0 @@ -import styled, { css } from "styled-components"; -import { margin } from "styled-system"; -import baseTheme from "../../style/themes/base"; -import { isSafari } from "../../__internal__/utils/helpers/browser-type-check"; -import addFocusStyling from "../../style/utils/add-focus-styling"; - -const lineHeight = 21; - -const StyledEditorWrapper = styled.div` - ${margin} -`; - -StyledEditorWrapper.defaultProps = { - theme: baseTheme, -}; - -const StyledEditorContainer = styled.div<{ - hasError?: boolean; - rows?: number; - hasPreview?: boolean; -}>` - ${({ hasError, rows, hasPreview }) => css` - border-radius: var(--borderRadius050); - min-height: ${rows - ? `${rows * lineHeight}` - : `${hasPreview ? 125 : 220}`}px; - position: relative; - - div.DraftEditor-root { - min-height: inherit; - height: 100%; - margin: 4px; - } - - div.DraftEditor-editorContainer, - div.public-DraftEditor-content { - min-height: inherit; - height: 100%; - background-color: var(--colorsUtilityYang100); - line-height: ${lineHeight}px; - - ${!isSafari(navigator) && - css` - .text-editor-block-ordered { - position: relative; - left: -4px; - padding-left: 4px; - } - - .text-editor-block-unordered { - position: relative; - } - `} - } - - div.public-DraftEditor-content { - padding: 14px 8px; - } - - background-color: var(--colorsUtilityYang100); - outline: ${hasError - ? "2px solid var(--colorsSemanticNegative500)" - : "1px solid var(--colorsUtilityMajor200)"}; - `} -`; - -const StyledEditorOutline = styled.div<{ - isFocused?: boolean; - hasError?: boolean; -}>` - ${({ isFocused, hasError }) => css` - border-radius: var(--borderRadius050); - outline: none; - - ${isFocused && - css` - ${addFocusStyling()} - `} - `} -`; - -export { StyledEditorWrapper, StyledEditorContainer, StyledEditorOutline }; diff --git a/src/components/text-editor/text-editor.test.tsx b/src/components/text-editor/text-editor.test.tsx deleted file mode 100644 index 2efc0d583c..0000000000 --- a/src/components/text-editor/text-editor.test.tsx +++ /dev/null @@ -1,923 +0,0 @@ -import React, { useState } from "react"; -import { EditorState } from "draft-js"; -import { - render, - screen, - fireEvent, - waitFor, - act, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test-utils"; -import TextEditor, { - TextEditorContentState, - TextEditorProps, - TextEditorState, -} from "./text-editor.component"; -import * as utils from "./__internal__/utils"; -import EditorLinkPreview from "../link-preview"; -import { isSafari } from "../../__internal__/utils/helpers/browser-type-check"; -import CarbonProvider from "../carbon-provider"; - -jest.mock("../../__internal__/utils/helpers/browser-type-check"); -let windowScrollTo: typeof window.scrollTo; -// we need this mock import in order to be able to mock the getContentInfo util later without error -jest.mock("./__internal__/utils", () => ({ - __esModule: true, - ...jest.requireActual("./__internal__/utils"), -})); -// mock Tooltip with a "bare-bones" implementation as using the real component causes tests to flake and timeout when -// doing user actions that trigger tooltips -jest.mock("../tooltip", () => - jest.fn(({ children, message, isVisible }) => ( - <> - {children} - {isVisible ?
    {message}
    : null} - - )), -); - -beforeAll(() => { - windowScrollTo = window.scrollTo; - window.scrollTo = jest.fn(); - // need to mock isSafari to return false in order to meet coverage - (isSafari as jest.MockedFunction).mockImplementation( - () => false, - ); -}); - -afterAll(() => { - window.scrollTo = windowScrollTo; - (isSafari as jest.MockedFunction).mockRestore(); -}); - -const ControlledTextEditor = (props: Partial) => { - // due to issues with the interaction of draftJS and jsdom, things don't work properly when starting with an empty editor. - // We therefore start with some text (just a single space) in, and add to this in the tests. - const [value, setValue] = useState(() => - EditorState.createWithContent(TextEditorContentState.createFromText(" ")), - ); - return ( - { - setValue(val); - }} - labelText="Text Editor Label" - {...props} - /> - ); -}; - -/* -Unfortunately draftjs (on which TextEditor is based) does not interact well with jsdom, so testing many behavioural features in RTL tests is not possible. -(See https://github.com/testing-library/user-event/issues/858 for one of these - and note that the workaround suggested with textInput does not appear to work. -Nor is this the only issue.) -As a result, most tests for TextEditor are now written in Playwright. If we ever move away from draftjs we should revisit this and see if any of them can be -moved back to unit tests. -*/ - -testStyledSystemMargin( - (props) => ( - {}} - {...props} - /> - ), - () => screen.getByTestId("text-editor-wrapper"), -); - -test("pressing Tab with the text editor focused moves focus to the first Toolbar button", async () => { - const user = userEvent.setup(); - render( - {}} - />, - ); - - act(() => { - screen.getByRole("textbox", { name: "Text Editor Label" }).focus(); - }); - await user.tab(); - - expect(screen.getByRole("button", { name: "bold" })).toHaveFocus(); -}); - -test("clicking the label sets focus on the text editor", async () => { - const user = userEvent.setup(); - render( - {}} - />, - ); - - await user.click(screen.getByText("Text Editor Label")); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveFocus(); -}); - -test("pressing shift+Tab with the text editor focused does not move focus to the toolbar", async () => { - const user = userEvent.setup(); - render( - {}} - />, - ); - - act(() => { - screen.getByRole("textbox", { name: "Text Editor Label" }).focus(); - }); - await user.tab({ shift: true }); - - expect(document.body).toHaveFocus(); -}); - -test.each([ - "http://foo.com", - "https://bar.co.uk/", - "www.wiz.org", - "https://user:pwd@foo.com", - "https://foo.com:3000", - "https://foo.com/path/file-name.suffix", - "https://foo.com", - "https://foo.com/file.suffix?query=value&query2=value2", - "https://foo.com/file.suffix#hash", - "https://user:pwd@foo.com:3000/path/file-name.suffix?query-string#hash", -])("renders `%s` with HTML link style when it is part of the text", (url) => { - render( - {}} - />, - ); - - const link = screen.getByTestId("link-anchor"); // can't use getByRole("link") as the `a` tag has no href - expect(link).toBeVisible(); - expect(link).toHaveTextContent(url); - expect(link).toHaveStyle({ "text-decoration": "underline" }); -}); - -test.each([ - "foo://foo.com", - "https://bar.", - "wwww..s", - "http://f..o", - ".ca", - "_", - ":1rrr", - "https://user@foo.com", -])("renders `%s` without HTML link style", (invalidUrl) => { - const fullText = `this is not actually a link with text before - ${invalidUrl} - and after`; - render( - {}} - />, - ); - - expect(screen.queryByTestId("link-anchor")).not.toBeInTheDocument(); - expect(screen.queryByText(invalidUrl)).not.toBeInTheDocument(); - expect(screen.getByText(fullText)).not.toHaveStyle({ - "text-decoration": "underline", - }); -}); - -test.each([ - "http://foo.com", - "https://bar.co.uk/", - "www.wiz.org", - "https://user:pwd@foo.com", - "https://foo.com:3000", - "https://foo.com/path/file-name.suffix", - "https://foo.com", - "https://foo.com/file.suffix?query=value&query2=value2", - "https://foo.com/file.suffix#hash", - "https://user:pwd@foo.com:3000/path/file-name.suffix?query-string#hash", -])( - "renders `%s` with HTML link style when it is dynamically added to the editor", - async (url) => { - const user = userEvent.setup(); - render(); - - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - `this is a link with text before - ${url} - and after`, - ); - - const link = screen.getByTestId("link-anchor"); // can't use getByRole("link") as the `a` tag has no href - expect(link).toBeVisible(); - expect(link).toHaveTextContent(url); - expect(link).toHaveStyle({ "text-decoration": "underline" }); - }, -); - -test.each([ - "foo://foo.com", - "https://bar.", - "wwww..s", - "http://f..o", - ".ca", - "_", - ":1rrr", - "https://user@foo.com", -])( - "renders `%s` without HTML link style when it is dynamically added to the editor", - async (invalidUrl) => { - const user = userEvent.setup(); - render(); - - const fullText = `this is not actually a link with text before - ${invalidUrl} - and after`; - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - fullText, - ); - - expect(screen.queryByTestId("link-anchor")).not.toBeInTheDocument(); - expect(screen.queryByText(invalidUrl)).not.toBeInTheDocument(); - expect(screen.getByText(fullText)).not.toHaveStyle({ - "text-decoration": "underline", - }); - }, -); - -test("renders the elements passed in the `previews` prop", () => { - render( - {}} - previews={[ -
    I am a link preview
    , - , - ]} - />, - ); - - expect(screen.getByText("I am a link preview")).toBeVisible(); - expect( - screen.getByRole("button", { name: "I am a second link preview" }), - ).toBeVisible(); -}); - -test("does not render previews that are a simple number or string", () => { - render( - {}} - previews={[123, "foo", 456]} - />, - ); - - expect(screen.queryByText("123")).not.toBeInTheDocument(); - expect(screen.queryByText("foo")).not.toBeInTheDocument(); - expect(screen.queryByText("456")).not.toBeInTheDocument(); -}); - -test.each([ - ["http://foo.com", "http://foo.com"], - ["https://bar.co.uk/", "https://bar.co.uk/"], - ["www.wiz.org", "https://www.wiz.org"], - ["https://user:pwd@foo.com", "https://user:pwd@foo.com"], - ["https://foo.com:3000", "https://foo.com:3000"], - [ - "https://foo.com/path/file-name.suffix", - "https://foo.com/path/file-name.suffix", - ], - ["https://foo.com", "https://foo.com"], - [ - "https://foo.com/file.suffix?query=value&query2=value2", - "https://foo.com/file.suffix?query=value&query2=value2", - ], - ["https://foo.com/file.suffix#hash", "https://foo.com/file.suffix#hash"], - [ - "https://user:pwd@foo.com:3000/path/file-name.suffix?query-string#hash", - "https://user:pwd@foo.com:3000/path/file-name.suffix?query-string#hash", - ], -])( - "calls the `onLinkAdded` callback when `%s` is dynamically added to the editor", - async (enteredUrl, builtUrl) => { - const user = userEvent.setup(); - const onLinkAdded = jest.fn(); - render(); - - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - `this is a link with text before - ${enteredUrl} - and after`, - ); - - expect(onLinkAdded).toHaveBeenCalledWith(builtUrl); - }, -); - -test.each([ - "foo://foo.com", - "https://bar.", - "wwww..s", - "http://f..o", - ".ca", - "_", - ":1rrr", - "https://user@foo.com", -])( - "does not call the `onLinkAdded` callback when `%s` is dynamically added to the editor", - async (invalidUrl) => { - const user = userEvent.setup(); - const onLinkAdded = jest.fn(); - render(); - - const fullText = `this is not actually a link with text before - ${invalidUrl} - and after`; - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - fullText, - ); - - expect(onLinkAdded).not.toHaveBeenCalled(); - }, -); - -test("calls the `onClose` callback of a `LinkPreview` component if clicked", async () => { - const user = userEvent.setup(); - const onClose = jest.fn(); - render( - {}} - previews={[ - , - , - , - ]} - />, - ); - - await user.click( - screen.getByRole("button", { name: "link preview close button" }), - ); - expect(onClose).toHaveBeenCalledWith("foo"); - expect(onClose).toHaveBeenCalledTimes(1); -}); - -test("renders as required when the `required` prop is set", () => { - render( - {}} - required - />, - ); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toBeRequired(); -}); - -test.each(["error", "warning"])( - "has the validation message together with character limit as accessible description when there is %s validation and component is rendered with new-style validation", - (validationType) => { - render( - - {}} - characterLimit={10} - {...{ [validationType]: "Validation message" }} - /> - , - ); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveAccessibleDescription( - "Validation message You can enter up to 10 characters", - ); - }, -); - -test("has the `inputHint` prop together with character limit as accessible description", () => { - render( - {}} - characterLimit={10} - inputHint="here is a hint" - />, - ); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveAccessibleDescription( - "here is a hint You can enter up to 10 characters", - ); -}); - -test("fires a console warning when the `rows` prop is passed as a number less than 2", () => { - const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); - // mock displayName for this test - const oldDisplayName = TextEditor.displayName; - TextEditor.displayName = "EditorWithRowsError"; - - render( - {}} - rows={1} - />, - ); - expect(consoleSpy).toHaveBeenCalledWith( - "Prop rows must be a number value that is 2 or greater to override the min-height of the `EditorWithRowsError`", - ); - expect(consoleSpy).toHaveBeenCalledTimes(1); - // clean up mocks - consoleSpy.mockRestore(); - TextEditor.displayName = oldDisplayName; -}); - -// for coverage only - this behaviour doesn't work properly in jsdom (note incorrect order of text being entered - the style -// is also absent so we don't assert it). This behaviour is tested properly in Playwright -test("can enter text after clicking the `bold` toolbar button", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "bold" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "bold text", - ); - expect(screen.getByText("old textb")).toBeVisible(); -}); - -// for coverage only - this behaviour doesn't work properly in jsdom (note incorrect order of text being entered - the style -// is also absent so we don't assert it). This behaviour is tested properly in Playwright -test("can enter text after clicking the `italic` toolbar button", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "italic" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "italic text", - ); - expect(screen.getByText("talic texti")).toBeVisible(); -}); - -// for coverage only - this behaviour doesn't work properly in jsdom (note incorrect order of text being entered). -// This behaviour is tested properly in Playwright -test("can enter text after clicking the `bullet-list` toolbar button", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "bullet-list" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "list item", - ); - expect(screen.getByRole("listitem")).toHaveTextContent("ist iteml"); -}); - -// for coverage only - this behaviour doesn't work properly in jsdom (note incorrect order of text being entered). -// This behaviour is tested properly in Playwright -test("can enter text after clicking the `number-list` toolbar button", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "number-list" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "list item", - ); - expect(screen.getByRole("listitem")).toHaveTextContent("ist iteml"); -}); - -// coverage only - both old and new validation styles are captured by Chromatic -test.each(["error", "warning", "info"])( - "renders the %s icon when the validationRedesignOptIn flag is not set", - (validationType) => { - render( - {}} - {...{ [validationType]: "Validation message" }} - />, - ); - - expect(screen.getByTestId(`icon-${validationType}`)).toBeVisible(); - }, -); - -// for coverage only - this behaviour doesn't work properly in jsdom (note incorrect order of text being entered - the style -// is also absent so we don't assert it). This behaviour is tested properly in Playwright -test("can enter text after using the keyboard shortcut for bold styling", async () => { - const user = userEvent.setup(); - render(); - - act(() => { - screen.getByRole("textbox", { name: "Text Editor Label" }).focus(); - }); - await user.keyboard("{Control>}b{/Control}"); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "bold text", - ); - - expect(screen.getByText("old textb")).toBeVisible(); -}); - -// the next several tests are for coverage only - due to an issue with React itself, dispatching the (deprecated) `keypress` event -// with non-standard properties is the only way to trigger the `handleBeforeInput` handler in a test. See -// https://github.com/testing-library/user-event/issues/858#issuecomment-1124820366 -// (The functionality of this function is fully tested in Playwright tests). - -// coverage (handleBeforeInput - double-space feature) -test("double-space is entered when triggering handleBeforeInput", async () => { - render(); - - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 65, // a - }, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 32, // space - }, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 32, // space - }, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 66, // b - }, - ); - // note: waitFor is to avoid "state update on unmounted component" warning, as fireEvent is not async - await waitFor(() => { - // not sure why the text content is not as expected, but this is only for coverage anyway, Playwright tests prove everything works - // as expected - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveTextContent("A"); - }); -}); - -// coverage (handleBeforeInput - case when over characterLimit) -test("text over the characterLimit is lost when triggering handleBeforeInput", async () => { - render(); - - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 65, // a - }, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 65, // a - }, - ); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveTextContent("A"); -}); - -// coverage (handleBeforeInput when triggering block styles - tested in Playwright) -test("`1.` keyboard shortcut triggers a list block", () => { - render(); - - // can't seem to trigger the right conditions at all in a jsdom environment, so we resort to mocking the - // output of the getContentInfo util - const spy = jest.spyOn(utils, "getContentInfo").mockImplementation( - () => - ({ - blockType: "unstyled", - blockLength: 1, - blockText: "1", - }) as ReturnType, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 46, // . - }, - ); - - expect(screen.getAllByRole("listitem")).toHaveLength(1); - spy.mockRestore(); -}); - -// coverage (handleBeforeInput when typing a "block" shortcut when already in one)) -test("typing `1.` when already in a list block does nothing", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole("button", { name: "bullet-list" })); - expect(screen.getAllByRole("listitem")).toHaveLength(1); - expect(screen.getByRole("listitem")).toHaveTextContent(""); - - // can't seem to trigger the right conditions at all in a jsdom environment, so we resort to mocking the - // output of the getContentInfo util - const spy = jest.spyOn(utils, "getContentInfo").mockImplementation( - () => - ({ - blockType: "unstyled", - blockLength: 1, - blockText: "1", - }) as ReturnType, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 46, // . - }, - ); - - expect(screen.getAllByRole("listitem")).toHaveLength(1); - expect(screen.getByRole("listitem")).toHaveTextContent("."); - spy.mockRestore(); -}); - -// coverage (extreme edge cases for handleBeforeInput with `.` character at start of a line) -test("typing a `.` at the start of a line does nothing", async () => { - render(); - - const spy = jest.spyOn(utils, "getContentInfo").mockImplementation( - () => - ({ - blockType: "unstyled", - blockLength: 0, - blockText: "", - }) as ReturnType, - ); - fireEvent.keyPress( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - cancelable: true, - bubbles: true, - composed: false, - which: 46, // . - }, - ); - - expect(screen.queryByRole("listitem")).not.toBeInTheDocument(); - spy.mockRestore(); -}); - -// for coverage only - pasting text is covered in Playwright tests -test("can paste text if it does not exceed the character limit", () => { - render(); - // we have to manually fire the "paste" event here, with data made to fit the mock that draftjs uses internally - // for DataTransfer - fireEvent.paste(screen.getByRole("textbox", { name: "Text Editor Label" }), { - clipboardData: { - getData: () => "text", - types: ["text/plain"], - }, - }); - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveTextContent("text"); -}); - -// for coverage only - pasting text is covered in Playwright tests -test("pasted text is truncated if it exceeds the character limit", () => { - render(); - // we have to manually fire the "paste" event here, with data made to fit the mock that draftjs uses internally - // for DataTransfer - fireEvent.paste(screen.getByRole("textbox", { name: "Text Editor Label" }), { - clipboardData: { - getData: () => "text is too long", - types: ["text/plain"], - }, - }); - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveTextContent("text is t"); -}); - -// for coverage only (of onBlur) -test("content is not affected when the input is blurred", async () => { - const user = userEvent.setup(); - render(); - - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "some text", - ); - await user.tab(); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveTextContent("some text"); -}); - -// coverage only -test("can enter text with both block and inline styles", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "bold" })); - await user.click(screen.getByRole("button", { name: "bullet-list" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "foo", - ); - expect(screen.getByRole("listitem")).toHaveTextContent("oof"); -}); - -// coverage only -test("allows inline style button to be clicked with text highlighted", async () => { - const user = userEvent.setup(); - // the only way to get the component to recognise non-collapsed selection in jsdom apparently is to - // force the selection in the mock component - const ControlledTextEditorWithForcedSelection = ( - props: Partial, - ) => { - const [value, setValue] = useState(() => - EditorState.createWithContent(TextEditorContentState.createFromText(" ")), - ); - const onChange = (val: EditorState) => { - setValue(val); - }; - const selectAll = () => { - const valueWithSelection = TextEditorState.forceSelection( - value, - value.getSelection().merge({ anchorOffset: 0, focusOffset: 4 }), - ); - setValue(valueWithSelection); - }; - return ( - <> - - - - ); - }; - - render(); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "some text", - ); - await user.click(screen.getByRole("button", { name: "Select All" })); - await user.click(screen.getByRole("button", { name: "bold" })); - - expect( - screen.getByRole("textbox", { name: "Text Editor Label" }), - ).toHaveTextContent("some text"); -}); - -// for coverage only - userEvent doesn't support the keycode property in keyboard events which lead to them -// being dispatched with keyCode of 0 (see https://github.com/testing-library/user-event/issues/842). This -// means that draftjs's handleKeyCommand is not called, as that relies on the keyCode property being as expected -// (see https://github.com/facebookarchive/draft-js/blob/main/src/component/handlers/edit/editOnKeyDown.js#L162 -// and https://github.com/facebookarchive/draft-js/blob/main/src/component/utils/getDefaultKeyBinding.js#L64). -// To get around this, we use userEvent to manually fire keydown with the expected keyCode (66 for B) -test("can enter text after using the keyboard shortcut for bold styling when handleKeyCommand is called", async () => { - const user = userEvent.setup(); - render(); - - act(() => { - screen.getByRole("textbox", { name: "Text Editor Label" }).focus(); - }); - fireEvent.keyDown( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - key: "B", - keyCode: 66, - ctrlKey: true, - }, - ); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "bold text", - ); - - expect(screen.getByText("old textb")).toBeVisible(); -}); - -// for coverage only - see comment above for how to trigger handleKeyCommand -test("pressing Enter in a list block when at the character limit does not start a new list item", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "bullet-list" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "foo", - ); - fireEvent.keyDown( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - key: "Enter", - keyCode: 13, - }, - ); - - expect(screen.getAllByRole("listitem")).toHaveLength(1); -}); - -// for coverage only (tested in Playwright) - see comment above for how to trigger handleKeyCommand -test("pressing backspace when at the start of a list item removes the item", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "bullet-list" })); - expect(screen.getAllByRole("listitem")).toHaveLength(1); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "{Backspace}", - ); // to remove the starting space character - fireEvent.keyDown( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - key: "Backspace", - keyCode: 8, - }, - ); - - expect(screen.queryAllByRole("listitem")).toHaveLength(0); -}); - -// for coverage only (tested in Playwright) - see comment above for how to trigger handleKeyCommand -test("pressing backspace when not at the start of a list item leaves the item in place", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "bullet-list" })); - await user.type( - screen.getByRole("textbox", { name: "Text Editor Label" }), - "foo", - ); - expect(screen.getAllByRole("listitem")).toHaveLength(1); - fireEvent.keyDown( - screen.getByRole("textbox", { name: "Text Editor Label" }), - { - key: "Backspace", - keyCode: 8, - }, - ); - - expect(screen.getAllByRole("listitem")).toHaveLength(1); -}); diff --git a/src/components/text-editor/types.ts b/src/components/text-editor/types.ts deleted file mode 100644 index b84301f4df..0000000000 --- a/src/components/text-editor/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const BOLD = "BOLD"; -export const ITALIC = "ITALIC"; -export const UNORDERED_LIST = "unordered-list-item"; -export const ORDERED_LIST = "ordered-list-item"; - -export type InlineStyleType = typeof BOLD | typeof ITALIC; - -export type BlockType = typeof UNORDERED_LIST | typeof ORDERED_LIST; diff --git a/src/locales/en-gb.ts b/src/locales/en-gb.ts index dce1e7e761..85b4111874 100644 --- a/src/locales/en-gb.ts +++ b/src/locales/en-gb.ts @@ -164,6 +164,23 @@ const enGB: Locale = { pod: { undo: () => "Undo", }, + richTextEditor: { + boldAria: () => "Bold", + cancelButton: () => "Cancel", + cancelButtonAria: () => "Cancel", + characterCounter(count) { + return `${count} characters remaining`; + }, + characterLimit(count) { + return `You are ${count} character(s) over the character limit`; + }, + contentEditorAria: () => "Rich text content editor", + italicAria: () => "Italic", + orderedListAria: () => "Ordered list", + saveButton: () => "Save", + saveButtonAria: () => "Save", + unorderedListAria: () => "Unordered list", + }, search: { searchButtonText: () => "Search", }, diff --git a/src/locales/locale.ts b/src/locales/locale.ts index 3f8d1b7bd8..0112b54846 100644 --- a/src/locales/locale.ts +++ b/src/locales/locale.ts @@ -131,6 +131,19 @@ interface Locale { pod: { undo: () => string; }; + richTextEditor: { + boldAria: () => string; + cancelButton: () => string; + cancelButtonAria: () => string; + characterCounter: (count: number) => string; + characterLimit: (count: number) => string; + contentEditorAria: () => string; + italicAria: () => string; + orderedListAria: () => string; + saveButton: () => string; + saveButtonAria: () => string; + unorderedListAria: () => string; + }; search: { searchButtonText: () => string; };