Merge "Do not grant Revert permission to Registered Users by default"
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 8ee7669..754a233 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -7,7 +7,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {
   AutocompleteSuggestion,
   AutocompleteQuery,
@@ -222,7 +222,7 @@
       </h3>
       <fieldset>
         <div>
-          <gr-textarea
+          <gr-suggestion-textarea
             class="description"
             autocomplete="on"
             rows="4"
@@ -230,7 +230,7 @@
             ?disabled=${this.computeGroupDisabled()}
             .text=${this.groupConfig?.description ?? ''}
             @text-changed=${this.handleDescriptionTextChanged}
-          ></gr-textarea>
+          ></gr-suggestion-textarea>
         </div>
         <span class="value">
           <gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 256c6a9..5cf71f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -97,14 +97,14 @@
                 <h3 class="heading-3">Description</h3>
                 <fieldset>
                   <div>
-                    <gr-textarea
+                    <gr-suggestion-textarea
                       autocomplete="on"
                       class="description monospace"
                       disabled=""
                       monospace=""
                       rows="4"
                     >
-                    </gr-textarea>
+                    </gr-suggestion-textarea>
                   </div>
                   <span class="value">
                     <gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 90277534..4e8841e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -9,7 +9,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
 import {
   ConfigInfo,
@@ -244,7 +244,7 @@
     return html`
       <h3 id="Description" class="heading-3">Description</h3>
       <fieldset>
-        <gr-textarea
+        <gr-suggestion-textarea
           id="descriptionInput"
           class="description"
           autocomplete="on"
@@ -254,7 +254,7 @@
           ?disabled=${this.readOnly}
           .text=${this.repoConfig.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
-        ></gr-textarea>
+        ></gr-suggestion-textarea>
       </fieldset>
     `;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 4deb99a..0d30933 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -42,7 +42,7 @@
 import {PageErrorEvent} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
 import {fixture, html, assert} from '@open-wc/testing';
 
@@ -199,7 +199,7 @@
             <fieldset>
               <h3 class="heading-3" id="Description">Description</h3>
               <fieldset>
-                <gr-textarea
+                <gr-suggestion-textarea
                   autocomplete="on"
                   class="description monospace"
                   disabled=""
@@ -208,7 +208,7 @@
                   placeholder="<Insert repo description here>"
                   rows="4"
                 >
-                </gr-textarea>
+                </gr-suggestion-textarea>
               </fieldset>
               <h3 class="heading-3" id="Options">Repository Options</h3>
               <fieldset id="options">
@@ -728,7 +728,7 @@
           '#Title'
         ).classList.contains('edited')
       );
