Merge "Make <gr-textarea> and <iron-autogrow-textarea> swappable"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index ef02c95..701052d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -592,7 +592,7 @@
       element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
       await element.updateComplete;
-      pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
+      pressKey(element.textarea!, 's', Modifier.CTRL_KEY);
       assert.isTrue(spy.called);
     });
 
@@ -602,11 +602,7 @@
         element.messageText = 'is that the horse from horsing around??';
         element.editing = true;
         await element.updateComplete;
-        pressKey(
-          element.textarea!.textarea!.textarea,
-          Key.ENTER,
-          Modifier.CTRL_KEY
-        );
+        pressKey(element.textarea!, Key.ENTER, Modifier.CTRL_KEY);
         assert.isTrue(spy.called);
       });
       test('propagates on patchset comment', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 78b7610..04f66a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -7,6 +7,7 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
+import '../../../embed/gr-textarea';
 import {getAppContext} from '../../../services/app-context';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {
@@ -17,7 +18,7 @@
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html} from 'lit';
+import {LitElement, TemplateResult, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
@@ -31,6 +32,8 @@
 import {getAccountDisplayName} from '../../../utils/display-name-util';
 import {configModelToken} from '../../../models/config/config-model';
 import {formStyles} from '../../../styles/form-styles';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {GrTextarea} from '../../../embed/gr-textarea';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -58,6 +61,120 @@
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
+/** Allows us to swap out <iron-autogrow-textare> for <gr-textarea>. */
+abstract class TextAreaWrapper {
+  constructor(readonly el: GrSuggestionTextarea) {}
+
+  abstract render(): TemplateResult;
+
+  abstract isFocused(): boolean;
+
+  abstract focus(): void;
+
+  abstract putCursorAtEnd(): void;
+
+  abstract getCursorPosition(): number;
+
+  abstract setCursorPosition(pos: number): void;
+}
+
+class IronWrapper extends TextAreaWrapper {
+  override render() {
+    return html`
+      <iron-autogrow-textarea
+        id="textarea"
+        class=${classMap({noBorder: this.el.hideBorder})}
+        .autocomplete=${this.el.autocomplete}
+        .placeholder=${this.el.placeholder}
+        ?disabled=${this.el.disabled}
+        .rows=${this.el.rows}
+        .maxRows=${this.el.maxRows}
+        .value=${this.el.text}
+        @value-changed=${(e: ValueChangedEvent) => {
+          this.el.text = e.detail.value;
+        }}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  getIronTextarea(): IronAutogrowTextareaElement | undefined {
+    return this.el.textarea as IronAutogrowTextareaElement | undefined;
+  }
+
+  private getNativeTextarea(): HTMLTextAreaElement | undefined {
+    return this.getIronTextarea()?.textarea;
+  }
+
+  isFocused() {
+    return !!this.getIronTextarea()?.focused;
+  }
+
+  focus() {
+    this.getNativeTextarea()?.focus();
+  }
+
+  putCursorAtEnd() {
+    const textarea = this.getNativeTextarea();
+    if (!textarea) return;
+    const length = this.el.text.length;
+    textarea.selectionStart = length;
+    textarea.selectionEnd = length;
+    textarea.focus();
+  }
+
+  getCursorPosition(): number {
+    return this.getNativeTextarea()?.selectionStart ?? -1;
+  }
+
+  setCursorPosition(pos: number) {
+    const textarea = this.getNativeTextarea();
+    if (!textarea) return;
+    textarea.selectionStart = pos;
+    textarea.selectionEnd = pos;
+  }
+}
+
+class GrWrapper extends TextAreaWrapper {
+  override render() {
+    return html`<gr-textarea
+      id="textarea"
+      putCursorAtEndOnFocus
+      class=${classMap({noBorder: this.el.hideBorder})}
+      .placeholder=${this.el.placeholder}
+      ?disabled=${this.el.disabled}
+      .value=${this.el.text}
+      @input=${(e: InputEvent) => {
+        const value = (e.target as GrTextarea).value;
+        this.el.text = value ?? '';
+      }}
+    ></gr-textarea>`;
+  }
+
+  getGrTextarea(): GrTextarea | undefined {
+    return this.el.textarea as GrTextarea | undefined;
+  }
+
+  isFocused() {
+    return !!this.getGrTextarea()?.isFocused;
+  }
+
+  focus() {
+    this.getGrTextarea()?.focus();
+  }
+
+  putCursorAtEnd() {
+    this.getGrTextarea()?.putCursorAtEnd();
+  }
+
+  getCursorPosition(): number {
+    return this.getGrTextarea()?.getCursorPosition() ?? -1;
+  }
+
+  setCursorPosition(pos: number) {
+    this.getGrTextarea()?.setCursorPosition(pos);
+  }
+}
+
 export interface EmojiSuggestion extends Item {
   match: string;
 }
@@ -77,7 +194,9 @@
   /**
    * @event bind-value-changed
    */
-  @query('#textarea') textarea?: IronAutogrowTextareaElement;
+  @query('#textarea') textarea?:
+    | (IronAutogrowTextareaElement & LitElement)
+    | GrTextarea;
 
   @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
 
@@ -113,6 +232,8 @@
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
+  private readonly flagService = getAppContext().flagsService;
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
@@ -131,6 +252,8 @@
   // private but used in tests
   currentSearchString?: string;
 
+  wrapper: TextAreaWrapper = new IronWrapper(this);
+
   private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
@@ -170,6 +293,10 @@
 
   override connectedCallback() {
     super.connectedCallback();
+
+    const enabled = this.flagService.isEnabled(KnownExperimentId.GR_TEXTAREA);
+    this.wrapper = enabled ? new GrWrapper(this) : new IronWrapper(this);
+
     if (this.monospace) {
       this.classList.add('monospace');
     }
@@ -206,14 +333,21 @@
         #textarea {
           background-color: var(--view-background-color);
           width: 100%;
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: 0;
+          box-sizing: border-box;
+          position: relative;
+          --gr-textarea-padding: var(--spacing-s);
+          --gr-textarea-border-width: 0px;
+          --text-secondary: var(--deemphasized-text-color);
+          --iron-autogrow-textarea_-_padding: var(--spacing-s);
         }
         #hiddenText #emojiSuggestions {
           visibility: visible;
           white-space: normal;
         }
-        iron-autogrow-textarea {
-          position: relative;
-        }
         #textarea.noBorder {
           border: none;
         }
@@ -237,19 +371,7 @@
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
       <span id="caratSpan"></span>
       ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
-      <iron-autogrow-textarea
-        id="textarea"
-        class=${classMap({noBorder: this.hideBorder})}
-        .autocomplete=${this.autocomplete}
-        .placeholder=${this.placeholder}
-        ?disabled=${this.disabled}
-        .rows=${this.rows}
-        .maxRows=${this.maxRows}
-        .value=${this.text}
-        @value-changed=${(e: ValueChangedEvent) => {
-          this.text = e.detail.value;
-        }}
-      ></iron-autogrow-textarea>
+      ${this.wrapper.render()}
     `;
   }
 
@@ -282,9 +404,6 @@
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('text')) {
       this.fireChangedEvents();
-      // Add to updated because we want this.textarea.selectionStart and
-      // this.textarea is null in the willUpdate lifecycle
-      this.computeIndexAndSearchString();
       this.handleTextChanged();
     }
   }
@@ -295,22 +414,14 @@
     this.emojiSuggestions?.close();
   }
 
