498 lines
12 KiB
JavaScript
498 lines
12 KiB
JavaScript
/**
|
|
@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`
|
|
<div class="wrap" part="wrap">
|
|
<div class="prefix">
|
|
<slot name="prefix"></slot>
|
|
</div>
|
|
<slot id="content"></slot>
|
|
<div class="suffix">
|
|
<slot name="suffix"></slot>
|
|
</div>
|
|
</div>
|
|
${errorMessage ? html`
|
|
<div class="error-message" part="error-message">${errorMessage}</div>
|
|
` : 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);
|