-      queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+      queryAndAssert<GrSuggestionTextarea>(element, '#descriptionInput').text =
         configInputObj.description;
       queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
         configInputObj.state;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index ad3e15c..30f4bf8 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -458,7 +458,9 @@
     // app.
     assign(
       window.location,
-      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+      `${basePath}/login/${encodeURIComponent(
+        returnUrl.substring(basePath.length)
+      )}`
     );
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 039a616..863f4f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -10,7 +10,7 @@
 import '../gr-dialog/gr-dialog';
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icon/gr-icon';
-import '../gr-textarea/gr-textarea';
+import '../gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
@@ -20,7 +20,7 @@
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {provide, resolve} from '../../../models/dependency';
-import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
 import {
   AccountDetailInfo,
   DraftInfo,
@@ -87,6 +87,8 @@
 import {getFileExtension} from '../../../utils/file-util';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {deepEqual} from '../../../utils/deep-util';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {waitUntil} from '../../../utils/async-util';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -141,7 +143,7 @@
    */
 
   @query('#editTextarea')
-  textarea?: GrTextarea;
+  textarea?: GrSuggestionTextarea;
 
   @query('#container')
   container?: HTMLElement;
@@ -155,6 +157,9 @@
   @query('#confirmDeleteCommentDialog')
   confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
 
+  @query('#suggestionDiffPreview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
   @property({type: Object})
   comment?: Comment;
 
@@ -859,7 +864,7 @@
   private renderEditingTextarea() {
     if (!this.editing || this.collapsed) return;
     return html`
-      <gr-textarea
+      <gr-suggestion-textarea
         id="editTextarea"
         class="editMessage"
         autocomplete="on"
@@ -876,7 +881,7 @@
           this.autoSaveTrigger$.next();
           this.generateSuggestionTrigger$.next();
         }}
-      ></gr-textarea>
+      ></gr-suggestion-textarea>
     `;
   }
 
@@ -1059,6 +1064,7 @@
 
     if (this.generatedFixSuggestion) {
       return html`<gr-suggestion-diff-preview
+        id="suggestionDiffPreview"
         .fixSuggestionInfo=${this.generatedFixSuggestion}
       ></gr-suggestion-diff-preview>`;
     } else if (this.generatedSuggestion) {
@@ -1269,7 +1275,13 @@
       return;
     }
     this.generatedFixSuggestion = suggestion;
-    this.autoSaveTrigger$.next();
+    try {
+      await waitUntil(() => this.getFixSuggestions() !== undefined);
+      this.autoSaveTrigger$.next();
+    } catch (error) {
+      // Error is ok in some cases like quick save by user.
+      console.warn(error);
+    }
   }
 
   private renderRobotActions() {
@@ -1682,7 +1694,7 @@
       isError(this.comment) ||
       this.messageText.trimEnd() !== this.comment.message ||
       this.unresolved !== this.comment.unresolved ||
-      !deepEqual(this.comment.fix_suggestions, this.getFixSuggestions())
+      this.isFixSuggestionChanged()
     );
   }
 
@@ -1690,15 +1702,22 @@
   private rawSave(options: {showToast: boolean}) {
     assert(isDraft(this.comment), 'only drafts are editable');
     assert(!isSaving(this.comment), 'saving already in progress');
-    return this.getCommentsModel().saveDraft(
-      {
-        ...this.comment,
-        message: this.messageText.trimEnd(),
-        unresolved: this.unresolved,
-        fix_suggestions: this.getFixSuggestions(),
-      },
-      options.showToast
-    );
+    const draft: DraftInfo = {
+      ...this.comment,
+      message: this.messageText.trimEnd(),
+      unresolved: this.unresolved,
+    };
+    if (this.isFixSuggestionChanged()) {
+      draft.fix_suggestions = this.getFixSuggestions();
+    }
+    return this.getCommentsModel().saveDraft(draft, options.showToast);
+  }
+
+  isFixSuggestionChanged(): boolean {
+    // Check to not change fix suggestion when draft is not being edited only
+    // when user quickly disable generating suggestions and click save
+    if (!this.editing && this.generateSuggestion) return false;
+    return !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions());
   }
 
   getFixSuggestions(): FixSuggestionInfo[] | undefined {
@@ -1708,6 +1727,13 @@
     if (!this.generatedFixSuggestion) return undefined;
     // Disable fix suggestions when the comment already has a user suggestion
     if (this.comment && hasUserSuggestion(this.comment)) return undefined;
+    // we ignore fixSuggestions until they are previewed.
+    if (
+      this.suggestionDiffPreview &&
+      !this.suggestionDiffPreview?.previewed &&
+      !this.suggestionLoading
+    )
+      return undefined;
     return [this.generatedFixSuggestion];
   }
 
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 098b79a..ef02c95 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
@@ -406,7 +406,7 @@
                 </div>
               </div>
               <div class="body">
-                <gr-textarea
+                <gr-suggestion-textarea
                   autocomplete="on"
                   class="code editMessage"
                   code=""
@@ -414,7 +414,7 @@
                   rows="4"
                   text="This is the test comment message."
                 >
-                </gr-textarea>
+                </gr-suggestion-textarea>
                 <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
                 <div class="actions">
                   <div class="leftActions">
@@ -1081,7 +1081,7 @@
       await element.updateComplete;
       assert.dom.equal(
         queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        /* HTML */ '<gr-suggestion-diff-preview id="suggestionDiffPreview"> </gr-suggestion-diff-preview>'
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 923a00e..8314912 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -62,6 +62,9 @@
   @property({type: Boolean})
   showAddSuggestionButton = false;
 
+  @property({type: Boolean, attribute: 'previewed', reflect: true})
+  previewed = false;
+
   @property({type: String})
   uuid?: string;
 
@@ -270,6 +273,7 @@
     )
       return;
 