-  getNativeTextarea() {
-    return this.textarea!.textarea;
-  }
-
+  // Note that this may not work as intended, because the textarea is not
+  // rendered yet.
   override focus() {
-    // Note that this may not work as intended, because the textarea is not
-    // rendered yet.
-    this.textarea?.textarea.focus();
+    this.wrapper.focus();
   }
 
   putCursorAtEnd() {
-    const textarea = this.getNativeTextarea();
-    // Put the cursor at the end always.
-    textarea.selectionStart = textarea.value.length;
-    textarea.selectionEnd = textarea.selectionStart;
-    textarea.focus();
+    this.wrapper.putCursorAtEnd();
   }
 
   private getVisibleDropdown() {
@@ -433,8 +544,7 @@
     // below needs to happen after iron-autogrow-textarea has set the
     // incorrect value.
     await this.updateComplete;
-    this.textarea!.selectionStart = specialCharIndex + text.length + move;
-    this.textarea!.selectionEnd = specialCharIndex + text.length + move;
+    this.wrapper.setCursorPosition(specialCharIndex + text.length + move);
     this.resetDropdown();
   }
 
@@ -456,12 +566,11 @@
    * private but used in test
    */
   updateCaratPosition() {
-    if (typeof this.textarea!.value === 'string') {
-      this.hiddenText!.textContent = this.textarea!.value.substring(
-        0,
-        this.textarea!.selectionStart
-      );
+    let position = this.wrapper.getCursorPosition();
+    if (position === -1) {
+      position = this.text.length;
     }
+    this.hiddenText!.textContent = this.text.substring(0, position);
 
     const caratSpan = this.caratSpan!;
     this.hiddenText!.appendChild(caratSpan);
@@ -474,9 +583,9 @@
     // - The search string is an space or new line
     // - The colon has been removed
     // - There are no suggestions that match the search string
+    const position = this.wrapper.getCursorPosition();
     return (
-      this.textarea!.selectionStart !==
-        (this.currentSearchString ?? '').length + charIndex + 1 ||
+      position !== (this.currentSearchString ?? '').length + charIndex + 1 ||
       this.currentSearchString === ' ' ||
       this.currentSearchString === '\n' ||
       !(text[charIndex] === char)
@@ -522,7 +631,7 @@
       )
     ) {
       this.resetDropdown();
-    } else if (activeDropdown!.isHidden && this.textarea!.focused) {
+    } else if (activeDropdown!.isHidden && this.wrapper.isFocused()) {
       // Otherwise open the dropdown and set the position to be just below the
       // cursor.
       // Do not open dropdown if textarea is not focused
@@ -543,8 +652,11 @@
     );
   }
 
