| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| |
| import '../gr-button/gr-button'; |
| import '../gr-icon/gr-icon'; |
| import '../../shared/gr-autocomplete/gr-autocomplete'; |
| import { |
| AutocompleteQuery, |
| GrAutocomplete, |
| } from '../gr-autocomplete/gr-autocomplete'; |
| import {Key} from '../../../utils/dom-util'; |
| import {queryAndAssert} from '../../../utils/common-util'; |
| import {css, html, LitElement, nothing} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {ShortcutController} from '../../lit/shortcut-controller'; |
| import {ValueChangedEvent} from '../../../types/events'; |
| import {fire} from '../../../utils/event-util'; |
| import '@material/web/menu/menu'; |
| import {MdMenu} from '@material/web/menu/menu'; |
| import '@material/web/textfield/filled-text-field'; |
| import {MdFilledTextField} from '@material/web/textfield/filled-text-field'; |
| |
| 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?: MdMenu; |
| |
| @property() |
| labelText = ''; |
| |
| @property({type: Boolean}) |
| editing = false; |
| |
| @property() |
| value?: string; |
| |
| @property() |
| placeholder = ''; |
| |
| @property({type: Boolean}) |
| readOnly = 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?: MdFilledTextField; |
| |
| @query('#autocomplete') |
| grAutocomplete?: GrAutocomplete; |
| |
| private readonly shortcuts = new ShortcutController(this); |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| align-items: center; |
| display: inline-flex; |
| } |
| 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); |
| --md-menu-container-color: var(--dialog-background-color); |
| --md-menu-top-space: 0px; |
| --md-menu-bottom-space: 0px; |
| } |
| .inputContainer { |
| background-color: var(--dialog-background-color); |
| padding: var(--spacing-m); |
| white-space: nowrap; |
| } |
| /* This makes inputContainer on one line. */ |
| .inputContainer gr-autocomplete, |
| .inputContainer .buttons { |
| display: inline-block; |
| } |
| .buttons gr-button { |
| margin-left: var(--spacing-m); |
| } |
| md-filled-text-field { |
| min-width: 15em; |
| --md-filled-text-field-container-color: rgba(0, 0, 0, 0); |
| --md-filled-text-field-focus-active-indicator-color: var( |
| --link-color |
| ); |
| --md-filled-text-field-hover-state-layer-color: rgba(0, 0, 0, 0); |
| --md-filled-field-top-space: var(--spacing-m); |
| --md-filled-field-bottom-space: var(--spacing-m); |
| --md-filled-field-leading-space: 8px; |
| --md-filled-field-active-indicator-color: var(--link-color); |
| --md-filled-field-hover-active-indicator-color: var(--link-color); |
| --md-sys-color-primary: var(--primary-text-color); |
| --md-sys-color-on-surface: var(--primary-text-color); |
| --md-sys-color-on-surface-variant: var(--deemphasized-text-color); |
| --md-filled-text-field-label-text-color: var( |
| --deemphasized-text-color |
| ); |
| --md-filled-text-field-focus-label-text-color: var( |
| --deemphasized-text-color |
| ); |
| --md-filled-text-field-hover-label-text-color: var( |
| --deemphasized-text-color |
| ); |
| } |
| gr-button gr-icon { |
| color: inherit; |
| } |
| gr-button.pencil { |
| --gr-button-padding: var(--spacing-s); |
| --margin: calc(0px - var(--spacing-s)); |
| } |
| .dropdown-content { |
| width: max-content; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| this.setAttribute('title', this.computeLabel()); |
| return html`<div style="position: relative;"> |
| ${this.renderActivateButton()} |
| <md-menu |
| id="dropdown" |
| anchor="button" |
| tabindex="-1" |
| .quick=${true} |
| @closing=${() => { |
| this.cancel(); |
| }} |
| > |
| <div class="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> |
| </md-menu> |
| </div>`; |
| } |
| |
| private renderActivateButton() { |
| if (this.showAsEditPencil) { |
| return html`<gr-button |
| id="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 |
| id="button" |
| 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} |
| .placeholder=${this.placeholder} |
| id="autocomplete" |
| .text=${this.inputText} |
| .query=${this.query} |
| @cancel=${this.cancel} |
| @text-changed=${(e: ValueChangedEvent) => { |
| this.inputText = e.detail.value; |
| }} |
| > |
| </gr-autocomplete>`; |
| } else { |
| return html` |
| <md-filled-text-field |
| id="input" |
| .label=${this.labelText} |
| .placeholder=${this.placeholder} |
| .maxlength=${this.maxLength} |
| .value=${this.inputText} |
| aria-label=${this.labelText || this.placeholder || nothing} |
| > |
| </md-filled-text-field> |
| `; |
| } |
| } |
| |
| 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?.show(); |
| 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; |
| // TODO: This event seems to be unused (no listener). Remove? |
| fire(this, 'changed', this.value); |
| } |
| |
| 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!.shadowRoot!.querySelector('input')!; |
| } |
| } |
| |
| 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(' '); |
| } |
| } |