+    this.previewed = false;
     this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     const res = await this.restApiService.getFixPreview(
       this.changeNum,
@@ -287,6 +291,7 @@
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
       this.previewLoadedFor = this.fixSuggestionInfo;
+      this.previewed = true;
     }
 
     return res;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
similarity index 98%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 7f70911..78b7610 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -72,8 +72,8 @@
   }
 }
 
-@customElement('gr-textarea')
-export class GrTextarea extends LitElement {
+@customElement('gr-suggestion-textarea')
+export class GrSuggestionTextarea extends LitElement {
   /**
    * @event bind-value-changed
    */
@@ -669,6 +669,6 @@
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-textarea': GrTextarea;
+    'gr-suggestion-textarea': GrSuggestionTextarea;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
similarity index 96%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
index d84f5a7..e73f685 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
@@ -4,8 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import './gr-textarea';
-import {GrTextarea} from './gr-textarea';
+import './gr-suggestion-textarea';
+import {GrSuggestionTextarea} from './gr-suggestion-textarea';
 import {
   Item,
   ItemSelectedEventDetail,
@@ -20,11 +20,13 @@
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
 
-suite('gr-textarea tests', () => {
-  let element: GrTextarea;
+suite('gr-suggestion-textarea tests', () => {
+  let element: GrSuggestionTextarea;
 
   setup(async () => {
-    element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
+    element = await fixture<GrSuggestionTextarea>(
+      html`<gr-suggestion-textarea></gr-suggestion-textarea>`
+    );
     sinon.stub(element.reporting, 'reportInteraction');
     await element.updateComplete;
   });
@@ -706,12 +708,12 @@
     });
   });
 
-  suite('gr-textarea monospace', () => {
-    let element: GrTextarea;
+  suite('gr-suggestion-textarea monospace', () => {
+    let element: GrSuggestionTextarea;
 
     setup(async () => {
-      element = await fixture<GrTextarea>(
-        html`<gr-textarea monospace></gr-textarea>`
+      element = await fixture<GrSuggestionTextarea>(
+        html`<gr-suggestion-textarea monospace></gr-suggestion-textarea>`
       );
       await element.updateComplete;
     });
@@ -721,12 +723,12 @@
     });
   });
 
-  suite('gr-textarea hideBorder', () => {
-    let element: GrTextarea;
+  suite('gr-suggestion-textarea hideBorder', () => {
+    let element: GrSuggestionTextarea;
 
     setup(async () => {
-      element = await fixture<GrTextarea>(
-        html`<gr-textarea hide-border></gr-textarea>`
+      element = await fixture<GrSuggestionTextarea>(
+        html`<gr-suggestion-textarea hide-border></gr-suggestion-textarea>`
       );
       await element.updateComplete;
     });
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
 import '../api/embed';
 import '../scripts/bundled-polymer';
 import './diff/gr-diff/gr-diff';
+import './gr-textarea';
 import './diff/gr-diff-cursor/gr-diff-cursor';
 import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
 import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..35dc2d1
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,788 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+  return new Promise(resolve => {
+    requestAnimationFrame(() => {
+      resolve();
+    });
+  });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+  const div = document.createElement('div');
+  try {
+    div.contentEditable = 'PLAINTEXT-ONLY';
+    return div.contentEditable === 'plaintext-only';
+  } catch (e) {
+    return false;
+  }
+}
+
+/** Input custom event detail object. */
+export interface InputEventDetail {
+  value: string;
+}
+
+/** Cursor position change custom event detail object.
+ *
+ * The current position of the cursor.
+ */
+export interface CursorPositionChangeEventDetail {
+  position: number;
+}
+
+/** hint shown custom event detail object */
+export interface HintShownEventDetail {
+  hint: string;
+}
+
+/** hint dismissed custom event detail object */
+export interface HintDismissedEventDetail {
+  hint: string;
+}
+
+/** hint applied custom event detail object */
+export interface HintAppliedEventDetail {
+  hint: string;
+  oldValue: string;
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+  'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement {
+  // editableDivElement is available right away where it may be undefined. This
+  // is used for calls for scrollTop as if it is undefined then we can fallback
+  // to 0. For other usecases use editableDiv.
+  @query('.editableDiv')
+  private readonly editableDivElement?: HTMLDivElement;
+
+  @queryAsync('.editableDiv')
+  private readonly editableDiv?: Promise<HTMLDivElement>;
+
+  @property({type: Boolean, reflect: true}) disabled = false;
+
+  @property({type: String, reflect: true}) placeholder: string | undefined;
+
+  /**
+   * The hint is shown as a autocomplete string which can be added by pressing
+   * TAB.
+   *
+   * The hint is shown
+   *  1. At the cursor position, only when cursor position is at the end of
+   *     textarea content.
+   *  2. When textarea has focus.
+   *  3. When selection inside the textarea is collapsed.
+   *
+   * When hint is applied listen for hintApplied event and remove the hint
+   * as component property to avoid showing the hint again.
+   */
+  @property({type: String})
+  set hint(newHint) {
+    if (this.hint !== newHint) {
+      this.innerHint = newHint;
+      this.updateHintInDomIfRendered();
+    }
+  }
+
+  get hint() {
+    return this.innerHint;
+  }
+
+  /**
+   * Show hint is shown as placeholder which people can autocomplete to.
+   *
+   * This takes precedence over hint property.
+   * It is shown even when textarea has no focus.
+   * This is shown only when textarea is blank.
+   */
+  @property({type: String}) placeholderHint: string | undefined;
+
+  /**
+   * Sets the value for textarea and also renders it in dom if it is different
+   * from last rendered value.
+   *
+   * To prevent cursor position from jumping to front of text even when value
+   * remains same, Check existing value before triggering the update and only
+   * update when there is a change.
+   *
+   * Also .innerText binding can't be used for security reasons.
+   */
+  @property({type: String})
+  set value(newValue) {
+    if (this.ignoreValue && this.ignoreValue === newValue) {
+      return;
+    }
+    const oldVal = this.value;
+    if (oldVal !== newValue) {
+      this.innerValue = newValue;
+      this.updateValueInDom();
+    }
+  }
+
+  get value() {
+    return this.innerValue;
+  }
+
+  /**
+   * This value will be ignored by textarea and is not set.
+   */
+  @property({type: String}) ignoreValue: string | undefined;
+
+  /**
+   * Sets cursor at the end of content on focus.
+   */
+  @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+  /**
+   * Enables save shortcut.
+   *
+   * On S key down with control or meta key enabled is exposed with output event
+   * 'saveShortcut'.
+   */
+  @property({type: Boolean}) enableSaveShortcut = false;
+
+  /*
+   * Is textarea focused. This is a readonly property.
+   */
+  get isFocused(): boolean {
+    return this.focused;
+  }
+
+  /**
+   * Native element for editable div.
+   */
+  get nativeElement() {
+    return this.editableDivElement;
+  }
+
+  /**
+   * Scroll Top for editable div.
+   */
+  override get scrollTop() {
+    return this.editableDivElement?.scrollTop ?? 0;
+  }
+
+  private innerValue: string | undefined;
+
+  private innerHint: string | undefined;
+
+  private focused = false;
+
+  private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          position: relative;
+          width: 100%;
+        }
+
+        :host([disabled]) {
+          .editableDiv {
+            background-color: var(--input-field-disabled-bg, lightgrey);
+            color: var(--text-disabled, black);
+            cursor: default;
+          }
+        }
+
+        .editableDiv {
+          background-color: var(--input-field-bg, white);
+          border: 2px solid var(--onedev-textarea-border-color, white);
+          border-radius: 4px;
+          box-sizing: border-box;
+          color: var(--text-default, black);
+          max-height: var(--onedev-textarea-max-height, 16em);
+          min-height: var(--onedev-textarea-min-height, 4em);
+          overflow-x: auto;
+          padding: 12px;
+          white-space: pre-wrap;
+          width: 100%;
+
+          &:focus-visible {
+            border-color: var(--onedev-textarea-focus-outline-color, black);
+            outline: none;
+          }
+
+          &:empty::before {
+            content: attr(data-placeholder);
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+          }
+
+          &.hintShown:empty::after,
+          .autocomplete-hint:empty::after {
+            background-color: var(--secondary-bg-color, white);
+            border: 1px solid var(--text-secondary, lightgrey);
+            border-radius: 2px;
+            content: 'tab';
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+            font-size: 10px;
+            line-height: 10px;
+            margin-left: 4px;
+            padding: 1px 3px;
+          }
+
+          .autocomplete-hint {
+            &:empty::before {
+              content: attr(data-hint);
+              color: var(--text-secondary, lightgrey);
+            }
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const isHintShownAsPlaceholder =
+      (!this.disabled && this.placeholderHint) ?? false;
+
+    const placeholder = isHintShownAsPlaceholder
+      ? this.placeholderHint
+      : this.placeholder;
+    const ariaPlaceholder = isHintShownAsPlaceholder
+      ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+      : placeholder;
+
+    const classes = classMap({
+      editableDiv: true,
+      hintShown: isHintShownAsPlaceholder,
+    });
+
+    // Chrome supports non-standard "contenteditable=plaintext-only",
+    // which prevents HTML from being inserted into a contenteditable element.
+    // https://github.com/w3c/editing/issues/162
+    return html`<div
+      aria-disabled=${this.disabled}
+      aria-multiline="true"
+      aria-placeholder=${ifDefined(ariaPlaceholder)}
+      data-placeholder=${ifDefined(placeholder)}
+      class=${classes}
+      contenteditable=${this.contentEditableAttributeValue}
+      dir="ltr"
+      role="textbox"
+      @input=${this.onInput}
+      @focus=${this.onFocus}
+      @blur=${this.onBlur}
+      @keydown=${this.handleKeyDown}
+      @keyup=${this.handleKeyUp}
+      @mouseup=${this.handleMouseUp}
+      @scroll=${this.handleScroll}
+    ></div>`;
+  }
+
+  /**
+   * Focuses the textarea.
+   */
+  override async focus() {
+    const editableDivElement = await this.editableDiv;
+    const isFocused = this.isFocused;
+    editableDivElement?.focus?.();
+    // If already focused, do not change the cursor position.
+    if (this.putCursorAtEndOnFocus && !isFocused) {
+      await this.putCursorAtEnd();
+    }
+  }
+
+  /**
+   * Puts the cursor at the end of existing content.
+   * Scrolls the content of textarea towards the end.
+   */
+  async putCursorAtEnd() {
+    const editableDivElement = await this.editableDiv;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+
+    const range = document.createRange();
+    editableDivElement.focus();
+    range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    await this.onCursorPositionChange(null);
+  }
+
+  /**
+   * Sets cursor position to given position and scrolls the content to cursor
+   * position.
+   *
+   * If position is out of bounds of value of textarea then cursor is places at
+   * end of content of textarea.
+   */
+  async setCursorPosition(position: number) {
+    // 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) {
+      return;
+    }
+    editableDivElement.focus();
+    const findNodeToFocusOn = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+        let currentNodeLength = 0;
+
+        if (childNode.nodeName === 'BR') {
+          currentNodeLength++;
+          isOnFreshLine = true;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          currentNodeLength++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+          currentNodeLength += childNode.textContent.length;
+        }
+
+        if (remainingOffset <= currentNodeLength) {
+          nodeToFocusOn = childNode;
+          break;
+        } else {
+          remainingOffset -= currentNodeLength;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findNodeToFocusOn(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    // Find the node to focus on.
+    findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+    await this.setFocusOnNode(
+      selection,
+      editableDivElement,
+      nodeToFocusOn,
+      remainingOffset
+    );
+  }
+
+  /**
+   * Replaces text from start and end cursor position.
+   */
+  setRangeText(replacement: string, start: number, end: number) {
+    const pre = this.value?.substring(0, start) ?? '';
+    const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+    this.value = pre + replacement + post;
+    this.setCursorPosition(pre.length + replacement.length);
+  }
+
+  private get contentEditableAttributeValue() {
+    return this.disabled
+      ? 'false'
+      : this.isPlaintextOnlySupported
+      ? ('plaintext-only' as unknown as 'true')
+      : 'true';
+  }
+
+  private async setFocusOnNode(
+    selection: Selection,
+    editableDivElement: Node,
+    nodeToFocusOn: Node | null,
+    remainingOffset: number
+  ) {
+    const range = document.createRange();
+    // If node is null or undefined then fallback to focus event which will put
+    // cursor at the end of content.
+    if (nodeToFocusOn === null) {
+      range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    }
+    // If node to focus is BR then focus offset is number of nodes.
+    else if (nodeToFocusOn.nodeName === 'BR') {
+      const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+      range.setEnd(nextNode, 0);
+    } else {
+      range.setStart(nodeToFocusOn, remainingOffset);
+    }
+
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    // Scroll the content to cursor position.
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    await this.onCursorPositionChange(null);
+  }
+
+  private async onInput(event: Event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+
+    const value = await this.getValue();
+    this.innerValue = value;
+
+    this.dispatchEvent(
+      new CustomEvent('input', {
+        detail: {
+          value: this.value,
+        },
+      })
+    );
+  }
+
+  private async onFocus(event: Event) {
+    this.focused = true;
+    await this.onCursorPositionChange(event);
+  }
+
+  private async onBlur(event: Event) {
+    this.focused = false;
+    this.removeHintSpanIfShown();
+    await this.onCursorPositionChange(event);
+  }
+
+  private async handleKeyDown(event: KeyboardEvent) {
+    if (
+      event.key === 'Tab' &&
+      !event.shiftKey &&
+      !event.ctrlKey &&
+      !event.metaKey
+    ) {
+      await this.handleTabKeyPress(event);
+      return;
+    }
+    if (
+      this.enableSaveShortcut &&
+      event.key === 's' &&
+      (event.ctrlKey || event.metaKey)
+    ) {
+      event.preventDefault();
+      this.dispatchEvent(new CustomEvent('saveShortcut'));
+    }
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private async handleKeyUp(event: KeyboardEvent) {
+    await this.onCursorPositionChange(event);
+  }
+
+  private async handleMouseUp(event: MouseEvent) {
+    await this.onCursorPositionChange(event);
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private handleScroll() {
+    this.dispatchEvent(new CustomEvent('scroll'));
+  }
+
+  private async handleTabKeyPress(event: KeyboardEvent) {
+    const oldValue = this.value;
+    if (this.placeholderHint && !oldValue) {
+      event.preventDefault();
+      await this.appendHint(this.placeholderHint, event);
+    } else if (this.hasHintSpan()) {
+      event.preventDefault();
+      await this.appendHint(this.hint!, event);
+    }
+  }
+
+  private async appendHint(hint: string, event: Event) {
+    const oldValue = this.value ?? '';
+    const newValue = oldValue + hint;
+
+    this.value = newValue;
+    await this.putCursorAtEnd();
+    await this.onInput(event);
+
+    this.dispatchEvent(
+      new CustomEvent('hintApplied', {
+        detail: {
+          hint,
+          oldValue,
+        },
+      })
+    );
+  }
+
+  private async toggleHintVisibilityIfAny() {
+    // Wait for the next animation frame so that entered key is processed and
+    // available in dom.
+    await animationFrame();
+
+    const editableDivElement = await this.editableDiv;
+    const currentValue = (await this.getValue()) ?? '';
+    const cursorPosition = await this.getCursorPosition();
+    if (
+      !editableDivElement ||
+      (this.placeholderHint && !currentValue) ||
+      !this.hint ||
+      !this.isFocused ||
+      cursorPosition !== currentValue.length
+    ) {
+      this.removeHintSpanIfShown();
+      return;
+    }
+
+    const hintSpan = this.hintSpan();
+    if (!hintSpan) {
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+      return;
+    }
+
+    const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+    if (oldHint !== this.hint) {
+      this.removeHintSpanIfShown();
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+    }
+  }
+
+  private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+    const hintSpan = document.createElement('span');
+    hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+    hintSpan.setAttribute('role', 'alert');
+    hintSpan.setAttribute(
+      'aria-label',
+      'Suggestion: ' + hint + ' Press TAB to accept it.'
+    );
+    hintSpan.dataset['hint'] = hint;
+    editableDivElement.appendChild(hintSpan);
+    this.dispatchEvent(
+      new CustomEvent('hintShown', {
+        detail: {
+          hint,
+        },
+      })
+    );
+  }
+
+  private removeHintSpanIfShown() {
+    const hintSpan = this.hintSpan();
+    if (hintSpan) {
+      hintSpan.remove();
+      this.dispatchEvent(
+        new CustomEvent('hintDismissed', {
+          detail: {
+            hint: (hintSpan as HTMLElement).dataset['hint'],
+          },
+        })
+      );
+    }
+  }
+
+  private hasHintSpan() {
+    return !!this.hintSpan();
+  }
+
+  private hintSpan() {
+    return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+  }
+
+  private async onCursorPositionChange(event: Event | null) {
+    event?.preventDefault();
+    event?.stopImmediatePropagation();
+
+    this.dispatchEvent(
+      new CustomEvent('cursorPositionChange', {
+        detail: {
+          position: await this.getCursorPosition(),
+        },
+      })
+    );
+  }
+
+  private async updateValueInDom() {
+    const editableDivElement = await this.editableDiv;
+    if (editableDivElement) {
+      editableDivElement.innerText = this.value || '';
+    }
+  }
+
+  private async updateHintInDomIfRendered() {
+    // Wait for editable div to render then process the hint.
+    await this.editableDiv;
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private async getValue() {
+    const editableDivElement = await this.editableDiv;
+    if (editableDivElement) {
+      const [output] = this.parseText(editableDivElement, false, true);
+      return output;
+    }
+    return '';
+  }
+
+  private parseText(
+    node: Node,
+    isLastBr: boolean,
+    isFirst: boolean
+  ): [string, boolean] {
+    let textValue = '';
+    let output = '';
+    if (node.nodeName === 'BR') {
+      return ['\n', true];
+    }
+
+    if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+      return [node.textContent, false];
+    }
+
+    if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+      textValue = '\n';
+    }
+
+    isLastBr = false;
+
+    for (let i = 0; i < node.childNodes?.length; i++) {
+      [output, isLastBr] = this.parseText(
+        node.childNodes[i],
+        isLastBr,
+        i === 0
+      );
+      textValue += output;
+    }
+    return [textValue, isLastBr];
+  }
+
+  private async getCursorPosition() {
+    const selection = this.getSelection();
+    const editableDivElement = await this.editableDiv;
+
+    // Cursor position is -1 (not available) if
+    //
+    // If textarea is not rendered.
+    // If textarea is not focused
+    // There is no accessible selection object.
+    // This is not a collapsed selection.
+    if (
+      !editableDivElement ||
+      !this.focused ||
+      !selection ||
+      selection.focusNode === null ||
+      !selection.isCollapsed
+    ) {
+      return -1;
+    }
+
+    let cursorPosition = 0;
+    let isOnFreshLine = true;
+
+    const findCursorPosition = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+
+        if (childNode.nodeName === 'BR') {
+          cursorPosition++;
+          isOnFreshLine = true;
+          continue;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          cursorPosition++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode === selection.focusNode) {
+          cursorPosition += selection.focusOffset;
+          break;
+        } else if (childNode.nodeType === 3 && childNode.textContent) {
+          cursorPosition += childNode.textContent.length;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findCursorPosition(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    if (editableDivElement === selection.focusNode) {
+      // If focus node is the top textarea then focusOffset is the number of
+      // child nodes before the cursor position.
+      const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+        0,
+        selection.focusOffset
+      );
+      findCursorPosition(partOfNodes);
+    } else {
+      findCursorPosition(Array.from(editableDivElement.childNodes));
+    }
+
+    return cursorPosition;
+  }
+
+  /** Gets the current selection, preferring the shadow DOM selection. */
+  private getSelection(): Selection | undefined | null {
+    // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+    return this.shadowRoot?.getSelection?.();
+  }
+
+  private scrollToCursorPosition(range: Range) {
+    const tempAnchorEl = document.createElement('br');
+    range.insertNode(tempAnchorEl);
+
+    tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+    tempAnchorEl.remove();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-textarea': GrTextarea;
+  }
+  interface HTMLElementEventMap {
+    // prettier-ignore
+    'saveShortcut': CustomEvent<{}>;
+    // prettier-ignore
+    'hintApplied': CustomEvent<HintAppliedEventDetail>;
+    // prettier-ignore
+    'hintShown': CustomEvent<HintShownEventDetail>;
+    // prettier-ignore
+    'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+    // prettier-ignore
+    'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..7af3697
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {
+  AUTOCOMPLETE_HINT_CLASS,
+  CursorPositionChangeEventDetail,
+  GrTextarea,
+} from './gr-textarea';
+
+async function rafPromise() {
+  return new Promise(res => {
+    requestAnimationFrame(res);
+  });
+}
+
+suite('gr-textarea test', () => {
+  let element: GrTextarea;
+
+  setup(async () => {
+    element = await fixture(html` <gr-textarea> </gr-textarea>`);
+  });
+
+  test('text area is registered correctly', () => {
+    assert.instanceOf(element, GrTextarea);
+  });
+
+  test('when disabled textarea have contenteditable set to false', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+  });
+
+  test('when disabled textarea have aria-disabled set', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+  });
+
+  test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+    const placeholder = 'A sample placehodler...';
+    element.placeholder = placeholder;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+  });
+
+  test('renders the value', async () => {
+    const value = 'Some value';
+    element.value = value;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.innerText, value);
+  });
+
+  test('streams change event when editable div has input event', async () => {
+    const value = 'Some value \n other value';
+    const INPUT_EVENT = 'input';
+    let changeCalled = false;
+
+    element.addEventListener(INPUT_EVENT, () => {
+      changeCalled = true;
+    });
+
+    const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+
+    editableDiv.innerText = value;
+    editableDiv.dispatchEvent(new Event('input'));
+    await changeEventPromise;
+
+    assert.isTrue(changeCalled);
+  });
+
+  test('does not have focus by default', async () => {
+    assert.isFalse(element.isFocused);
+  });
+
+  test('when focused, isFocused is set to true', async () => {
+    await element.focus();
+    assert.isTrue(element.isFocused);
+  });
+
+  test('when cursor position is set to 0', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    await element.setCursorPosition(0);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 0);
+  });
+
+  test('when cursor position is set to 1', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some value';
+    await element.updateComplete;
+    await element.setCursorPosition(1);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 1);
+  });
+
+  test('when cursor position is set to new line', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some \n\n\n value';
+    await element.updateComplete;
+    await element.setCursorPosition(7);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 7);
+  });
+
+  test('when textarea is empty, placeholder hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+  });
+
+  test('when TAB is pressed, placeholder hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, placeholderHint);
+  });
+
+  test('when cursor is at end, hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    await element.updateComplete;
+    element.value = oldValue;
+    await element.putCursorAtEnd();
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await element.updateComplete;
+    await rafPromise();
+
+    const spanHintElement = editableDiv?.querySelector(
+      '.' + AUTOCOMPLETE_HINT_CLASS
+    ) as HTMLSpanElement;
+    const styles = window.getComputedStyle(spanHintElement, ':before');
+    assert.equal(styles['content'], '"' + hint + '"');
+  });
+
+  test('when TAB is pressed, hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    element.value = oldValue;
+    await element.updateComplete;
+    await element.putCursorAtEnd();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await rafPromise();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, oldValue + hint);
+  });
+});
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 49b8edf..603e6c8 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ac3dbac..241d59b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index b3408d1..00132bd 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 2f72352..5a21ab7 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.10.0-SNAPSHOT</version>
+  <version>3.11.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index 23ccefb..181159e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.10.0-SNAPSHOT"
+GERRIT_VERSION = "3.11.0-SNAPSHOT"