-  private computeIndexAndSearchString() {
-    const currentCarat = this.textarea?.selectionStart ?? this.text.length;
+  public computeIndexAndSearchString() {
+    let currentCarat = this.wrapper.getCursorPosition();
+    if (currentCarat === -1) {
+      currentCarat = this.text.length;
+    }
     const m = this.text
       .substring(0, currentCarat)
       .match(/(?:^|\s)([:@][\S]*)$/);
@@ -561,6 +673,7 @@
 
   // Private but used in tests.
   async handleTextChanged() {
+    this.computeIndexAndSearchString();
     await this.computeSuggestions();
     this.openOrResetDropdown();
     this.focus();
@@ -643,10 +756,8 @@
     // When nothing is selected, selectionStart is the caret position. We want
     // the indentation level of the current line, not the end of the text which
     // may be different.
-    const currentLine = this.textarea!.textarea.value.substring(
-      0,
-      this.textarea!.selectionStart
-    )
+    const currentLine = this.text
+      .substring(0, this.wrapper.getCursorPosition())
       .split('\n')
       .pop();
     const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
index e73f685..7ad3429 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
@@ -13,17 +13,39 @@
 import {
   mockPromise,
   pressKey,
+  stubFlags,
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
-suite('gr-suggestion-textarea tests', () => {
+suite('gr-suggestion-textarea tests with <gr-textarea>', () =>
+  createSuite(true)
+);
+
+suite('gr-suggestion-textarea tests with <iron-autogrow-textarea>', () =>
+  createSuite(false)
+);
+
+function createSuite(grTextareaEnabled: boolean) {
   let element: GrSuggestionTextarea;
 
+  const setText = async (text: string) => {
+    element.text = text;
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.wrapper.setCursorPosition(text.length);
+    element.handleTextChanged();
+    await element.updateComplete;
+  };
+
   setup(async () => {
+    stubFlags('isEnabled')
+      .withArgs(KnownExperimentId.GR_TEXTAREA)
+      .returns(grTextareaEnabled);
     element = await fixture<GrSuggestionTextarea>(
       html`<gr-suggestion-textarea></gr-suggestion-textarea>`
     );
@@ -32,6 +54,18 @@
   });
 
   test('renders', () => {
+    const textareaHtml = grTextareaEnabled
+      ? /* HTML */ `
+          <gr-textarea putcursoratendonfocus id="textarea"> </gr-textarea>
+        `
+      : /* HTML */ `
+          <iron-autogrow-textarea
+            aria-disabled="false"
+            focused=""
+            id="textarea"
+          >
+          </iron-autogrow-textarea>
+        `;
     assert.shadowDom.equal(
       element,
       /* HTML */ `<div id="hiddenText"></div>
@@ -44,8 +78,7 @@
           role="listbox"
         >
         </gr-autocomplete-dropdown>
-        <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea>`,
+        ${textareaHtml}`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -68,17 +101,14 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
-
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await waitUntil(() => element.wrapper.isFocused() === true);
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
       assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
-      assert.isTrue(element.textarea!.focused);
+      assert.isTrue(element.wrapper.isFocused());
 
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
@@ -87,8 +117,7 @@
       assert.isFalse(element.mentionsSuggestions!.isHidden);
       assert.equal(element.currentSearchString, '');
 
-      element.text = '@abc@google.com';
-      await element.updateComplete;
+      await setText('@abc@google.com');
 
       assert.equal(element.currentSearchString, 'abc@google.com');
       assert.equal(element.specialCharIndex, 0);
@@ -106,11 +135,9 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '\n@';
+      await setText('\n@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -134,14 +161,12 @@
       const promise = mockPromise<Item[]>();
       stubRestApi('queryAccounts').returns(promise);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
       element.suggestions = [
         {dataValue: 'prior@google.com', text: 'Prior suggestion'},
       ];
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await element.updateComplete;
       assert.equal(element.suggestions.length, 0);
@@ -169,16 +194,13 @@
       const suggestionStub = stubRestApi('queryAccounts');
       suggestionStub.returns(promise1);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
-      await element.updateComplete;
+      await setText('@');
       assert.equal(element.currentSearchString, '');
 
       suggestionStub.returns(promise2);
-      element.text = '@abc@google.com';
+      await setText('@abc@google.com');
       // None of suggestions returned yet.
       assert.equal(element.suggestions.length, 0);
       await element.updateComplete;
@@ -231,11 +253,9 @@
       );
 
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -255,7 +275,6 @@
     test('emoji dropdown does not open if mention dropdown is open', async () => {
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
-      const resetSpy = sinon.spy(element, 'resetDropdown');
       stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
@@ -263,11 +282,9 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
       element.suggestions = [
         {
           name: 'a',
@@ -277,30 +294,28 @@
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
-      assert.isFalse(resetSpy.called);
-
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h';
+      await setText('@h');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h';
+      await setText('@h');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h:';
+      await setText('@h:');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h:D';
+      await setText('@h:D');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -311,11 +326,9 @@
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
+      await setText(':');
       element.suggestions = [
         {
           name: 'a',
@@ -327,23 +340,23 @@
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D';
+      await setText(':D');
       await element.updateComplete;
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@';
+      await setText(':D@');
       await element.updateComplete;
       // emoji dropdown hidden since we have no more suggestions
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@b';
+      await setText(':D@b');
       await element.updateComplete;
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@b ';
+      await setText(':D@b ');
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
@@ -358,11 +371,9 @@
       );
 
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.wrapper.isFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -387,8 +398,7 @@
     // by default textarea has focus when rendered
     // explicitly remove focus from the element for the test
     element.blur();
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
+    element.wrapper.setCursorPosition(1);
     element.text = ':';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -396,9 +406,8 @@
 
   test('emoji selector is not open when a general text is entered', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    element.textarea!.selectionStart = 9;
-    element.textarea!.selectionEnd = 9;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    element.wrapper.setCursorPosition(9);
     element.text = 'some text';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -410,13 +419,13 @@
     const listenerStub = sinon.stub();
     element.addEventListener('text-changed', listenerStub);
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
-    element.text = ':';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText(':');
     assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
-    assert.isTrue(element.textarea!.focused);
+    assert.isTrue(element.wrapper.isFocused());
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    await element.emojiSuggestions!.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 0);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -425,13 +434,8 @@
 
   test('emoji selector opens when a colon is typed after space', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 2;
-    element.textarea!.selectionEnd = 2;
-    element.text = ' :';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText(' :');
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 1);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -440,30 +444,17 @@
 
   test('emoji selector doesn`t open when a colon is typed after character', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 5;
-    element.textarea!.selectionEnd = 5;
-    element.text = 'test:';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText('test:');
     assert.isTrue(element.emojiSuggestions!.isHidden);
     assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
   test('emoji selector opens when a colon is typed and some substring', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
-    element.text = ':';
-    await element.updateComplete;
-    element.textarea!.selectionStart = 2;
-    element.textarea!.selectionEnd = 2;
-    element.text = ':t';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText(':');
+    await setText(':t');
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 0);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -474,19 +465,11 @@
     element.textarea!.focus();
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
+    element.wrapper.setCursorPosition(1);
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 1
     const text = ': hello';
-    sinon.stub(element, 'textarea').value({
-      selectionStart: 1,
-      value: text,
-      focused: true,
-      textarea: {
-        focus: () => {},
-      },
-    });
+    sinon.stub(element.wrapper, 'getCursorPosition').returns(1);
     element.text = text;
     await element.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -497,25 +480,14 @@
 
   test('emoji selector closes when text changes before the colon', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    await element.updateComplete;
-    element.textarea!.selectionStart = 10;
-    element.textarea!.selectionEnd = 10;
-    element.text = 'test test ';
-    await element.updateComplete;
-    element.textarea!.selectionStart = 12;
-    element.textarea!.selectionEnd = 12;
-
-    element.text = 'test test :';
-    await element.updateComplete;
+    await waitUntil(() => element.wrapper.isFocused() === true);
+    await setText('test test ');
+    await setText('test test :');
 
     // typing : opens the selector
     assert.isFalse(element.emojiSuggestions!.isHidden);
 
-    element.textarea!.selectionStart = 15;
-    element.textarea!.selectionEnd = 15;
-    element.text = 'test test :smi';
-    await element.updateComplete;
+    await setText('test test :smi');
 
     assert.equal(element.currentSearchString, 'smi');
     assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -573,10 +545,12 @@
   });
 
   test('handleDropdownItemSelect', async () => {
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.wrapper.setCursorPosition(16);
     element.specialCharIndex = 10;
+    element.handleTextChanged();
     await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
     const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
@@ -587,46 +561,37 @@
 
     // wait for reset dropdown to finish
     await waitUntil(() => element.specialCharIndex === -1);
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
-    element.specialCharIndex = 10;
     await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.wrapper.setCursorPosition(16);
+    await element.updateComplete;
+    element.specialCharIndex = 10;
+    element.handleTextChanged();
     // move the cursor to the left while the suggestion popup is open
-    element.textarea!.selectionStart = 0;
+    element.wrapper.setCursorPosition(0);
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂');
 
     // wait for reset dropdown to finish
     await waitUntil(() => element.specialCharIndex === -1);
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
+    element.wrapper.setCursorPosition(16);
     const text = 'test test :tears happy';
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 16
-    const stub = sinon.stub(element, 'textarea').value({
-      selectionStart: 16,
-      value: text,
-      focused: true,
-      textarea: {
-        focus: () => {},
-      },
-    });
+    const stub = sinon.stub(element.wrapper, 'getCursorPosition').returns(16);
     element.text = text;
     element.specialCharIndex = 10;
     await element.updateComplete;
     stub.restore();
     // move the cursor to the right while the suggestion popup is open
-    element.textarea!.selectionStart = 22;
+    element.wrapper.setCursorPosition(22);
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂 happy');
   });
 
   test('updateCaratPosition', async () => {
-    element.textarea!.selectionStart = 4;
-    element.textarea!.selectionEnd = 4;
-    element.text = 'test';
-    await element.updateComplete;
+    await setText('test');
     element.updateCaratPosition();
     assert.deepEqual(
       element.hiddenText!.innerHTML,
@@ -636,7 +601,7 @@
 
   test('newline receives matching indentation', async () => {
     const indentCommand = sinon.stub(document, 'execCommand');
-    element.textarea!.value = '    a';
+    await setText('    a');
     element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
     await element.updateComplete;
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
@@ -655,24 +620,11 @@
   });
 
   suite('keyboard shortcuts', async () => {
-    async function setupDropdown() {
-      element.textarea!.focus();
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
-      await element.updateComplete;
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 2;
-      element.text = ':1';
-      await element.emojiSuggestions!.updateComplete;
-      await element.updateComplete;
-    }
-
     test('escape key', async () => {
       const resetSpy = sinon.spy(element, 'resetDropdown');
       pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isFalse(resetSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isTrue(resetSpy.called);
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -682,7 +634,7 @@
       const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
       pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isFalse(upSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isTrue(upSpy.called);
     });
@@ -691,7 +643,7 @@
       const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
       pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isFalse(downSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isTrue(downSpy.called);
     });
@@ -700,7 +652,7 @@
       const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
       pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isTrue(enterSpy.called);
       await element.updateComplete;
@@ -737,4 +689,4 @@
       assert.isTrue(element.textarea!.classList.contains('noBorder'));
     });
   });
-});
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
index 79a8578..628b65b 100644
--- a/polygerrit-ui/app/embed/gr-textarea.ts
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -318,7 +318,16 @@
 
     range.detach();
 
-    await this.onCursorPositionChange(null);
+    this.onCursorPositionChange(null);
+  }
+
+  public setCursorPosition(position: number) {
+    this.setCursorPositionForDiv(position, this.editableDivElement);
+  }
+
+  public async setCursorPositionAsync(position: number) {
+    const editableDivElement = await this.editableDiv;
+    this.setCursorPositionForDiv(position, editableDivElement);
   }
 
   /**
@@ -328,12 +337,14 @@
    * If position is out of bounds of value of textarea then cursor is places at
    * end of content of textarea.
    */
-  async setCursorPosition(position: number) {
+  private setCursorPositionForDiv(
+    position: number,
+    editableDivElement?: HTMLDivElement
+  ) {
     // This will keep track of remaining offset to place the cursor.
     let remainingOffset = position;
     let isOnFreshLine = true;
     let nodeToFocusOn: Node | null = null;
-    const editableDivElement = await this.editableDiv;
     const selection = this.getSelection();
 
     if (!editableDivElement || !selection) {
@@ -345,6 +356,10 @@
         const childNode = childNodes[i];
         let currentNodeLength = 0;
 
+        if (childNode.nodeType === Node.COMMENT_NODE) {
+          continue;
+        }
+
         if (childNode.nodeName === 'BR') {
           currentNodeLength++;
           isOnFreshLine = true;
@@ -373,10 +388,9 @@
       }
     };
 
-    // Find the node to focus on.
     findNodeToFocusOn(Array.from(editableDivElement.childNodes));
 
-    await this.setFocusOnNode(
+    this.setFocusOnNode(
       selection,
       editableDivElement,
       nodeToFocusOn,
@@ -403,7 +417,7 @@
       : 'true';
   }
 
-  private async setFocusOnNode(
+  private setFocusOnNode(
     selection: Selection,
     editableDivElement: Node,
     nodeToFocusOn: Node | null,
@@ -432,7 +446,7 @@
 
     range.detach();
 
-    await this.onCursorPositionChange(null);
+    this.onCursorPositionChange(null);
   }
 
   private async onInput(event: Event) {
@@ -451,15 +465,15 @@
     );
   }
 
-  private async onFocus(event: Event) {
+  private onFocus(event: Event) {
     this.focused = true;
-    await this.onCursorPositionChange(event);
+    this.onCursorPositionChange(event);
   }
 
-  private async onBlur(event: Event) {
+  private onBlur(event: Event) {
     this.focused = false;
     this.removeHintSpanIfShown();
-    await this.onCursorPositionChange(event);
+    this.onCursorPositionChange(event);
   }
 
   private async handleKeyDown(event: KeyboardEvent) {
@@ -483,12 +497,12 @@
     await this.toggleHintVisibilityIfAny();
   }
 
-  private async handleKeyUp(event: KeyboardEvent) {
-    await this.onCursorPositionChange(event);
+  private handleKeyUp(event: KeyboardEvent) {
+    this.onCursorPositionChange(event);
   }
 
   private async handleMouseUp(event: MouseEvent) {
-    await this.onCursorPositionChange(event);
+    this.onCursorPositionChange(event);
     await this.toggleHintVisibilityIfAny();
   }
 
@@ -532,7 +546,7 @@
 
     const editableDivElement = await this.editableDiv;
     const currentValue = (await this.getValue()) ?? '';
-    const cursorPosition = await this.getCursorPosition();
+    const cursorPosition = await this.getCursorPositionAsync();
     if (
       !editableDivElement ||
       (this.placeholderHint && !currentValue) ||
@@ -598,21 +612,22 @@
     return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
   }
 
-  private async onCursorPositionChange(event: Event | null) {
+  private onCursorPositionChange(event: Event | null) {
     event?.preventDefault();
     event?.stopImmediatePropagation();
 
     this.dispatchEvent(
       new CustomEvent('cursorPositionChange', {
         detail: {
-          position: await this.getCursorPosition(),
+          position: this.getCursorPosition(),
         },
       })
     );
   }
 
   private async updateValueInDom() {
-    const editableDivElement = await this.editableDiv;
+    const editableDivElement =
+      this.editableDivElement ?? (await this.editableDiv);
     if (editableDivElement) {
       editableDivElement.innerText = this.value || '';
     }
@@ -665,9 +680,17 @@
     return [textValue, isLastBr];
   }
 
-  private async getCursorPosition() {
-    const selection = this.getSelection();
+  public getCursorPosition() {
+    return this.getCursorPositionForDiv(this.editableDivElement);
+  }
+
+  public async getCursorPositionAsync() {
     const editableDivElement = await this.editableDiv;
+    return this.getCursorPositionForDiv(editableDivElement);
+  }
+
+  private getCursorPositionForDiv(editableDivElement?: HTMLDivElement) {
+    const selection = this.getSelection();
 
     // Cursor position is -1 (not available) if
     //
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
index 59388c3..b701dcb 100644
--- a/polygerrit-ui/app/embed/gr-textarea_test.ts
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -115,7 +115,7 @@
       cursorPosition = detail.position;
     });
 
-    await element.setCursorPosition(0);
+    element.setCursorPosition(0);
     await cursorPositionChangeEventPromise;
 
     assert.equal(cursorPosition, 0);
@@ -137,7 +137,7 @@
 
     element.value = 'Some value';
     await element.updateComplete;
-    await element.setCursorPosition(1);
+    element.setCursorPosition(1);
     await cursorPositionChangeEventPromise;
 
     assert.equal(cursorPosition, 1);
@@ -159,7 +159,7 @@
 
     element.value = 'Some \n\n\n value';
     await element.updateComplete;
-    await element.setCursorPosition(7);
+    element.setCursorPosition(7);
     await cursorPositionChangeEventPromise;
 
     assert.equal(cursorPosition, 7);