diff options
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.ts | 402 |
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]; |