| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '@polymer/iron-dropdown/iron-dropdown'; |
| import '@polymer/paper-input/paper-input'; |
| import '../gr-button/gr-button'; |
| import '../gr-icon/gr-icon'; |
| import '../../shared/gr-autocomplete/gr-autocomplete'; |
| import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown'; |
| import { |
| AutocompleteQuery, |
| GrAutocomplete, |
| } from '../gr-autocomplete/gr-autocomplete'; |
| import {Key} from '../../../utils/dom-util'; |
| import {queryAndAssert} from '../../../utils/common-util'; |
| import {LitElement, css, html} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {PaperInputElement} from '@polymer/paper-input/paper-input'; |
| import {IronInputElement} from '@polymer/iron-input'; |
| import {ShortcutController} from '../../lit/shortcut-controller'; |
| |
| const AWAIT_MAX_ITERS = 10; |
| const AWAIT_STEP = 5; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-editable-label': GrEditableLabel; |
| } |
| } |
| |
| @customElement('gr-editable-label') |
| export class GrEditableLabel extends LitElement { |
| /** |
| * Fired when the value is changed. |
| * |
| * @event changed |
| */ |
| |
| @query('#dropdown') |
| dropdown?: IronDropdownElement; |
| |
| @property() |
| labelText = ''; |
| |
| @property({type: Boolean}) |
| editing = false; |
| |
| @property() |
| value?: string; |
| |
| @property() |
| placeholder = ''; |
| |
| @property({type: Boolean}) |
| readOnly = false; |
| |
| @property({type: Boolean, reflect: true}) |
| uppercase = false; |
| |
| @property({type: Number}) |
| maxLength?: number; |
| |
| @property({type: String}) |
| confirmLabel = 'Save'; |
| |
| /* private but used in test */ |
| @state() inputText = ''; |
| |
| @property({type: Boolean}) |
| showAsEditPencil = false; |
| |
| @property({type: Boolean}) |
| autocomplete = false; |
| |
| @property({type: Object}) |
| query: AutocompleteQuery = () => Promise.resolve([]); |
| |
| @query('#input') |
| input?: PaperInputElement; |
| |
| @query('#autocomplete') |
| grAutocomplete?: GrAutocomplete; |
| |
| private readonly shortcuts = new ShortcutController(this); |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| align-items: center; |
| display: inline-flex; |
| } |
| :host([uppercase]) label { |
| text-transform: uppercase; |
| } |
| input, |
| label { |
| width: 100%; |
| } |
| label { |
| color: var(--deemphasized-text-color); |
| display: inline-block; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| label.editable { |
| color: var(--link-color); |
| cursor: pointer; |
| } |
| #dropdown { |
| box-shadow: var(--elevation-level-2); |
| } |
| .inputContainer { |
| background-color: var(--dialog-background-color); |
| padding: var(--spacing-m); |
| } |
| /* This makes inputContainer on one line. */ |
| .inputContainer gr-autocomplete, |
| .inputContainer .buttons { |
| display: inline-block; |
| } |
| .buttons gr-button { |
| margin-left: var(--spacing-m); |
| } |
| /* prettier formatter removes semi-colons after css mixins. */ |
| /* prettier-ignore */ |
| paper-input { |
| --paper-input-container: { |
| padding: 0; |
| min-width: 15em; |
| }; |
| --paper-input-container-input: { |
| font-size: inherit; |
| }; |
| --paper-input-container-focus-color: var(--link-color); |
| } |
| gr-button gr-icon { |
| color: inherit; |
| } |
| gr-button.pencil { |
| --gr-button-padding: var(--spacing-s); |
| --margin: calc(0px - var(--spacing-s)); |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| this.setAttribute('title', this.computeLabel()); |
| return html`${this.renderActivateButton()} |
| <iron-dropdown |
| id="dropdown" |
| .verticalAlign=${'auto'} |
| .horizontalAlign=${'auto'} |
| .allowOutsideScroll=${true} |
| .noCancelOnEscKey=${true} |
| .noCancelOnOutsideClick=${true} |
| > |
| <div class="dropdown-content" slot="dropdown-content"> |
| <div class="inputContainer" part="input-container"> |
| ${this.renderInputBox()} |
| <div class="buttons"> |
| <gr-button primary id="saveBtn" @click=${this.save} |
| >${this.confirmLabel}</gr-button |
| > |
| <gr-button id="cancelBtn" @click=${this.cancel}>cancel</gr-button> |
| </div> |
| </div> |
| </div> |
| </iron-dropdown>`; |
| } |
| |
| private renderActivateButton() { |
| if (this.showAsEditPencil) { |
| return html`<gr-button |
| link="" |
| class="pencil ${this.computeLabelClass()}" |
| @click=${this.showDropdown} |
| title=${this.computeLabel()} |
| > |
| <div> |
| <gr-icon icon="edit" filled small></gr-icon> |
| </div> |
| </gr-button>`; |
| } else { |
| return html`<label |
| class=${this.computeLabelClass()} |
| title=${this.computeLabel()} |
| aria-label=${this.computeLabel()} |
| @click=${this.showDropdown} |
| part="label" |
| >${this.computeLabel()}</label |
| >`; |
| } |
| } |
| |
| private renderInputBox() { |
| if (this.autocomplete) { |
| return html`<gr-autocomplete |
| .label=${this.labelText} |
| id="autocomplete" |
| .text=${this.inputText} |
| .query=${this.query} |
| @cancel=${this.cancel} |
| @text-changed=${(e: CustomEvent) => { |
| this.inputText = e.detail.value; |
| }} |
| > |
| </gr-autocomplete>`; |
| } else { |
| return html`<paper-input |
| id="input" |
| .label=${this.labelText} |
| .maxlength=${this.maxLength} |
| .value=${this.inputText} |
| ></paper-input>`; |
| } |
| } |
| |
| constructor() { |
| super(); |
| this.shortcuts.addLocal({key: Key.ENTER}, e => this.handleEnter(e)); |
| this.shortcuts.addLocal({key: Key.ESC}, e => this.handleEsc(e)); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| if (!this.getAttribute('tabindex')) { |
| this.setAttribute('tabindex', '0'); |
| } |
| if (!this.getAttribute('id')) { |
| this.setAttribute('id', 'global'); |
| } |
| } |
| |
| private usePlaceholder(value?: string, placeholder?: string) { |
| return (!value || !value.length) && placeholder; |
| } |
| |
| private computeLabel(): string { |
| const {value, placeholder} = this; |
| if (this.usePlaceholder(value, placeholder)) { |
| return placeholder; |
| } |
| return value || ''; |
| } |
| |
| private showDropdown() { |
| if (this.readOnly || this.editing) return; |
| return this.openDropdown().then(() => { |
| this.nativeInput.focus(); |
| if (!this.input?.value) return; |
| this.nativeInput.setSelectionRange(0, this.input.value.length); |
| }); |
| } |
| |
| open() { |
| return this.openDropdown().then(() => { |
| this.nativeInput.focus(); |
| }); |
| } |
| |
| private openDropdown() { |
| this.dropdown?.open(); |
| this.inputText = this.value || ''; |
| this.editing = true; |
| |
| return new Promise<void>(resolve => { |
| this.awaitOpen(resolve); |
| }); |
| } |
| |
| /** |
| * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually |
| * opening. Eventually replace with a direct way to listen to the overlay. |
| */ |
| private awaitOpen(fn: () => void) { |
| let iters = 0; |
| const step = () => { |
| setTimeout(() => { |
| if (this.dropdown?.style.display !== 'none') { |
| fn.call(this); |
| } else if (iters++ < AWAIT_MAX_ITERS) { |
| step.call(this); |
| } |
| }, AWAIT_STEP); |
| }; |
| step.call(this); |
| } |
| |
| private save() { |
| if (!this.editing) { |
| return; |
| } |
| this.dropdown?.close(); |
| if (this.input) { |
| this.value = this.input.value ?? undefined; |
| } else { |
| this.value = this.inputText || ''; |
| } |
| this.editing = false; |
| this.dispatchEvent( |
| new CustomEvent('changed', { |
| detail: this.value, |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| } |
| |
| private cancel() { |
| if (!this.editing) { |
| return; |
| } |
| this.dropdown?.close(); |
| this.editing = false; |
| this.inputText = this.value || ''; |
| } |
| |
| private get nativeInput(): HTMLInputElement { |
| if (this.autocomplete) { |
| return this.grAutocomplete!.nativeInput; |
| } else { |
| return (this.input!.inputElement as IronInputElement) |
| .inputElement as HTMLInputElement; |
| } |
| } |
| |
| private handleEnter(event: KeyboardEvent) { |
| const inputContainer = queryAndAssert(this, '.inputContainer'); |
| const isEventFromInput = event |
| .composedPath() |
| .some(element => element === inputContainer); |
| if (isEventFromInput) { |
| this.save(); |
| } |
| } |
| |
| private handleEsc(event: KeyboardEvent) { |
| // If autocomplete is used, it's handling the ESC instead. |
| if (this.autocomplete) { |
| return; |
| } |
| const inputContainer = queryAndAssert(this, '.inputContainer'); |
| const isEventFromInput = event |
| .composedPath() |
| .some(element => element === inputContainer); |
| if (isEventFromInput) { |
| this.cancel(); |
| } |
| } |
| |
| private computeLabelClass() { |
| const {readOnly, value, placeholder} = this; |
| const classes = []; |
| if (!readOnly) { |
| classes.push('editable'); |
| } |
| if (this.usePlaceholder(value, placeholder)) { |
| classes.push('placeholder'); |
| } |
| return classes.join(' '); |
| } |
| } |