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}>${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}"
+ >
+ ${richTextEditorTag}>
`;
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}>
<${buttonTag} slot="footer-actions" appearance="outline">Ok${buttonTag}>
${richTextEditorTag}>
@@ -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}>
<${buttonTag} slot="footer-actions" appearance="outline">Ok${buttonTag}>
${richTextEditorTag}>
`;
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>${richTextEditorTag}>`)
);
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${buttonTag}>
@@ -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 () => {