import { classMap } from "lit/directives/class-map.js"; import { type CSSResultGroup, html, type HTMLTemplateResult, type PropertyValues, unsafeCSS } from 'lit'; import { defaultValue } from "../../internal/default-value"; import { FormControlController } from "../../internal/form"; import { HasSlotController } from "../../internal/slot"; import { ifDefined } from "lit/directives/if-defined.js"; import { property, query, state } from 'lit/decorators.js'; import { watch } from "../../internal/watch"; import ZincElement from '../../internal/zinc-element'; import ZnSelect from "../select"; import type { ZincFormControl } from '../../internal/zinc-element'; import type ZnInput from "../input"; import styles from './inline-edit.scss'; /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/inline-edit * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - Default slot. When `input-type` is `select`, accepts `zn-option` elements to define select options. If provided, takes precedence over the `options` property. * @slot example - An example slot. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnInlineEdit extends ZincElement implements ZincFormControl { static styles: CSSResultGroup = unsafeCSS(styles); private readonly formControlController = new FormControlController(this, { defaultValue: (control: ZnInlineEdit) => control.defaultValue, value: (control: ZnInlineEdit) => { if (control.multiple) { const val = typeof control.value === 'string' ? control.value.split(' ').filter(v => v !== '') : control.value; return val.length > 0 ? val : ''; } return control.value; }, }); private readonly hasSlotController = new HasSlotController(this, 'help-text', '[default]'); @property() value: string | string[] = ''; @property() name: string; @property({ reflect: true }) placeholder: string; @property({ attribute: 'edit-text' }) editText: string; @property({ type: Boolean }) disabled: boolean @property({ type: Boolean }) inline: boolean @property({ type: Boolean }) padded: boolean @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; @property({ type: Boolean }) required: boolean @property() pattern: string; @property({ type: Boolean, reflect: true }) multiple: boolean; @property({ type: Boolean }) clearable: boolean; @property() min: string | number; @property() max: string | number; @property() step: number | 'any'; @property({ attribute: "input-type" }) inputType: 'select' | 'text' | 'data-select' | 'number' | 'textarea' = 'text'; @property({ type: Object }) options: { [key: string]: string } = {}; @property({ attribute: 'provider' }) selectProvider: string; @property({ attribute: 'icon-position', type: Boolean }) iconPosition: 'start' | 'end' | 'none' = 'none'; /** * The URL to fetch options from. When set , the component fetches JSON from this URL and renders the results as * options. The expected format is an array of objects with `key` and `value` properties: * `[{"key": "us", "value": "United States"}, ...]` * When not set, the component works exactly as before using slotted `` elements. * Only works with type select. */ @property({ attribute: 'data-uri' }) dataUri: string; /** * Context data to send as a header when fetching options from the URL specified by the `src` property. */ @property({ attribute: 'context-data' }) contextData: string; /** Enables search/filtering on select inputs. */ @property({ type: Boolean }) search: boolean; /** The input's help text. If you need to display HTML, use the `help-text` slot instead. **/ @property({ attribute: 'help-text' }) helpText: string = ''; /** The text direction for the input (ltr or rtl) **/ @property() dir: 'ltr' | 'rtl' | 'auto' = 'auto'; /** * Specifies what permission the browser has to provide assistance in filling out form field values. Refer to * [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values. */ @property() autocomplete: string; @state() private hasFocus: boolean; @state() private isEditing: boolean; private _valueBeforeEdit: string | string[]; @query('.ai__input') input: ZnInput | ZnSelect; @defaultValue('value') defaultValue: string | string[]; get validity(): ValidityState { return this.input?.validity; } get validationMessage(): string { return this.input.validationMessage; } checkValidity(): boolean { return this.input.checkValidity(); } getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } reportValidity(): boolean { return this.input.reportValidity(); } setCustomValidity(message: string): void { this.input.setCustomValidity(message); } connectedCallback() { super.connectedCallback(); document.addEventListener('keydown', this.escKeyHandler); document.addEventListener('keydown', this.submitKeyHandler); document.addEventListener('click', this.mouseEventHandler); this.addEventListener('mousedown', this.captureMouseDown, { capture: true }); this.addEventListener('keydown', this.captureKeyDown, { capture: true }); } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('keydown', this.escKeyHandler); document.removeEventListener('keydown', this.submitKeyHandler); document.removeEventListener('click', this.mouseEventHandler); this.removeEventListener('mousedown', this.captureMouseDown, true); this.removeEventListener('keydown', this.captureKeyDown, true); } async firstUpdated() { await this.updateComplete; this.input.addEventListener('onclick', this.handleEditClick); } protected willUpdate(changedProperties: PropertyValues) { if (changedProperties.has('value') || changedProperties.has('multiple')) { if (this.multiple && typeof this.value === 'string') { this.value = this.value.split(' ').filter(v => v !== ''); } else if (!this.multiple && Array.isArray(this.value)) { this.value = (this.value as string[]).join(' '); } } } @watch('value', { waitUntilFirstUpdate: true }) async handleValueChange() { await this.updateComplete; this.formControlController.updateValidity(); } @watch('isEditing', { waitUntilFirstUpdate: true }) async handleIsEditingChange() { await this.updateComplete; if (this.input instanceof ZnSelect && !this.isEditing) { await this.input.hide(); } } mouseEventHandler = (e: MouseEvent) => { if (this.isEditing && !this.contains(e.target as Node)) { const hasChanged = Array.isArray(this.value) ? JSON.stringify(this.value) !== JSON.stringify(this._valueBeforeEdit) : this.value !== this._valueBeforeEdit; if (!hasChanged) { this.isEditing = false; this.input.blur(); } } }; escKeyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape' && this.isEditing) { this.isEditing = false; this.value = this._valueBeforeEdit; this.input.blur(); } }; submitKeyHandler = (e: KeyboardEvent) => { if (e.key === 'Enter' && this.isEditing && !e.shiftKey) { this.isEditing = false; this.emit('zn-submit', { detail: { value: this.value, element: this } }); this.formControlController.submit(); this.input.blur(); } } captureMouseDown = (e: MouseEvent) => { if (this.disabled && !this.isEditing) { e.stopPropagation(); } } captureKeyDown = (e: KeyboardEvent) => { if (this.disabled && !this.isEditing) { e.stopPropagation(); e.preventDefault(); } } handleEditClick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (this.disabled) { return; } if (!this.isEditing) { this._valueBeforeEdit = this.value; } this.isEditing = true; } handleSubmitClick = (e: MouseEvent) => { e.preventDefault(); this.isEditing = false; this.emit('zn-submit', { detail: { value: this.value, element: this } }); this.formControlController.submit(); }; handleCancelClick = (e: MouseEvent) => { e.preventDefault(); this.isEditing = false; this.value = this._valueBeforeEdit; }; handleBlur = () => { this.hasFocus = false; }; handleInput = (e: Event) => { if (this.disabled || !this.isEditing) { return; } this.value = (e.target as (HTMLInputElement | HTMLSelectElement)).value; this.emit('zn-input'); }; private moveSlottedOptionsToSelect() { if (!(this.input instanceof ZnSelect)) return; const options = Array.from(this.querySelectorAll('zn-option')); options.forEach(option => { this.input.appendChild(option); }); } handleSlotChange = async () => { await this.updateComplete; if (this.input instanceof ZnSelect) { this.moveSlottedOptionsToSelect(); } }; protected render() { const hasEditText = this.editText; const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasHelpText = this.helpText ? true : hasHelpTextSlot; // Default input type to select if options are provided if (Object.keys(this.options).length > 0) { this.inputType = 'select'; } // Default to data-select if provider is provided if (this.selectProvider) { this.inputType = 'data-select'; } let input: HTMLTemplateResult; switch (this.inputType) { case 'select': input = this._getSelectInput(); break; case 'data-select': input = this._getDataSelectInput(); break; case 'number': input = this._getNumberInput(); break; case 'textarea': input = this._getTextAreaInput(); break default: input = this._getTextInput(); } return html`
${input}
${!this.isEditing ? html` ${hasEditText ? html` ${this.editText} ` : html` `}` : html` `}
${this.isEditing && hasHelpText ? html`
${this.helpText}
` : ''} ${this.inputType === 'select' ? html`
` : ''}`; } protected _getTextAreaInput(): HTMLTemplateResult { return html` `; } protected _getTextInput(): HTMLTemplateResult { return html` `; } protected _getNumberInput(): HTMLTemplateResult { return html` `; } protected _getSelectInput(): HTMLTemplateResult { const hasSlottedOptions = this.hasSlotController.test('[default]'); const value = this.multiple && Array.isArray(this.value) ? (this.value as string[]).join(' ') : this.value; return html` ${!hasSlottedOptions ? Object.keys(this.options).map(key => html` ${this.options[key]} `) : ''} ` } protected _getDataSelectInput(): HTMLTemplateResult { return html` `; } }