diff --git a/change/@ni-nimble-components-9b9f98cc-fb5e-4db3-a9ce-9ec38f1c4fbf.json b/change/@ni-nimble-components-9b9f98cc-fb5e-4db3-a9ce-9ec38f1c4fbf.json new file mode 100644 index 0000000000..02ae0eb091 --- /dev/null +++ b/change/@ni-nimble-components-9b9f98cc-fb5e-4db3-a9ce-9ec38f1c4fbf.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Implementation of additional APIs for rich text editor", + "packageName": "@ni/nimble-components", + "email": "123377523+vivinkrishna-ni@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package-lock.json b/package-lock.json index 1b93463ce0..d8b8a0ecb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9477,9 +9477,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.0.4.tgz", - "integrity": "sha512-2YOMjRqoBGEP4YGgYpuPuBBJHMeqKOhLnS0WVwjVP84zOmMgZ7A8M6ILC9Xr7Q/qHZCvyBGWOSsI7+3HsEzzYQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.1.6.tgz", + "integrity": "sha512-gm8n1oiBhSP6CDhalmmWwLD7yzIUqJJ246/t8rY3o+HJ/I+p0rqCx0mPvMiwcIBmYX8tUCVz7mb9aSFUu/umOQ==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9489,9 +9489,9 @@ } }, "node_modules/@tiptap/extension-bold": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.0.4.tgz", - "integrity": "sha512-CWSQy1uWkVsen8HUsqhm+oEIxJrCiCENABUbhaVcJL/MqhnP4Trrh1B6O00Yfoc0XToPRRibDaHMFs4A3MSO0g==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.1.6.tgz", + "integrity": "sha512-gZDVuhYdceBQ/xGGY1X7lmkgNrDHFuFYBFRWMK0pLe9YBlQtJPc6+hiOmCtRtGmbQADDnvMmSU2a0+8bckmbCw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9501,9 +9501,9 @@ } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.0.4.tgz", - "integrity": "sha512-JSZKBVTaKSuLl5fR4EKE4dOINOrgeRHYA25Vj6cWjgdvpTw5ef7vcUdn9yP4JwTmLRI+VnnMlYL3rqigU3iZNg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.6.tgz", + "integrity": "sha512-NjPL5cIa4wVqv62OEw4lQ4Dj4c2hxia7GtPKHZKjoot5iu1RDkzD9Cxy/0tmH0vfCwTqa0JbGf9FAxRCyok4kg==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9513,9 +9513,9 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.0.4.tgz", - "integrity": "sha512-mCj2fAhnNhIHttPSqfTPSSTGwClGaPYvhT56Ij/Pi4iCrWjPXzC4XnIkIHSS34qS2tJN4XJzr/z7lm3NeLkF1w==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.1.6.tgz", + "integrity": "sha512-econFqLeQR8pe0xv7kjw6ZPRhcNXGrNi9854celX0lhqTqtBxvU6nWHzUDzoq/lmnXYgpFTPv42AwUEspvpwdw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9525,9 +9525,9 @@ } }, "node_modules/@tiptap/extension-history": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.0.4.tgz", - "integrity": "sha512-3GAUszn1xZx3vniHMiX9BSKmfvb5QOb0oSLXInN+hx80CgJDIHqIFuhx2dyV9I/HWpa0cTxaLWj64kfDzb1JVg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.1.6.tgz", + "integrity": "sha512-ltHz9cW3bWi7Z3m960F5eLPAqZDBNOpUP31t9YdKqhyxA16eygryj1USVeus9DX5OBoW79I8EecFAuRo3Rymlw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9538,9 +9538,9 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.0.4.tgz", - "integrity": "sha512-C/6+qs4Jh8xERRP0wcOopA1+emK8MOkBE4RQx5NbPnT2iCpERP0GlmHBFQIjaYPctZgKFHxsCfRnneS5Xe76+A==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.1.6.tgz", + "integrity": "sha512-o41hil+x2yqFciOiJPx67FnguJ4/aEMU8MotmXekFGHM+I0wFOd4lA5t7HqFU5Si0Z7gyTb/N0wLUbAnbyk/Aw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9550,9 +9550,9 @@ } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.0.4.tgz", - "integrity": "sha512-tSkbLgRo1QMNDJttWs9FeRywkuy5T2HdLKKfUcUNzT3s0q5AqIJl7VyimsBL4A6MUfN1qQMZCMHB4pM9Mkluww==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.1.6.tgz", + "integrity": "sha512-hgG8XzWRvhmEtb70ut2YTWfexMDu4PHgDS8WxYGOCVH0F+DwZqGF5KEARhFSPlmRUCWcmKey4sp8YDpLqShEWA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9562,9 +9562,9 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.4.tgz", - "integrity": "sha512-Kfg+8k9p4iJCUKP/yIa18LfUpl9trURSMP/HX3/yQTz9Ul1vDrjxeFjSE5uWNvupcXRAM24js+aYrCmV7zpU+Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.1.6.tgz", + "integrity": "sha512-7igbJBSeCByYM9G3XHlK1sqPQtIsOlezdc4PH7xBaOtvNDd1ruGvOGFovo9b5TW8+J08KCAqy25cV4Pn72fuGw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9574,9 +9574,9 @@ } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.0.4.tgz", - "integrity": "sha512-nDxpopi9WigVqpfi8nU3B0fWYB14EMvKIkutNZo8wJvKGTZufNI8hw66wupIx/jZH1gFxEa5dHerw6aSYuWjgQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.1.6.tgz", + "integrity": "sha512-k0QSIaJPVgTn9+X2580JFCjV2RCH1Fo+gPodABDnjunfoUVSjuq0rlILEtTuha3evlS6kDKiz7lk7pIoCo36Cw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9585,10 +9585,23 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.1.6.tgz", + "integrity": "sha512-M6C80FnbDPiZWVGFIVVOUMbqNUMhXRzlJr7uwUWP98OJfj3Du4pk8mF5Lo5MsWH3C/XW3YRbqlGPpdas3onSkQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0", + "@tiptap/pm": "^2.0.0" + } + }, "node_modules/@tiptap/extension-text": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.0.4.tgz", - "integrity": "sha512-i8/VFlVZh7TkAI49KKX5JmC0tM8RGwyg5zUpozxYbLdCOv07AkJt+E1fLJty9mqH4Y5HJMNnyNxsuZ9Ol/ySRA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.1.6.tgz", + "integrity": "sha512-CqV0N6ngoXZFeJGlQ86FSZJ/0k7+BN3S6aSUcb5DRAKsSEv/Ga1LvSG24sHy+dwjTuj3EtRPJSVZTFcSB17ZSA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -33325,16 +33338,17 @@ "@ni/nimble-tokens": "^6.3.0", "@tanstack/table-core": "^8.9.3", "@tanstack/virtual-core": "^3.0.0-beta.44", - "@tiptap/core": "^2.0.4", - "@tiptap/extension-bold": "^2.0.4", - "@tiptap/extension-bullet-list": "^2.0.4", - "@tiptap/extension-document": "^2.0.4", - "@tiptap/extension-history": "^2.0.4", - "@tiptap/extension-italic": "^2.0.4", - "@tiptap/extension-list-item": "^2.0.4", - "@tiptap/extension-ordered-list": "^2.0.4", - "@tiptap/extension-paragraph": "^2.0.4", - "@tiptap/extension-text": "^2.0.4", + "@tiptap/core": "^2.1.6", + "@tiptap/extension-bold": "^2.1.6", + "@tiptap/extension-bullet-list": "^2.1.6", + "@tiptap/extension-document": "^2.1.6", + "@tiptap/extension-history": "^2.1.6", + "@tiptap/extension-italic": "^2.1.6", + "@tiptap/extension-list-item": "^2.1.6", + "@tiptap/extension-ordered-list": "^2.1.6", + "@tiptap/extension-paragraph": "^2.1.6", + "@tiptap/extension-placeholder": "^2.1.6", + "@tiptap/extension-text": "^2.1.6", "@types/d3-array": "^3.0.4", "@types/d3-random": "^3.0.1", "@types/d3-scale": "^4.0.2", diff --git a/packages/nimble-components/package.json b/packages/nimble-components/package.json index 116c9abb7b..0bbeb1545d 100644 --- a/packages/nimble-components/package.json +++ b/packages/nimble-components/package.json @@ -64,16 +64,17 @@ "@ni/nimble-tokens": "^6.3.0", "@tanstack/table-core": "^8.9.3", "@tanstack/virtual-core": "^3.0.0-beta.44", - "@tiptap/core": "^2.0.4", - "@tiptap/extension-bold": "^2.0.4", - "@tiptap/extension-bullet-list": "^2.0.4", - "@tiptap/extension-document": "^2.0.4", - "@tiptap/extension-history": "^2.0.4", - "@tiptap/extension-italic": "^2.0.4", - "@tiptap/extension-list-item": "^2.0.4", - "@tiptap/extension-ordered-list": "^2.0.4", - "@tiptap/extension-paragraph": "^2.0.4", - "@tiptap/extension-text": "^2.0.4", + "@tiptap/core": "^2.1.6", + "@tiptap/extension-bold": "^2.1.6", + "@tiptap/extension-bullet-list": "^2.1.6", + "@tiptap/extension-document": "^2.1.6", + "@tiptap/extension-history": "^2.1.6", + "@tiptap/extension-italic": "^2.1.6", + "@tiptap/extension-list-item": "^2.1.6", + "@tiptap/extension-ordered-list": "^2.1.6", + "@tiptap/extension-paragraph": "^2.1.6", + "@tiptap/extension-placeholder": "^2.1.6", + "@tiptap/extension-text": "^2.1.6", "@types/d3-array": "^3.0.4", "@types/d3-random": "^3.0.1", "@types/d3-scale": "^4.0.2", diff --git a/packages/nimble-components/src/rich-text-editor/index.ts b/packages/nimble-components/src/rich-text-editor/index.ts index b88609bf97..d0ae18e4f5 100644 --- a/packages/nimble-components/src/rich-text-editor/index.ts +++ b/packages/nimble-components/src/rich-text-editor/index.ts @@ -1,7 +1,12 @@ -import { observable } from '@microsoft/fast-element'; -import { DesignSystem, FoundationElement } from '@microsoft/fast-foundation'; +import { observable, attr, DOM } from '@microsoft/fast-element'; +import { + applyMixins, + ARIAGlobalStatesAndProperties, + DesignSystem, + FoundationElement +} from '@microsoft/fast-foundation'; import { keyEnter, keySpace } from '@microsoft/fast-web-utilities'; -import { Editor } from '@tiptap/core'; +import { Editor, AnyExtension, Extension } from '@tiptap/core'; import { schema, defaultMarkdownParser, @@ -19,10 +24,13 @@ import Italic from '@tiptap/extension-italic'; import ListItem from '@tiptap/extension-list-item'; import OrderedList from '@tiptap/extension-ordered-list'; import Paragraph from '@tiptap/extension-paragraph'; +import Placeholder from '@tiptap/extension-placeholder'; +import type { PlaceholderOptions } from '@tiptap/extension-placeholder'; import Text from '@tiptap/extension-text'; import { template } from './template'; import { styles } from './styles'; import type { ToggleButton } from '../toggle-button'; +import type { ErrorPattern } from '../patterns/error/types'; declare global { interface HTMLElementTagNameMap { @@ -33,7 +41,72 @@ declare global { /** * A nimble styled rich text editor */ -export class RichTextEditor extends FoundationElement { +export class RichTextEditor extends FoundationElement implements ErrorPattern { + /** + * @internal + */ + public editor = this.createEditor(); + + /** + * @internal + */ + public tiptapEditor = this.createTiptapEditor(); + + /** + * Whether to disable user from editing and interacting with toolbar buttons + * + * @public + * HTML Attribute: disabled + */ + @attr({ mode: 'boolean' }) + public disabled = false; + + /** + * Whether to hide the footer of the rich text editor + * + * @public + * HTML Attribute: footer-hidden + */ + @attr({ attribute: 'footer-hidden', mode: 'boolean' }) + public footerHidden = false; + + /** + * Whether to display the error state. + * + * @public + * HTML Attribute: error-visible + */ + @attr({ attribute: 'error-visible', mode: 'boolean' }) + public errorVisible = false; + + /** + * A message explaining why the value is invalid. + * + * @public + * HTML Attribute: error-text + */ + @attr({ attribute: 'error-text' }) + public errorText?: string; + + /** + * @public + * HTML Attribute: placeholder + */ + @attr + public placeholder?: string; + + /** + * True if the editor is empty or contains only whitespace, false otherwise. + * + * @public + */ + public get empty(): boolean { + // Tiptap [isEmpty](https://tiptap.dev/api/editor#is-empty) returns false even if the editor has only whitespace. + // However, the expectation is to return true if the editor is empty or contains only whitespace. + // Hence, by retrieving the current text content using Tiptap state docs and then trimming the string to determine whether it is empty or not. + return this.tiptapEditor.state.doc.textContent.trim().length === 0; + } + /** * @internal */ @@ -58,24 +131,26 @@ export class RichTextEditor extends FoundationElement { @observable public numberedListButton!: ToggleButton; + /** + * The width of the vertical scrollbar, if displayed. + * @internal + */ + @observable + public scrollbarWidth = -1; + /** * @internal */ public editorContainer!: HTMLDivElement; - private tiptapEditor!: Editor; - private editor!: HTMLDivElement; + private resizeObserver?: ResizeObserver; + private updateScrollbarWidthQueued = false; private readonly markdownParser = this.initializeMarkdownParser(); private readonly markdownSerializer = this.initializeMarkdownSerializer(); private readonly domSerializer = DOMSerializer.fromSchema(schema); private readonly xmlSerializer = new XMLSerializer(); - public constructor() { - super(); - this.initializeEditor(); - } - /** * @internal */ @@ -85,6 +160,10 @@ export class RichTextEditor extends FoundationElement { this.editorContainer.append(this.editor); } this.bindEditorTransactionEvent(); + this.bindEditorUpdateEvent(); + this.stopNativeInputEventPropagation(); + this.resizeObserver = new ResizeObserver(() => this.onResize()); + this.resizeObserver.observe(this); } /** @@ -93,6 +172,46 @@ export class RichTextEditor extends FoundationElement { public override disconnectedCallback(): void { super.disconnectedCallback(); this.unbindEditorTransactionEvent(); + this.unbindEditorUpdateEvent(); + this.unbindNativeInputEvent(); + this.resizeObserver?.disconnect(); + } + + /** + * @internal + */ + public disabledChanged(): void { + this.tiptapEditor.setEditable(!this.disabled); + this.setEditorTabIndex(); + this.editor.setAttribute( + 'aria-disabled', + this.disabled ? 'true' : 'false' + ); + } + + /** + * Update the placeholder text and view of the editor. + * @internal + */ + public placeholderChanged(): void { + const placeholderExtension = this.getTipTapExtension( + 'placeholder' + ) as Extension; + placeholderExtension.options.placeholder = this.placeholder ?? ''; + this.tiptapEditor.view.dispatch(this.tiptapEditor.state.tr); + + this.queueUpdateScrollbarWidth(); + } + + /** + * @internal + */ + public ariaLabelChanged(): void { + if (this.ariaLabel !== null && this.ariaLabel !== undefined) { + this.editor.setAttribute('aria-label', this.ariaLabel); + } else { + this.editor.removeAttribute('aria-label'); + } } /** @@ -205,6 +324,41 @@ export class RichTextEditor extends FoundationElement { return false; } + private createEditor(): HTMLDivElement { + const editor = document.createElement('div'); + editor.className = 'editor'; + editor.setAttribute('aria-multiline', 'true'); + editor.setAttribute('role', 'textbox'); + editor.setAttribute('aria-disabled', 'false'); + return editor; + } + + private createTiptapEditor(): Editor { + /** + * For more information on the extensions for the supported formatting options, refer to the links below. + * Tiptap marks: https://tiptap.dev/api/marks + * Tiptap nodes: https://tiptap.dev/api/nodes + */ + return new Editor({ + element: this.editor, + extensions: [ + Document, + Paragraph, + Text, + BulletList, + OrderedList, + ListItem, + Bold, + Italic, + History, + Placeholder.configure({ + placeholder: '', + showOnlyWhenEditable: false + }) + ] + }); + } + /** * This function takes the Fragment from parseMarkdownToDOM function and return the serialized string using XMLSerializer */ @@ -288,34 +442,6 @@ export class RichTextEditor extends FoundationElement { ); } - private initializeEditor(): void { - // Create div from the constructor because the TipTap editor requires its host element before the template is instantiated. - this.editor = document.createElement('div'); - this.editor.className = 'editor'; - this.editor.setAttribute('aria-multiline', 'true'); - this.editor.setAttribute('role', 'textbox'); - - /** - * For more information on the extensions for the supported formatting options, refer to the links below. - * Tiptap marks: https://tiptap.dev/api/marks - * Tiptap nodes: https://tiptap.dev/api/nodes - */ - this.tiptapEditor = new Editor({ - element: this.editor, - extensions: [ - Document, - Paragraph, - Text, - BulletList, - OrderedList, - ListItem, - Bold, - Italic, - History - ] - }); - } - /** * Binding the "transaction" event to the editor allows continuous monitoring the events and updating the button state in response to * various actions such as mouse events, keyboard events, changes in the editor content etc,. @@ -347,8 +473,85 @@ export class RichTextEditor extends FoundationElement { return false; } } + + private unbindEditorUpdateEvent(): void { + this.tiptapEditor.off('update'); + } + + /** + * input event is fired when there is a change in the content of the editor. + * + * https://tiptap.dev/api/events#update + */ + private bindEditorUpdateEvent(): void { + this.tiptapEditor.on('update', () => { + this.$emit('input'); + this.queueUpdateScrollbarWidth(); + }); + } + + /** + * Stopping the native input event propagation emitted by the contenteditable element in the Tiptap + * since there is an issue (linked below) in ProseMirror where selecting the text and removing it + * does not trigger the native HTMLElement input event. So using the "update" event emitted by the + * Tiptap to capture it as an "input" customEvent in the rich text editor. + * + * Prose Mirror issue: https://discuss.prosemirror.net/t/how-to-handle-select-backspace-delete-cut-type-kind-of-events-handletextinput-or-handledomevents-input-doesnt-help/4844 + */ + private stopNativeInputEventPropagation(): void { + this.tiptapEditor.view.dom.addEventListener('input', event => { + event.stopPropagation(); + }); + } + + private unbindNativeInputEvent(): void { + this.tiptapEditor.view.dom.removeEventListener('input', () => {}); + } + + private queueUpdateScrollbarWidth(): void { + if (!this.$fastController.isConnected) { + return; + } + if (!this.updateScrollbarWidthQueued) { + this.updateScrollbarWidthQueued = true; + DOM.queueUpdate(() => this.updateScrollbarWidth()); + } + } + + private updateScrollbarWidth(): void { + this.updateScrollbarWidthQueued = false; + this.scrollbarWidth = this.tiptapEditor.view.dom.offsetWidth + - this.tiptapEditor.view.dom.clientWidth; + } + + private onResize(): void { + this.scrollbarWidth = this.tiptapEditor.view.dom.offsetWidth + - this.tiptapEditor.view.dom.clientWidth; + } + + private getTipTapExtension( + extensionName: string + ): AnyExtension | undefined { + return this.tiptapEditor.extensionManager.extensions.find( + extension => extension.name === extensionName + ); + } + + private setEditorTabIndex(): void { + this.tiptapEditor.setOptions({ + editorProps: { + attributes: { + tabindex: this.disabled ? '-1' : '0' + } + } + }); + } } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RichTextEditor extends ARIAGlobalStatesAndProperties {} +applyMixins(RichTextEditor, ARIAGlobalStatesAndProperties); + const nimbleRichTextEditor = RichTextEditor.compose({ baseName: 'rich-text-editor', template, diff --git a/packages/nimble-components/src/rich-text-editor/specs/README.md b/packages/nimble-components/src/rich-text-editor/specs/README.md index d5928ee243..1157db3c4b 100644 --- a/packages/nimble-components/src/rich-text-editor/specs/README.md +++ b/packages/nimble-components/src/rich-text-editor/specs/README.md @@ -102,8 +102,8 @@ Example usage of the `nimble-rich-text-editor` in the application layer is as fo _Props/Attrs_ -- `empty` - is a read-only property that indicates whether the editor is empty or not. This will be achieved through Tiptap's - [isEmpty](https://tiptap.dev/api/editor#is-empty) API. The component and the Angular directive will have a getter method +- `empty` - is a read-only property that indicates whether the editor is empty or not. This will be achieved by retrieving the current text + content from the editor and calculating its length. The component and the Angular directive will have a getter method that can be used to bind it in the Angular application. - `fit-to-content` - is a boolean attribute allows the text area to expand vertically to fit the content. - `placeholder` - is a string attribute to include a placeholder text for the editor when it is empty. This text is passed as plain text (not markdown) @@ -150,6 +150,14 @@ problematic when attempting to clear the editor's content by setting the markdow empty and hasn't undergone processing. To overcome this issue, utilizing `methods` could offer a potential solution, allowing the content to be set regardless of whether it has changed from its previous value. +_empty_ + +We considered utilizing Tiptap's [isEmpty](https://tiptap.dev/api/editor#is-empty) API to determine whether the editor is empty. However, this API +does not return true if the editor only consists of whitespace. In the context of the comments feature, this property is exposed to find out the +editor's empty state, even when it contains only whitespace. This is necessary because the Backend service for comments does not permit the +creation of comments comprised of just whitespace. Consequently, by using this property, we should disable the `OK` button when the editor is +empty. To achieve this, we retrieve the current text content value, trim the string, and return true if its length is zero. + _Events_ - `input` - event emitted when there is a change in the editor. This can be achieved through Tiptap's [update event](https://tiptap.dev/api/events#update). diff --git a/packages/nimble-components/src/rich-text-editor/styles.ts b/packages/nimble-components/src/rich-text-editor/styles.ts index e32148e60e..c9d818ce64 100644 --- a/packages/nimble-components/src/rich-text-editor/styles.ts +++ b/packages/nimble-components/src/rich-text-editor/styles.ts @@ -1,17 +1,24 @@ import { css } from '@microsoft/fast-element'; import { display } from '@microsoft/fast-foundation'; import { + bodyDisabledFontColor, bodyFont, bodyFontColor, borderHoverColor, borderRgbPartialColor, borderWidth, + controlLabelFontColor, + controlLabelDisabledFontColor, + failColor, + iconSize, smallDelay, standardPadding } from '../theme-provider/design-tokens'; +import { styles as errorStyles } from '../patterns/error/styles'; export const styles = css` ${display('inline-flex')} + ${errorStyles} :host { font: ${bodyFont}; @@ -21,6 +28,10 @@ export const styles = css` --ni-private-rich-text-editor-hover-indicator-width: calc( ${borderWidth} + 1px ); + ${ + /** Initial height of rich text editor with one line space when the footer is visible. */ '' + } + height: 82px; --ni-private-rich-text-editor-footer-section-height: 40px; ${ /** Minimum width is added to accommodate all the possible buttons in the toolbar and to support the mobile width. */ '' @@ -29,6 +40,7 @@ export const styles = css` } .container { + box-sizing: border-box; display: flex; flex-direction: column; position: relative; @@ -60,38 +72,56 @@ export const styles = css` } } + :host([disabled]) .container { + color: ${bodyDisabledFontColor}; + border: ${borderWidth} solid rgba(${borderRgbPartialColor}, 0.1); + } + + :host([error-visible]) .container { + border-bottom-color: ${failColor}; + } + :host(:hover) .container::after { - width: 100%; + width: calc(100% + 2 * ${borderWidth}); + } + + :host([disabled]:hover) .container::after { + width: 0px; + } + + :host([error-visible]) .container::after { + border-bottom-color: ${failColor}; + } + + .editor-container { + display: contents; } .editor { + display: flex; + flex-direction: column; border: ${borderWidth} solid transparent; border-radius: 0px; - height: calc( - 100% - var(--ni-private-rich-text-editor-footer-section-height) - ); - overflow: auto; + flex: 1; + overflow: hidden; } - .editor-container { - display: contents; + :host([footer-hidden]) .editor { + height: 100%; } .ProseMirror { - ${ - /** - * Min height represents the one line space for the initial view and max height is referred from the visual design. - * However, max height will be `fit-content` when the `fit-to-content` attribute for the editor component is implemented. - */ '' - } - min-height: 32px; - max-height: 132px; + overflow: auto; height: 100%; - border: ${borderWidth} solid transparent; + border: 0px; border-radius: 0px; background-color: transparent; font: inherit; padding: 8px; + ${ + /* This padding ensures that showing/hiding the error icon doesn't affect text layout */ '' + } + padding-right: calc(${iconSize}); box-sizing: border-box; position: relative; color: inherit; @@ -139,15 +169,39 @@ export const styles = css` margin-block: 0; } + ${ + /** + * Styles provided by Tiptap are necessary to display the placeholder value when the editor is empty. + * Tiptap doc reference: https://tiptap.dev/api/extensions/placeholder#additional-setup + */ '' + } + .ProseMirror p.is-editor-empty:first-child::before { + color: ${controlLabelFontColor}; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + word-break: break-word; + } + + :host([disabled]) .ProseMirror p.is-editor-empty:first-child::before { + color: ${controlLabelDisabledFontColor}; + } + .footer-section { display: flex; justify-content: space-between; + flex-shrink: 0; border: ${borderWidth} solid transparent; border-top-color: rgba(${borderRgbPartialColor}, 0.1); height: var(--ni-private-rich-text-editor-footer-section-height); overflow: hidden; } + :host([footer-hidden]) .footer-section { + display: none; + } + nimble-toolbar::part(positioning-region) { background: transparent; padding-right: 8px; @@ -164,4 +218,15 @@ export const styles = css` gap: ${standardPadding}; place-items: center; } + + :host([error-visible]) .error-icon { + display: none; + } + + :host([error-visible]) .error-icon.scrollbar-width-calculated { + display: inline-flex; + position: absolute; + top: calc(${standardPadding} / 2); + right: var(--ni-private-rich-text-editor-scrollbar-width); + } `; diff --git a/packages/nimble-components/src/rich-text-editor/template.ts b/packages/nimble-components/src/rich-text-editor/template.ts index e8db67808a..68dd0ce93d 100644 --- a/packages/nimble-components/src/rich-text-editor/template.ts +++ b/packages/nimble-components/src/rich-text-editor/template.ts @@ -6,6 +6,8 @@ import { iconBoldBTag } from '../icons/bold-b'; import { iconItalicITag } from '../icons/italic-i'; import { iconListTag } from '../icons/list'; import { iconNumberListTag } from '../icons/number-list'; +import { errorTextTemplate } from '../patterns/error/template'; +import { iconExclamationMarkTag } from '../icons/exclamation-mark'; // prettier-ignore export const template = html` @@ -13,12 +15,18 @@ export const template = html`
-
`; diff --git a/packages/nimble-components/src/rich-text-editor/testing/rich-text-editor.pageobject.ts b/packages/nimble-components/src/rich-text-editor/testing/rich-text-editor.pageobject.ts index 2ad02b8ee3..478a087dde 100644 --- a/packages/nimble-components/src/rich-text-editor/testing/rich-text-editor.pageobject.ts +++ b/packages/nimble-components/src/rich-text-editor/testing/rich-text-editor.pageobject.ts @@ -72,38 +72,22 @@ export class RichTextEditorPageObject { await waitForUpdatesAsync(); } - /** - * To click a formatting button in the footer section, pass its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public async clickFooterButton(button: ToolbarButton): Promise { const toggleButton = this.getFormattingButton(button); toggleButton!.click(); await waitForUpdatesAsync(); } - /** - * To retrieve the checked state of the button, provide its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public getButtonCheckedState(button: ToolbarButton): boolean { const toggleButton = this.getFormattingButton(button); return toggleButton!.checked; } - /** - * To retrieve the tab index of the button, provide its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public getButtonTabIndex(button: ToolbarButton): number { const toggleButton = this.getFormattingButton(button); return toggleButton!.tabIndex; } - /** - * To trigger a space key press for the button, provide its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public spaceKeyActivatesButton(button: ToolbarButton): void { const toggleButton = this.getFormattingButton(button)!; const event = new KeyboardEvent('keypress', { @@ -112,10 +96,6 @@ export class RichTextEditorPageObject { toggleButton.control.dispatchEvent(event); } - /** - * To trigger a enter key press for the button, provide its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public enterKeyActivatesButton(button: ToolbarButton): void { const toggleButton = this.getFormattingButton(button)!; const event = new KeyboardEvent('keypress', { @@ -156,10 +136,53 @@ export class RichTextEditorPageObject { .map(el => el.textContent || ''); } + public getEditorTabIndex(): string { + return this.getTiptapEditor()?.getAttribute('tabindex') ?? ''; + } + + public async setFooterHidden(footerHidden: boolean): Promise { + if (footerHidden) { + this.richTextEditorElement.setAttribute('footer-hidden', ''); + } else { + this.richTextEditorElement.removeAttribute('footer-hidden'); + } + await waitForUpdatesAsync(); + } + + public isFooterHidden(): boolean { + const footerSection = this.getFooter()!; + return window.getComputedStyle(footerSection).display === 'none'; + } + + public async setDisabled(disabled: boolean): Promise { + if (disabled) { + this.richTextEditorElement.setAttribute('disabled', ''); + } else { + this.richTextEditorElement.removeAttribute('disabled'); + } + await waitForUpdatesAsync(); + } + + public isButtonDisabled(button: ToolbarButton): boolean { + const toggleButton = this.getFormattingButton(button)!; + return toggleButton.hasAttribute('disabled'); + } + + public getPlaceholderValue(): string { + const editor = this.getTiptapEditor()!; + return editor.firstElementChild?.getAttribute('data-placeholder') ?? ''; + } + private getEditorSection(): Element | null | undefined { return this.richTextEditorElement.shadowRoot?.querySelector('.editor'); } + private getFooter(): Element | null | undefined { + return this.richTextEditorElement.shadowRoot!.querySelector( + '.footer-section' + ); + } + private getTiptapEditor(): Element | null | undefined { return this.richTextEditorElement.shadowRoot?.querySelector( '.ProseMirror' @@ -167,11 +190,11 @@ export class RichTextEditorPageObject { } private getFormattingButton( - index: ToolbarButton + button: ToolbarButton ): ToggleButton | null | undefined { const buttons: NodeListOf = this.richTextEditorElement.shadowRoot!.querySelectorAll( 'nimble-toggle-button' ); - return buttons[index]; + return buttons[button]; } } diff --git a/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor-matrix.stories.ts b/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor-matrix.stories.ts index a88a07e49a..4503e28c77 100644 --- a/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor-matrix.stories.ts +++ b/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor-matrix.stories.ts @@ -16,6 +16,12 @@ import { } from '../../theme-provider/design-token-names'; import { buttonTag } from '../../button'; import { loremIpsum } from '../../utilities/tests/lorem-ipsum'; +import { + DisabledState, + ErrorState, + disabledStates, + errorStates +} from '../../utilities/tests/states'; const metadata: Meta = { title: 'Tests/Rich Text Editor', @@ -28,9 +34,43 @@ const richTextMarkdownString = '1. **Bold*Italics***'; export default metadata; +const footerHiddenStates = [ + ['Footer Visible', false], + ['Footer Hidden', true] +] as const; +type FooterHiddenState = (typeof footerHiddenStates)[number]; + +const placeholderValueStates = [ + ['', null], + ['Placeholder', 'Placeholder text'] +] as const; +type PlaceholderValueStates = (typeof placeholderValueStates)[number]; + // prettier-ignore -const component = (): ViewTemplate => html` - <${richTextEditorTag}> +const component = ( + [disabledName, disabled]: DisabledState, + [footerHiddenName, footerHidden]: FooterHiddenState, + [errorStateName, isError, errorText]: ErrorState, + [placeholderName, placeholderText]: PlaceholderValueStates +): ViewTemplate => html` +

+ ${() => footerHiddenName} ${() => errorStateName} ${() => placeholderName} ${() => disabledName} +

+ <${richTextEditorTag} + style="margin: 5px 0px; width: 500px;" + ?disabled="${() => disabled}" + ?footer-hidden="${() => footerHidden}" + ?error-visible="${() => isError}" + error-text="${() => errorText}" + placeholder="${() => placeholderText}" + > + `; const playFunction = (): void => { @@ -38,15 +78,22 @@ const playFunction = (): void => { editorNodeList.forEach(element => element.setMarkdown(richTextMarkdownString)); }; +const longTextPlayFunction = (): void => { + const editorNodeList = document.querySelectorAll('nimble-rich-text-editor'); + editorNodeList.forEach(element => element.setMarkdown( + `${loremIpsum}\n\n **${loremIpsum}**\n\n ${loremIpsum}` + )); +}; + const editorSizingTestCase = ( [widthLabel, widthStyle]: [string, string], [heightLabel, heightStyle]: [string, string] ): ViewTemplate => html`

${widthLabel}; ${heightLabel}

+ )}); margin-bottom: 0px;">${() => widthLabel}; ${() => heightLabel}

- <${richTextEditorTag} style="${widthStyle}; ${heightStyle};"> + <${richTextEditorTag} style="${() => widthStyle}; ${() => heightStyle};"> <${buttonTag} slot="footer-actions" appearance="ghost">Cancel <${buttonTag} slot="footer-actions" appearance="outline">Ok @@ -54,11 +101,34 @@ const editorSizingTestCase = ( `; export const richTextEditorThemeMatrix: StoryFn = createMatrixThemeStory( - createMatrix(component) + createMatrix(component, [ + disabledStates, + footerHiddenStates, + errorStates, + [placeholderValueStates[0]] + ]) ); - richTextEditorThemeMatrix.play = playFunction; +export const errorStateThemeMatrixWithLengthyContent: StoryFn = createMatrixThemeStory( + createMatrix(component, [ + [disabledStates[0]], + [footerHiddenStates[0]], + errorStates, + [placeholderValueStates[0]] + ]) +); +errorStateThemeMatrixWithLengthyContent.play = longTextPlayFunction; + +export const placeholderStateThemeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component, [ + disabledStates, + [footerHiddenStates[0]], + [errorStates[0]], + placeholderValueStates + ]) +); + export const richTextEditorSizing: StoryFn = createStory(html` ${createMatrix(editorSizingTestCase, [ [ @@ -75,14 +145,13 @@ export const richTextEditorSizing: StoryFn = createStory(html` `); const mobileWidthComponent = html` - <${richTextEditorTag} style="padding: 20px; width: 300px;"> + <${richTextEditorTag} style="padding: 20px; width: 300px; height: 250px;"> <${buttonTag} slot="footer-actions" appearance="ghost">Cancel <${buttonTag} slot="footer-actions" appearance="outline">Ok `; export const plainTextContentInMobileWidth: StoryFn = createStory(mobileWidthComponent); - plainTextContentInMobileWidth.play = (): void => { document.querySelector('nimble-rich-text-editor')!.setMarkdown(loremIpsum); }; @@ -99,7 +168,6 @@ const multipleSubPointsContent = ` 1. Sub point 9`; export const multipleSubPointsContentInMobileWidth: StoryFn = createStory(mobileWidthComponent); - multipleSubPointsContentInMobileWidth.play = (): void => { document .querySelector('nimble-rich-text-editor')! @@ -107,7 +175,6 @@ multipleSubPointsContentInMobileWidth.play = (): void => { }; export const longWordContentInMobileWidth: StoryFn = createStory(mobileWidthComponent); - longWordContentInMobileWidth.play = (): void => { document .querySelector('nimble-rich-text-editor')! @@ -115,6 +182,7 @@ longWordContentInMobileWidth.play = (): void => { 'ThisIsALongWordWithoutSpaceToTestLongWordInSmallWidthThisIsALongWordWithoutSpaceToTestLongWordInSmallWidth' ); }; + export const hiddenRichTextEditor: StoryFn = createStory( hiddenWrapper(html`<${richTextEditorTag} hidden>`) ); diff --git a/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.spec.ts b/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.spec.ts index 761db3fb45..6ea1bc3570 100644 --- a/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.spec.ts +++ b/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.spec.ts @@ -7,6 +7,7 @@ import { wackyStrings } from '../../utilities/tests/wacky-strings'; import type { Button } from '../../button'; import type { ToggleButton } from '../../toggle-button'; import { ToolbarButton } from '../testing/types'; +import { createEventListener } from '../../utilities/tests/component'; async function setup(): Promise> { return fixture( @@ -47,7 +48,7 @@ describe('RichTextEditor', () => { it('should initialize Tiptap editor', () => { expect(pageObject.editorSectionHasChildNodes()).toBeTrue(); expect(pageObject.getEditorSectionFirstElementChildClassName()).toBe( - 'ProseMirror' + 'tiptap ProseMirror' ); }); @@ -63,6 +64,34 @@ describe('RichTextEditor', () => { expect(editor!.getAttribute('aria-multiline')).toBe('true'); }); + it('should initialize "aria-label" with undefined when there is no "aria-label" set in the element', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + + expect(editor!.hasAttribute('aria-label')).toBeFalse(); + }); + + it('should forwards value of aria-label to internal control', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + element.ariaLabel = 'Rich Text Editor'; + + expect(editor!.getAttribute('aria-label')).toBe('Rich Text Editor'); + }); + + it('should support setting blank "aria-label" value when setting empty string', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + element.ariaLabel = ''; + + expect(editor!.getAttribute('aria-label')).toBe(''); + }); + + it('should remove value of aria-label from internal control when cleared from host', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + element.ariaLabel = 'not empty'; + element.ariaLabel = null; + + expect(editor!.getAttribute('aria-label')).toBeNull(); + }); + it('should have either one of the list buttons checked at the same time on click', async () => { expect( pageObject.getButtonCheckedState(ToolbarButton.bulletList) @@ -146,7 +175,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button click check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -180,7 +208,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button key press check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -211,7 +238,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button key press check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -242,7 +268,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button keyboard shortcut check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -274,7 +299,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button not propagate change event to parent element`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -603,7 +627,6 @@ describe('RichTextEditor', () => { wackyStrings.forEach(value => { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" that are unmodified when rendered the same value within paragraph tag`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -914,7 +937,6 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of notSupportedMarkdownStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `string "${value.name}" renders as plain text "${value.name}" within paragraph tag`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -946,7 +968,6 @@ describe('RichTextEditor', () => { focused, disabled ); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" that are unmodified when set the same "${value.name}" within paragraph tag`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -982,7 +1003,6 @@ describe('RichTextEditor', () => { for (const value of modifiedWackyStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" modified when rendered`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1146,7 +1166,6 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of notSupportedMarkdownStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `markdown string "${value.name}" returns as plain text "${value.name}" without any change`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1190,7 +1209,6 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of specialMarkdownStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `special markdown string "${value.name}" returns as plain text "${value.value}" with added esacpe character`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1223,7 +1241,6 @@ describe('RichTextEditor', () => { focused, disabled ); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" returns unmodified when set the same markdown string"${value.name}"`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1254,9 +1271,8 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of wackyStringWithSpecialMarkdownCharacter) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( - ` wacky string contains special markdown syntax "${value.name}" returns as plain text "${value.value}" with added esacpe character`, + ` wacky string contains special markdown syntax "${value.name}" returns as plain text "${value.value}" with added escape character`, // eslint-disable-next-line @typescript-eslint/no-loop-func async () => { element.setMarkdown(value.name); @@ -1286,7 +1302,6 @@ describe('RichTextEditor', () => { for (const value of modifiedWackyStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" returns modified when assigned`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1302,6 +1317,182 @@ describe('RichTextEditor', () => { ); } }); + + describe('disabled state', () => { + it('should reflect disabled value to the aria-disabled of editor-section', async () => { + const editor = element.shadowRoot?.querySelector('.editor'); + expect(editor!.getAttribute('aria-disabled')).toBe('false'); + + await pageObject.setDisabled(true); + + expect(editor!.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should reflect disabled value to the "contenteditable" attribute of tiptap editor', async () => { + const editor = element.shadowRoot?.querySelector('.ProseMirror'); + expect(editor!.getAttribute('contenteditable')).toBe('true'); + + await pageObject.setDisabled(true); + + expect(editor!.getAttribute('contenteditable')).toBe('false'); + }); + + it('should enable the editor when "disabled" attribute is set and removed', async () => { + const editor = element.shadowRoot?.querySelector('.ProseMirror'); + expect(pageObject.getEditorTabIndex()).toBe('0'); + + await pageObject.setDisabled(true); + await pageObject.setDisabled(false); + + expect(editor!.getAttribute('contenteditable')).toBe('true'); + }); + + it('should change the tabindex value of the editor when disabled value changes', async () => { + expect(pageObject.getEditorTabIndex()).toBe('0'); + + await pageObject.setDisabled(true); + + expect(pageObject.getEditorTabIndex()).toBe('-1'); + }); + + describe('should reflect disabled value to the disabled and aria-disabled state of toggle buttons', () => { + const focused: string[] = []; + const disabled: string[] = []; + for (const value of formattingButtons) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `for "${value.name}" button`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + expect( + pageObject.isButtonDisabled( + value.toolbarButtonIndex + ) + ).toBeFalse(); + + await pageObject.setDisabled(true); + + expect( + pageObject.isButtonDisabled( + value.toolbarButtonIndex + ) + ).toBeTrue(); + } + ); + } + }); + }); + + it('should hide the footer when "footer-hidden" attribute is enabled', async () => { + expect(pageObject.isFooterHidden()).toBeFalse(); + + await pageObject.setFooterHidden(true); + + expect(pageObject.isFooterHidden()).toBeTrue(); + }); + + it('should show the footer when "footer-hidden" attribute is disabled', async () => { + expect(pageObject.isFooterHidden()).toBeFalse(); + + await pageObject.setFooterHidden(true); + await pageObject.setFooterHidden(false); + + expect(pageObject.isFooterHidden()).toBeFalse(); + }); + + it('should fire "input" event when there is an input to the editor', async () => { + const inputEventListener = createEventListener(element, 'input'); + + await pageObject.setEditorTextContent('input'); + await inputEventListener.promise; + + expect(inputEventListener.spy).toHaveBeenCalledTimes(1); + }); + + it('should not fire "input" event when setting the content through "setMarkdown"', () => { + const inputEventListener = createEventListener(element, 'input'); + + element.setMarkdown('input'); + + expect(inputEventListener.spy).not.toHaveBeenCalled(); + }); + + it('should fire "input" event when the text is updated/removed from the editor', async () => { + const inputEventListener = createEventListener(element, 'input'); + + await pageObject.setEditorTextContent('update'); + await inputEventListener.promise; + + expect(inputEventListener.spy).toHaveBeenCalledTimes(1); + + await pageObject.setEditorTextContent(''); + await inputEventListener.promise; + + expect(inputEventListener.spy).toHaveBeenCalledTimes(1); + }); + + it('should initialize "empty" to true and set false when there is content', async () => { + expect(element.empty).toBeTrue(); + + await pageObject.setEditorTextContent('not empty'); + expect(element.empty).toBeFalse(); + + await pageObject.setEditorTextContent(''); + expect(element.empty).toBeTrue(); + }); + + it('should update "empty" when the content is loaded with "setMarkdown"', () => { + expect(element.empty).toBeTrue(); + + element.setMarkdown('not empty'); + expect(element.empty).toBeFalse(); + + element.setMarkdown(''); + expect(element.empty).toBeTrue(); + }); + + it('should return true for "empty" if there is only whitespace', async () => { + expect(element.empty).toBeTrue(); + + await pageObject.setEditorTextContent(' '); + expect(element.empty).toBeTrue(); + + element.setMarkdown(' '); + expect(element.empty).toBeTrue(); + }); + + it('should return true for "empty" even if the placeholder content is set', () => { + expect(element.empty).toBeTrue(); + + element.placeholder = 'Placeholder text'; + expect(element.empty).toBeTrue(); + }); + + it('should initialize the "placeholder" attribute with undefined', () => { + expect(element.placeholder).toBeUndefined(); + }); + + it('should reflect the "placeholder" value to its internal attribute', () => { + expect(pageObject.getPlaceholderValue()).toBe(''); + + element.placeholder = 'Placeholder text'; + + expect(pageObject.getPlaceholderValue()).toBe('Placeholder text'); + }); + + it('should set "placeholder" value to empty when attribute is cleared with an empty string', () => { + element.placeholder = 'Placeholder text'; + + expect(pageObject.getPlaceholderValue()).toBe('Placeholder text'); + + element.placeholder = ''; + + expect(pageObject.getPlaceholderValue()).toBe(''); + }); }); describe('RichTextEditor Before DOM Connection', () => { diff --git a/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.stories.ts b/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.stories.ts index ff2adc95cf..ae90e198d6 100644 --- a/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.stories.ts +++ b/packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.stories.ts @@ -1,5 +1,6 @@ import { html, ref, when } from '@microsoft/fast-element'; import type { Meta, StoryObj } from '@storybook/html'; +import { withActions } from '@storybook/addon-actions/decorator'; import { createUserSelectedThemeStory, incubatingWarning @@ -14,6 +15,13 @@ interface RichTextEditorArgs { getMarkdown: undefined; editorRef: RichTextEditor; setMarkdownData: (args: RichTextEditorArgs) => void; + disabled: boolean; + footerHidden: boolean; + errorVisible: boolean; + errorText: string; + input: unknown; + empty: unknown; + placeholder: string; } type ExampleDataType = (typeof exampleDataType)[keyof typeof exampleDataType]; @@ -54,11 +62,15 @@ client application must implement that functionality. const metadata: Meta = { title: 'Incubating/Rich Text Editor', tags: ['autodocs'], + decorators: [withActions], parameters: { docs: { description: { component: richTextEditorDescription } + }, + actions: { + handles: ['input'] } }, // prettier-ignore @@ -70,6 +82,11 @@ const metadata: Meta = { <${richTextEditorTag} ${ref('editorRef')} data-unused="${x => x.setMarkdownData(x)}" + ?disabled="${x => x.disabled}" + ?footer-hidden="${x => x.footerHidden}" + ?error-visible="${x => x.errorVisible}" + error-text="${x => x.errorText}" + placeholder="${x => x.placeholder}" > ${when(x => x.footerActionButtons, html` <${buttonTag} appearance="ghost" slot="footer-actions">Cancel @@ -103,11 +120,43 @@ const metadata: Meta = { }, setMarkdownData: { table: { disable: true } + }, + errorVisible: { + description: + 'Whether the editor should be styled to indicate that it is in an invalid state.' + }, + errorText: { + description: + 'A message to be displayed when the editor is in the invalid state explaining why the value is invalid.' + }, + placeholder: { + description: 'Placeholder text to show when editor is empty.' + }, + footerHidden: { + description: + 'Setting `footer-hidden` hides the footer section which consists of all formatting option buttons and the `footer-actions` slot content.' + }, + empty: { + name: 'empty', + description: + 'Read-only boolean value. Returns true if editor is either empty or contains only whitespace.', + control: false + }, + input: { + name: 'input', + description: + 'This event is fired when there is a change in the content of the editor.', + control: false } }, args: { data: exampleDataType.plainString, footerActionButtons: false, + disabled: false, + footerHidden: false, + errorVisible: false, + errorText: 'Value is invalid', + placeholder: 'Placeholder', editorRef: undefined, setMarkdownData: x => { void (async () => {