blob: cd020a981a815db57f075fe691e0be92d8d9e9a0 [file] [log] [blame]
/**
* @license
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@polymer/iron-dropdown/iron-dropdown';
import '@polymer/paper-input/paper-input';
import '../../../styles/shared-styles';
import '../gr-button/gr-button';
import '../../shared/gr-autocomplete/gr-autocomplete';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
import {PaperInputElementExt} from '../../../types/types';
import {
AutocompleteQuery,
GrAutocomplete,
} from '../gr-autocomplete/gr-autocomplete';
import {addShortcut, 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';
import {sharedStyles} from '../../../styles/shared-styles';
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;
/* private but used in test */
@state() inputText = '';
// This is used to push the iron-input element up on the page, so
// the input is placed in approximately the same position as the
// trigger.
@state() readonly verticalOffset = -30;
@property({type: Boolean})
showAsEditPencil = false;
@property({type: Boolean})
autocomplete = false;
@property({type: Object})
query: AutocompleteQuery = () => Promise.resolve([]);
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);
}
.buttons {
display: flex;
justify-content: flex-end;
padding-top: var(--spacing-l);
width: 100%;
}
.buttons gr-button {
margin-left: var(--spacing-m);
}
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 iron-icon {
color: inherit;
--iron-icon-height: 18px;
--iron-icon-width: 18px;
}
gr-button.pencil {
--gr-button-padding: 0px 0px;
}
`,
];
}
override render() {
this.setAttribute('title', this.computeLabel());
return html`${this.renderActivateButton()}
<iron-dropdown
id="dropdown"
.verticalAlign=${'auto'}
.horizontalAlign=${'auto'}
.verticalOffset=${this.verticalOffset}
allowOutsideScroll
@iron-overlay-canceled=${this.cancel}
>
<div class="dropdown-content" slot="dropdown-content">
<div class="inputContainer" part="input-container">
${this.renderInputBox()}
<div class="buttons">
<gr-button link="" id="cancelBtn" @click=${this.cancel}
>cancel</gr-button
>
<gr-button link="" id="saveBtn" @click=${this.save}
>save</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()}
><iron-icon icon="gr-icons:edit"></iron-icon
></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}
@commit=${this.handleCommit}
@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>`;
}
}
/** Called in disconnectedCallback. */
private cleanups: (() => void)[] = [];
override disconnectedCallback() {
super.disconnectedCallback();
for (const cleanup of this.cleanups) cleanup();
this.cleanups = [];
}
override connectedCallback() {
super.connectedCallback();
if (!this.getAttribute('tabindex')) {
this.setAttribute('tabindex', '0');
}
if (!this.getAttribute('id')) {
this.setAttribute('id', 'global');
}
this.cleanups.push(
addShortcut(this, {key: Key.ENTER}, e => this.handleEnter(e))
);
this.cleanups.push(
addShortcut(this, {key: Key.ESC}, e => this.handleEsc(e))
);
}
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();
const input = this.getInput();
if (!input?.value) return;
this.nativeInput.setSelectionRange(0, 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();
const input = this.getInput();
if (input) {
this.value = 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 {
return (this.getInput()?.$.nativeInput ||
this.getInput()?.inputElement ||
this.getGrAutocomplete()) as HTMLInputElement;
}
private handleEnter(event: KeyboardEvent) {
const grAutocomplete = this.getGrAutocomplete();
if (event.composedPath().some(el => el === grAutocomplete)) {
return;
}
const inputContainer = queryAndAssert(this, '.inputContainer');
const isEventFromInput = event
.composedPath()
.some(element => element === inputContainer);
if (isEventFromInput) {
this.save();
}
}
private handleEsc(event: KeyboardEvent) {
const inputContainer = queryAndAssert(this, '.inputContainer');
const isEventFromInput = event
.composedPath()
.some(element => element === inputContainer);
if (isEventFromInput) {
this.cancel();
}
}
private handleCommit() {
this.getInput()?.focus();
}
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(' ');
}
getInput(): PaperInputElementExt | null {
return this.shadowRoot!.querySelector<PaperInputElementExt>('#input');
}
getGrAutocomplete(): GrAutocomplete | null {
return this.shadowRoot!.querySelector<GrAutocomplete>('#autocomplete');
}
}