summaryrefslogtreecommitdiffstats
path: root/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
diff options
context:
space:
mode:
Diffstat (limited to 'polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts')
-rw-r--r--polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts402
1 files changed, 239 insertions, 163 deletions
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 9e6b42a7d9..e0c49c9f63 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -19,12 +19,7 @@ import '../gr-cursor-manager/gr-cursor-manager';
import '../gr-overlay/gr-overlay';
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-textarea_html';
-import {appContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {getAppContext} from '../../../services/app-context';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import {
GrAutocompleteDropdown,
@@ -32,6 +27,13 @@ import {
ItemSelectedEvent,
} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {addShortcut, Key} from '../../../utils/dom-util';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {PropertyValues} from 'lit';
+import {classMap} from 'lit/directives/class-map';
const MAX_ITEMS_DROPDOWN = 10;
@@ -63,96 +65,64 @@ interface EmojiSuggestion extends Item {
match: string;
}
-interface ValueChangeEvent {
- value: string;
-}
-
-export interface GrTextarea {
- $: {
- textarea: IronAutogrowTextareaElement;
- emojiSuggestions: GrAutocompleteDropdown;
- caratSpan: HTMLSpanElement;
- hiddenText: HTMLDivElement;
- };
-}
-
declare global {
interface HTMLElementEventMap {
'item-selected': CustomEvent<ItemSelectedEvent>;
- 'bind-value-changed': CustomEvent<ValueChangeEvent>;
}
}
@customElement('gr-textarea')
-export class GrTextarea extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
+export class GrTextarea extends LitElement {
/**
* @event bind-value-changed
*/
- @property({type: String})
- autocomplete?: string;
+ @query('#textarea') textarea?: IronAutogrowTextareaElement;
- @property({type: Boolean})
- disabled?: boolean;
+ @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
- @property({type: Number})
- rows?: number;
+ @query('#caratSpan', true) caratSpan?: HTMLSpanElement;
- @property({type: Number})
- maxRows?: number;
+ @query('#hiddenText') hiddenText?: HTMLDivElement;
- @property({type: String})
- placeholder?: string;
+ @property() autocomplete?: string;
- @property({type: String, notify: true, observer: '_handleTextChanged'})
- text = '';
+ @property({type: Boolean}) disabled?: boolean;
- @property({type: Boolean})
- hideBorder = false;
+ @property({type: Number}) rows?: number;
+
+ @property({type: Number}) maxRows?: number;
+
+ @property({type: String}) placeholder?: string;
+
+ @property({type: String}) text = '';
+
+ @property({type: Boolean, attribute: 'hide-border'}) hideBorder = false;
/** Text input should be rendered in monospace font. */
- @property({type: Boolean})
- monospace = false;
+ @property({type: Boolean}) monospace = false;
/** Text input should be rendered in code font, which is smaller than the
standard monospace font. */
- @property({type: Boolean})
- code = false;
-
- @property({type: Number})
- _colonIndex: number | null = null;
+ @property({type: Boolean}) code = false;
- @property({type: String, observer: '_determineSuggestions'})
- _currentSearchString?: string;
+ @state() colonIndex: number | null = null;
- @property({type: Boolean})
- _hideEmojiAutocomplete = true;
+ @state() currentSearchString?: string;
- @property({type: Number})
- _index: number | null = null;
+ @state() hideEmojiAutocomplete = true;
- @property({type: Array})
- _suggestions: EmojiSuggestion[] = [];
+ @state() private index: number | null = null;
- @property({type: Number})
- readonly _verticalOffset = 20;
- // Offset makes dropdown appear below text.
+ @state() suggestions: EmojiSuggestion[] = [];
- reporting: ReportingService;
+ // Accessed in tests.
+ readonly reporting = getAppContext().reportingService;
disableEnterKeyForSelectingEmoji = false;
/** Called in disconnectedCallback. */
private cleanups: (() => void)[] = [];
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
override disconnectedCallback() {
super.disconnectedCallback();
for (const cleanup of this.cleanups) cleanup();
@@ -161,42 +131,139 @@ export class GrTextarea extends PolymerElement {
override connectedCallback() {
super.connectedCallback();
+ if (this.monospace) {
+ this.classList.add('monospace');
+ }
+ if (this.code) {
+ this.classList.add('code');
+ }
this.cleanups.push(
- addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
+ addShortcut(this, {key: Key.UP}, e => this.handleUpKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
+ addShortcut(this, {key: Key.DOWN}, e => this.handleDownKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
+ addShortcut(this, {key: Key.TAB}, e => this.handleTabKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
+ addShortcut(this, {key: Key.ENTER}, e => this.handleEnterByKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
+ addShortcut(this, {key: Key.ESC}, e => this.handleEscKey(e), {
+ doNotPrevent: true,
+ })
);
}
- override ready() {
- super.ready();
- if (this.monospace) {
- this.classList.add('monospace');
- }
- if (this.code) {
- this.classList.add('code');
+ static override styles = [
+ sharedStyles,
+ css`
+ :host {
+ display: flex;
+ position: relative;
+ }
+ :host(.monospace) {
+ font-family: var(--monospace-font-family);
+ font-size: var(--font-size-mono);
+ line-height: var(--line-height-mono);
+ font-weight: var(--font-weight-normal);
+ }
+ :host(.code) {
+ font-family: var(--monospace-font-family);
+ font-size: var(--font-size-code);
+ /* usually 16px = 12px + 4px */
+ line-height: calc(var(--font-size-code) + var(--spacing-s));
+ font-weight: var(--font-weight-normal);
+ }
+ #emojiSuggestions {
+ font-family: var(--font-family);
+ }
+ gr-autocomplete {
+ display: inline-block;
+ }
+ #textarea {
+ background-color: var(--view-background-color);
+ width: 100%;
+ }
+ #hiddenText #emojiSuggestions {
+ visibility: visible;
+ white-space: normal;
+ }
+ iron-autogrow-textarea {
+ position: relative;
+ }
+ #textarea.noBorder {
+ border: none;
+ }
+ #hiddenText {
+ display: block;
+ float: left;
+ position: absolute;
+ visibility: hidden;
+ width: 100%;
+ white-space: pre-wrap;
+ }
+ `,
+ ];
+
+ override render() {
+ return html`
+ <div id="hiddenText"></div>
+ <!-- When the autocomplete is open, the span is moved at the end of
+ hiddenText in order to correctly position the dropdown. After being moved,
+ it is set as the positionTarget for the emojiSuggestions dropdown. -->
+ <span id="caratSpan"></span>
+ <gr-autocomplete-dropdown
+ id="emojiSuggestions"
+ .suggestions=${this.suggestions}
+ .index=${this.index}
+ .verticalOffset=${20}
+ @dropdown-closed=${this.resetEmojiDropdown}
+ @item-selected=${this.handleEmojiSelect}
+ >
+ </gr-autocomplete-dropdown>
+ <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;
+ }}
+ @bind-value-changed=${this.onValueChanged}
+ ></iron-autogrow-textarea>
+ `;
+ }
+
+ override willUpdate(changedProperties: PropertyValues) {
+ if (changedProperties.has('text')) {
+ this.handleTextChanged(this.text);
}
- if (this.hideBorder) {
- this.$.textarea.classList.add('noBorder');
+ if (changedProperties.has('currentSearchString')) {
+ this.determineSuggestions(this.currentSearchString!);
}
}
+ // private but used in test
closeDropdown() {
- return this.$.emojiSuggestions.close();
+ this.emojiSuggestions?.close();
}
getNativeTextarea() {
- return this.$.textarea.textarea;
+ return this.textarea!.textarea;
}
putCursorAtEnd() {
@@ -209,85 +276,87 @@ export class GrTextarea extends PolymerElement {
});
}
- _handleEscKey(e: KeyboardEvent) {
- if (this._hideEmojiAutocomplete) {
+ private handleEscKey(e: KeyboardEvent) {
+ if (this.hideEmojiAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
- this._resetEmojiDropdown();
+ this.resetEmojiDropdown();
}
- _handleUpKey(e: KeyboardEvent) {
- if (this._hideEmojiAutocomplete) {
+ private handleUpKey(e: KeyboardEvent) {
+ if (this.hideEmojiAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
- this.$.emojiSuggestions.cursorUp();
- this.$.textarea.textarea.focus();
+ this.emojiSuggestions!.cursorUp();
+ this.textarea!.textarea.focus();
this.disableEnterKeyForSelectingEmoji = false;
}
- _handleDownKey(e: KeyboardEvent) {
- if (this._hideEmojiAutocomplete) {
+ private handleDownKey(e: KeyboardEvent) {
+ if (this.hideEmojiAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
- this.$.emojiSuggestions.cursorDown();
- this.$.textarea.textarea.focus();
+ this.emojiSuggestions!.cursorDown();
+ this.textarea!.textarea.focus();
this.disableEnterKeyForSelectingEmoji = false;
}
- _handleTabKey(e: KeyboardEvent) {
+ private handleTabKey(e: KeyboardEvent) {
// Tab should have normal behavior if the picker is closed or if the user
// has only typed ':'.
- if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+ if (this.hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
return;
}
e.preventDefault();
e.stopPropagation();
- this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+ this.setEmoji(this.emojiSuggestions!.getCurrentText());
}
- _handleEnterByKey(e: KeyboardEvent) {
+ // private but used in test
+ handleEnterByKey(e: KeyboardEvent) {
// Enter should have newline behavior if the picker is closed or if the user
// has only typed ':'. Also make sure that shortcuts aren't clobbered.
- if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+ if (this.hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
this.indent(e);
return;
}
e.preventDefault();
e.stopPropagation();
- this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+ this.setEmoji(this.emojiSuggestions!.getCurrentText());
}
- _handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
+ // private but used in test
+ handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
if (e.detail.selected?.dataset['value']) {
- this._setEmoji(e.detail.selected?.dataset['value']);
+ this.setEmoji(e.detail.selected?.dataset['value']);
}
}
- _setEmoji(text: string) {
- if (this._colonIndex === null) {
+ private setEmoji(text: string) {
+ if (this.colonIndex === null) {
return;
}
- const colonIndex = this._colonIndex;
- this.text = this._getText(text);
- this.$.textarea.selectionStart = colonIndex + 1;
- this.$.textarea.selectionEnd = colonIndex + 1;
+ const colonIndex = this.colonIndex;
+ this.text = this.getText(text);
+ this.textarea!.selectionStart = colonIndex + 1;
+ this.textarea!.selectionEnd = colonIndex + 1;
this.reporting.reportInteraction('select-emoji', {type: text});
- this._resetEmojiDropdown();
+ this.resetEmojiDropdown();
}
- _getText(value: string) {
+ private getText(value: string) {
if (!this.text) return '';
return (
- this.text.substr(0, this._colonIndex || 0) +
+ this.text.substr(0, this.colonIndex || 0) +
value +
- this.text.substr(this.$.textarea.selectionStart)
+ this.text.substr(this.textarea!.selectionStart)
);
}
@@ -296,36 +365,31 @@ export class GrTextarea extends PolymerElement {
* the text up until the point of interest. Then caratSpan element is added
* to the end and is set to be the positionTarget for the dropdown. Together
* this allows the dropdown to appear near where the user is typing.
+ * private but used in test
*/
- _updateCaratPosition() {
- this._hideEmojiAutocomplete = false;
- if (typeof this.$.textarea.value === 'string') {
- this.$.hiddenText.textContent = this.$.textarea.value.substr(
+ updateCaratPosition() {
+ this.hideEmojiAutocomplete = false;
+ if (typeof this.textarea!.value === 'string') {
+ this.hiddenText!.textContent = this.textarea!.value.substr(
0,
- this.$.textarea.selectionStart
+ this.textarea!.selectionStart
);
}
- const caratSpan = this.$.caratSpan;
- this.$.hiddenText.appendChild(caratSpan);
- this.$.emojiSuggestions.positionTarget = caratSpan;
- this._openEmojiDropdown();
+ const caratSpan = this.caratSpan!;
+ this.hiddenText!.appendChild(caratSpan);
+ this.emojiSuggestions!.positionTarget = caratSpan;
+ this.openEmojiDropdown();
}
/**
- * _handleKeydown used for key handling in the this.$.textarea AND all child
+ * handleKeydown used for key handling in the this.textarea! AND all child
* autocomplete options.
+ * private but used in test
*/
- _onValueChanged(e: CustomEvent<ValueChangeEvent>) {
+ onValueChanged(e: BindValueChangeEvent) {
// Relay the event.
- this.dispatchEvent(
- new CustomEvent('bind-value-changed', {
- detail: e,
- composed: true,
- bubbles: true,
- })
- );
-
+ fire(this, 'bind-value-changed', {value: e.detail.value});
// If cursor is not in textarea (just opened with colon as last char),
// Don't do anything.
if (
@@ -337,9 +401,9 @@ export class GrTextarea extends PolymerElement {
const charAtCursor =
e.detail && e.detail.value
- ? e.detail.value[this.$.textarea.selectionStart - 1]
+ ? e.detail.value[this.textarea!.selectionStart - 1]
: '';
- if (charAtCursor !== ':' && this._colonIndex === null) {
+ if (charAtCursor !== ':' && this.colonIndex === null) {
return;
}
@@ -347,85 +411,95 @@ export class GrTextarea extends PolymerElement {
// colons after space or in beginning of textarea
if (charAtCursor === ':') {
if (
- this.$.textarea.selectionStart < 2 ||
- e.detail.value[this.$.textarea.selectionStart - 2] === ' '
+ this.textarea!.selectionStart < 2 ||
+ e.detail.value[this.textarea!.selectionStart - 2] === ' '
) {
- this._colonIndex = this.$.textarea.selectionStart - 1;
+ this.colonIndex = this.textarea!.selectionStart - 1;
}
}
- if (this._colonIndex === null) {
+ if (this.colonIndex === null) {
return;
}
- this._currentSearchString = e.detail.value.substr(
- this._colonIndex + 1,
- this.$.textarea.selectionStart - this._colonIndex - 1
+ this.currentSearchString = e.detail.value.substr(
+ this.colonIndex + 1,
+ this.textarea!.selectionStart - this.colonIndex - 1
);
+ this.determineSuggestions(this.currentSearchString);
// Under the following conditions, close and reset the dropdown:
// - The cursor is no longer at the end of the current search string
// - The search string is an space or new line
// - The colon has been removed
// - There are no suggestions that match the search string
if (
- this.$.textarea.selectionStart !==
- this._currentSearchString.length + this._colonIndex + 1 ||
- this._currentSearchString === ' ' ||
- this._currentSearchString === '\n' ||
- !(e.detail.value[this._colonIndex] === ':') ||
- !this._suggestions ||
- !this._suggestions.length
+ this.textarea!.selectionStart !==
+ this.currentSearchString.length + this.colonIndex + 1 ||
+ this.currentSearchString === ' ' ||
+ this.currentSearchString === '\n' ||
+ !(e.detail.value[this.colonIndex] === ':') ||
+ !this.suggestions ||
+ !this.suggestions.length
) {
- this._resetEmojiDropdown();
+ this.resetEmojiDropdown();
// Otherwise open the dropdown and set the position to be just below the
// cursor.
- } else if (this.$.emojiSuggestions.isHidden) {
- this._updateCaratPosition();
+ } else if (this.emojiSuggestions!.isHidden) {
+ this.updateCaratPosition();
}
- this.$.textarea.textarea.focus();
+ this.textarea!.textarea.focus();
}
- _openEmojiDropdown() {
- this.$.emojiSuggestions.open();
+ private openEmojiDropdown() {
+ this.emojiSuggestions!.open();
this.reporting.reportInteraction('open-emoji-dropdown');
}
- _formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
+ // private but used in test
+ formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
const suggestions = [];
for (const suggestion of matchedSuggestions) {
suggestion.dataValue = suggestion.value;
suggestion.text = `${suggestion.value} ${suggestion.match}`;
suggestions.push(suggestion);
}
- this.set('_suggestions', suggestions);
+ this.suggestions = suggestions;
}
- _determineSuggestions(emojiText: string) {
+ // private but used in test
+ determineSuggestions(emojiText: string) {
if (!emojiText.length) {
- this._formatSuggestions(ALL_SUGGESTIONS);
+ this.formatSuggestions(ALL_SUGGESTIONS);
this.disableEnterKeyForSelectingEmoji = true;
} else {
const matches = ALL_SUGGESTIONS.filter(suggestion =>
suggestion.match.includes(emojiText)
).slice(0, MAX_ITEMS_DROPDOWN);
- this._formatSuggestions(matches);
+ this.formatSuggestions(matches);
this.disableEnterKeyForSelectingEmoji = false;
}
}
- _resetEmojiDropdown() {
+ // private but used in test
+ resetEmojiDropdown() {
// hide and reset the autocomplete dropdown.
- flush();
- this._currentSearchString = '';
- this._hideEmojiAutocomplete = true;
+ this.requestUpdate();
+ this.currentSearchString = '';
+ this.hideEmojiAutocomplete = true;
this.closeDropdown();
- this._colonIndex = null;
- this.$.textarea.textarea.focus();
+ this.colonIndex = null;
+ this.textarea!.textarea.focus();
}
- _handleTextChanged(text: string) {
+ private handleTextChanged(text: string) {
+ // This is a bit redundant, because the `text` property has `notify:true`,
+ // so whenever the `text` changes the component fires two identical events
+ // `text-changed` and `value-changed`.
this.dispatchEvent(
new CustomEvent('value-changed', {detail: {value: text}})
);
+ this.dispatchEvent(
+ new CustomEvent('text-changed', {detail: {value: text}})
+ );
}
private indent(e: KeyboardEvent): void {
@@ -435,8 +509,10 @@ export class GrTextarea extends PolymerElement {
// 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
- .substr(0, this.$.textarea.selectionStart)
+ const currentLine = this.textarea!.textarea.value.substr(
+ 0,
+ this.textarea!.selectionStart
+ )
.split('\n')
.pop();
const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];