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);