/** @license Copyright (c) 2022 trading_peter This program is available under Apache License Version 2.0 */ import { LitElement, html, css } from 'lit'; import { FormElement } from '@tp/helpers/form-element.js'; import { EventHelpers } from '@tp/helpers/event-helpers.js'; import { Inert } from '@tp/helpers/inert.js'; const mixins = [ FormElement, EventHelpers, Inert ]; const BaseElement = mixins.reduce((baseClass, mixin) => { return mixin(baseClass); }, LitElement); class TpInput extends BaseElement { static get styles() { return [ css` :host { display: block; position: relative; outline: none; font-size: 14px; } .wrap ::slotted(input) { outline: none; box-shadow: none; padding: 0; width: 100%; min-width: 0; /** Because of FF **/ background: transparent; border: none; font-family: inherit; font-size: inherit; text-align: inherit; color: inherit; /** FF seems to need this **/ } .error-message { position: absolute; z-index: 1; left: 0; right: 0; font-size: 10px; color: var(--tp-input-text-color-invalid, #B71C1C); transition: opacity 0.3s; opacity: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; } :host([invalid]) .error-message { opacity: 1; } .wrap { display: flex; flex-direction: row; padding: 5px; border-radius: 2px; border: solid 1px #000; } .prefix, .suffix { display: flex; flex-direction: row; align-items: center; justify-content: center; } .prefix ::slotted([slot="prefix"]) { margin-right: 5px; white-space: nowrap; } .suffix ::slotted([slot="suffix"]) { margin-left: 5px; white-space: nowrap; } ` ]; } render() { const { errorMessage } = this; return html`
${errorMessage ? html`
${errorMessage}
` : null} `; } static get properties() { return { // The value for this element. value: { type: String }, // If true, something invalid was entered. invalid: { type: Boolean, reflect: true }, /* * Force invalid state no matter what. * Useful if the input must be invalid even if the value itself would be valid. * For example: Event if a valid email address was entered, an external test that makes a DNS check for the domain may fail. * In this case we still want to force the invalid state. */ forceInvalid: { type: Boolean }, /* * Regex pattern to live check the input. * Invalid input is blocked and never shown. * If you wan't live validation without blocking input use `pattern` and `auto-validate`. */ allowedPattern: { type: String }, /* * Error message to show if the value is invalid. */ errorMessage: { type: String }, /* * Validate while the control receives input. */ autoValidate: { type: Boolean }, /* * A custom validator function for checking the value. */ validator: { type: Object }, /* * Query selector to another input element. * The input's value must the be equal to the other input in order to be valid. */ equalTo: { type: String }, type: { type: String }, optional: { type: Boolean }, readonly: { type: Boolean, reflect: true }, focused: { type: Boolean, reflect: true }, disabled: { type: Boolean, reflect: true }, /* @private */ _previousValidInput: { type: String, value: '' } }; } constructor() { super(); this.value = ''; } get inputEl() { const slot = this.shadowRoot.querySelector('#content'); return slot.assignedNodes({ flatten: true }).filter(n => n.tagName === 'INPUT')[0]; } disconnectedCallback() { super.disconnectedCallback(); this.unlisten(this._equalToTarget, 'input', '_onInput'); } firstUpdated() { super.firstUpdated(); if (!this.inputEl) { console.warn(this.tagName + ': Cannot find input child!'); return; } this.listen(this.inputEl, 'focus', '_onFocus'); this.listen(this.inputEl, 'blur', '_onBlur'); this.listen(this.inputEl, 'input', '_onInput'); this.listen(this.inputEl, 'keypress', '_onKeypress'); if (this.value !== '' && this.value !== undefined && this.inputEl.value === '') { this.inputEl.value = this.value; this._onInput(); // Force validation } if (this.name === undefined && this.inputEl.name) { this.name = this.inputEl.name; } if (this.inputEl.name) { console.warn(this.tagName + ': Can\'t have a name on the inner input.'); this.inputEl.removeAttribute('name'); } if (this.value === undefined) { this.value = this.inputEl.value; } if (this.equalTo) { this.listen(this._equalToTarget, 'input', '_onInput'); } } shouldUpdate(changes) { if (changes.has('disabled')) { this._disabledChanged(this.disabled); } return super.shouldUpdate(changes); } updated(changes) { if (changes.has('optional')) { this._optionalChanged(changes.get('optional')); } if (changes.has('forceInvalid')) { this._forceInvalidChanged(); } if (changes.has('value')) { this._syncValue(); } if (changes.has('readonly')) { this._syncReadonly(); } } get _equalToTarget() { if (this._eqTarget) { return this._eqTarget; } // Make sure the input wants a `equalTo` target. if (!this.equalTo) { return; } const root = this.getRootNode(); this._eqTarget = root.querySelector(this.equalTo) || root.host.querySelector(this.equalTo); if (!this._eqTarget || this._eqTarget.value == undefined) { console.warn(this.tagName + ': Unable to find element to match against or target doesn\'t have a value property.', this); } return this._eqTarget; } get _valueRegEx() { if (this.allowedPattern) { return new RegExp(this.allowedPattern); } else { switch (this.type) { case 'number': { return /[0-9.,e-]/; } } } } _onFocus() { this.focused = true; } _onBlur() { this.focused = false; } _onInput() { this._inputWasChanged = true; if (this.allowedPattern && !this._patternAlreadyChecked) { const valid = this._checkPatternValidity(); if (!valid) { this.inputEl.value = this._previousValidInput; } } this.value = this._previousValidInput = this.inputEl.value; this._patternAlreadyChecked = false; if (this.autoValidate) { this.validate(); } } select() { this.inputEl.select(); } _onKeypress(e) { // Submit form if `Enter` key is pressed. if (e.keyCode === 13 && this._parentForm) { this._parentForm.submit(); e.preventDefault(); return; } if (!this.allowedPattern && this.type !== 'number') { return; } const regex = this._valueRegEx; if (!regex) { return; } // Handle special keys and backspace if (e.metaKey || e.ctrlKey || e.altKey) { return; } // Check the pattern either here or in `_onInput`, but not in both. this._patternAlreadyChecked = true; const thisChar = String.fromCharCode(e.charCode); if (this._isPrintable(e) && !regex.test(thisChar)) { e.preventDefault(); } } _checkPatternValidity() { const regex = this._valueRegEx; if (!regex) { return true; } for (let i = 0; i < this.inputEl.value.length; i++) { if (!regex.test(this.inputEl.value[i])) { return false; } } return true; } _isPrintable(e) { // What a control/printable character is varies wildly based on the browser. // - most control characters (arrows, backspace) do not send a `keypress` event // in Chrome, but the *do* on Firefox // - in Firefox, when they do send a `keypress` event, control chars have // a charCode = 0, keyCode = xx (for ex. 40 for down arrow) // - printable characters always send a keypress event. // - in Firefox, printable chars always have a keyCode = 0. In Chrome, the keyCode // always matches the charCode. // None of this makes any sense. // For these keys, ASCII code == browser keycode. const anyNonPrintable = (e.keyCode == 8) || // backspace (e.keyCode == 9) || // tab (e.keyCode == 13) || // enter (e.keyCode == 27); // escape // For these keys, make sure it's a browser keycode and not an ASCII code. const mozNonPrintable = (e.keyCode == 19) || // pause (e.keyCode == 20) || // caps lock (e.keyCode == 45) || // insert (e.keyCode == 46) || // delete (e.keyCode == 144) || // num lock (e.keyCode == 145) || // scroll lock (e.keyCode > 32 && e.keyCode < 41) || // page up/down, end, home, arrows (e.keyCode > 111 && e.keyCode < 124); // fn keys return !anyNonPrintable && !(e.charCode == 0 && mozNonPrintable); } _syncValue() { if (this.inputEl === undefined) return; if (this.inputEl.value !== this.value) { this.inputEl.value = this.value === undefined || this.value === null ? '' : this.value; } } _forceInvalidChanged() { if (!this.autoValidate || !this.inputEl || !this._inputWasChanged) return; if (this.forceInvalid) { this.invalid = true; } if (!this.forceInvalid) { this.invalid = this.validate(); } } /** * Validate the controls value. */ validate() { if (this.forceInvalid) { this.invalid = true; return false; } const valueIsFalsy = this.value === null || this.value === undefined || this.value === ''; // Run native validation first. let valid = this.inputEl.checkValidity(); // Then check if control is optional. If so and the value is falsy, assume valid. if (this.optional && valueIsFalsy) { valid = true; } else if (this.required && valueIsFalsy) { valid = false; } else if (typeof this.validator === 'function' && !this.validator(this, this.value)) { valid = false; } if (this.equalTo && this._equalToTarget && this.value !== this._equalToTarget.value) { valid = false; } this.invalid = !valid; return valid; } // Reset invalid state if value was changed. // This clears up old invalid states if the value was changed programmatically. _onValueChanged() { this.invalid = false; } focus() { this.inputEl.focus(); } blur() { this.inputEl.blur(); } /** * Reset the control if a parent era-form is reset. */ reset() { this.invalid = false; } _equalToChanged() { this.required = Boolean(this.equalTo); } // If the control is dynamically set completely optional, clear invalid state. _requiredChanged(newValue, oldValue) { if (oldValue !== undefined && (this.invalid || this.autoValidate)) { this.validate(); } } _syncReadonly() { if (this.inputEl) { if (this.readonly) { this.inputEl.setAttribute('readonly', ''); } else { this.inputEl.removeAttribute('readonly'); } } } _optionalChanged(oldValue) { if (oldValue === undefined) return; if (this.required === true && this.autoValidate === true) { this.validate(); } } _disabledChanged(disabled) { this.setAttribute('aria-disabled', disabled ? 'true' : 'false'); this.style.pointerEvents = disabled ? 'none' : ''; if (disabled) { this.focused = false; this.blur(); } } } window.customElements.define('tp-input', TpInput);