UNPKG

jb-input

Version:

input web component with built in validation

480 lines (473 loc) 17.6 kB
import CSS from "./jb-input.scss"; import { type ValidationItem, type ValidationResult, type WithValidation, ValidationHelper, type ShowValidationErrorParameters } from 'jb-validation'; import type { JBFormInputStandards } from 'jb-form'; import type { ValueSetterEventType, ElementsObject, JBInputValue, StandardValueCallbackFunc, ValidationValue, SupportedState, } from "./types"; import { renderHTML } from "./render"; import { createInputEvent, createKeyboardEvent, listenAndSilentEvent } from "jb-core"; export class JBInputWebComponent extends HTMLElement implements WithValidation<ValidationValue>, JBFormInputStandards<string> { static get formAssociated() { return true; } #value: JBInputValue = { displayValue: "", value: "" }; elements!: ElementsObject; #disabled = false; get disabled() { return this.#disabled; } set disabled(value: boolean) { this.#disabled = value; this.elements.input.disabled = value; if (value) { //TODO: remove as any when typescript support // biome-ignore lint/suspicious/noExplicitAny: <explanation> (this.#internals as any).states?.add("disabled"); } else { // biome-ignore lint/suspicious/noExplicitAny: <explanation> (this.#internals as any).states?.delete("disabled"); } } #required = false; set required(value: boolean) { this.#required = value; this.#checkValidity(false); } get required() { return this.#required; } #internals?: ElementInternals; hasState(state:SupportedState):boolean { return (this.#internals as any).states.has(state); } /** * @description will determine if component trigger jb-validation mechanism automatically on user event or it just let user-developer handle validation mechanism by himself */ get isAutoValidationDisabled(): boolean { //currently we only support disable-validation in attribute and only in initiate time but later we can add support for change of this return this.getAttribute('disable-auto-validation') === '' || this.getAttribute('disable-auto-validation') === 'true' ? true : false; } #checkValidity(showError: boolean) { if (!this.isAutoValidationDisabled) { return this.#validation.checkValidity({ showError }); } } #validation = new ValidationHelper<ValidationValue>({ clearValidationError: () => this.clearValidationError(), showValidationError: this.showValidationError.bind(this), getValue: () => this.#value, getValidations: this.#getInsideValidation.bind(this), getValueString: () => this.#value.displayValue, setValidationResult: this.#setValidationResult.bind(this) }); get validation() { return this.#validation; } get displayValue() { return this.#value.displayValue; } get value(): string { //do not write any logic or task here this function will be overrides by other inputs like mobile input or payment input return this.#value.value; } //do not call it from inside and use #setValue in inside set value(value: string) { //do not write any logic or task here this function will be overrides by other inputs like mobile input or payment input this.#setValue(value, "SET_VALUE"); } #setValue(value: string, eventType: ValueSetterEventType) { if (value === null || value === undefined) { value = ""; } const standardValue = this.standardValue(value, eventType); this.#setValueByObject(standardValue); } #setValueByObject(valueOnj: JBInputValue) { this.#value = valueOnj; //comment for typescript problem if (this.#internals && typeof this.#internals.setFormValue == "function") { this.#internals.setFormValue(valueOnj.value); } this.elements.input.value = valueOnj.displayValue; } initialValue = ""; get isDirty(): boolean { return this.#value.value !== this.initialValue; } //selection input behavior get selectionStart(): number { return this.elements.input.selectionStart; } set selectionStart(value: number) { this.elements.input.selectionStart = value; } get selectionEnd(): number { return this.elements.input.selectionEnd; } set selectionEnd(value: number) { this.elements.input.selectionEnd = value; } get selectionDirection(): "forward" | "backward" | "none" { return this.elements.input.selectionDirection; } set selectionDirection(value: "forward" | "backward" | "none") { this.elements.input.selectionDirection = value; } get name() { return this.getAttribute('name') || ''; } // end of selection input behavior constructor() { super(); if (typeof this.attachInternals == "function") { //some browser dont support attachInternals this.#internals = this.attachInternals(); } this.#initWebComponent(); } connectedCallback(): void { // standard web component event that called when all of dom is banded this.#callOnLoadEvent(); this.initProp(); this.#callOnInitEvent(); } #callOnLoadEvent(): void { const event = new CustomEvent("load", { bubbles: true, composed: true }); this.dispatchEvent(event); } #callOnInitEvent(): void { const event = new CustomEvent("init", { bubbles: true, composed: true }); this.dispatchEvent(event); } #initWebComponent(): void { const shadowRoot = this.attachShadow({ mode: "open", delegatesFocus: true, }); this.#render(); this.elements = { // biome-ignore lint/style/noNonNullAssertion: <explanation> input: shadowRoot.querySelector(".input-box input")!, // biome-ignore lint/style/noNonNullAssertion: <explanation> inputBox: shadowRoot.querySelector(".input-box")!, // biome-ignore lint/style/noNonNullAssertion: <explanation> label: shadowRoot.querySelector("label")!, // biome-ignore lint/style/noNonNullAssertion: <explanation> labelValue: shadowRoot.querySelector("label .label-value")!, // biome-ignore lint/style/noNonNullAssertion: <explanation> messageBox: shadowRoot.querySelector(".message-box")!, slots: { // biome-ignore lint/style/noNonNullAssertion: <explanation> startSection: shadowRoot.querySelector(".jb-input-start-section-wrapper slot")!, // biome-ignore lint/style/noNonNullAssertion: <explanation> endSection: shadowRoot.querySelector(".jb-input-end-section-wrapper slot")! } }; this.#registerEventListener(); } #render() { const html = `<style>${CSS}</style>\n${renderHTML()}`; const element = document.createElement("template"); element.innerHTML = html; this.shadowRoot.appendChild(element.content.cloneNode(true)); } #standardValueCallbacks: StandardValueCallbackFunc[] = [] addStandardValueCallback(func: StandardValueCallbackFunc) { this.#standardValueCallbacks.push(func); } /** * @description this function will get user inputted or pasted text and convert it to standard one base on developer config */ standardValue(valueString: string | number, eventType: ValueSetterEventType): JBInputValue { let standardValue: JBInputValue = { displayValue: valueString.toString(), value: valueString.toString(), }; standardValue = this.#standardValueCallbacks.reduce((acc, func) => { const res = func(valueString.toString(), this.#value, acc, eventType); return res; }, standardValue); return standardValue; } #registerEventListener(): void { this.elements.input.addEventListener("change", (e: Event) => this.#onInputChange(e), { capture: false }); this.elements.input.addEventListener("beforeinput", this.#onInputBeforeInput.bind(this), { capture: false }); this.elements.input.addEventListener("input", (e) => this.#onInputInput(e as InputEvent), { capture: false }); //because keyboard event are composable and will scape from shadow dom we need to listen to them in document and stop their propagation listenAndSilentEvent(this.elements.input, "keyup", this.#onInputKeyup.bind(this)); listenAndSilentEvent(this.elements.input, "keydown", this.#onInputKeyDown.bind(this)); listenAndSilentEvent(this.elements.input, "keypress", this.#onInputKeyPress.bind(this)); } initProp() { this.#setValue(this.getAttribute("value") || "", "SET_VALUE"); } static get observedAttributes(): string[] { return [ "label", "type", "message", "value", "name", "autocomplete", "placeholder", "disabled", "inputmode", "readonly", 'disable-auto-validation', "virtualkeyboardpolicy", "required", "error", ]; } //please do not add any other functionality in this func because it may override by enstatite d component attributeChangedCallback(name: string, oldValue: string, newValue: string): void { // do something when an attribute has changed this.onAttributeChange(name, newValue); } protected onAttributeChange(name: string, value: string): void { switch (name) { case "name": case "autocomplete": case "inputmode": case "readonly": case "virtualkeyboardpolicy": this.elements.input.setAttribute(name, value); break; case "label": this.elements.labelValue.innerHTML = value; if (value == null || value === undefined || value === "") { this.elements.label.classList.add("--hide"); } else { this.elements.label.classList.remove("--hide"); } break; case "type": this.elements.input.setAttribute("type", value); if (value == "number") { if (this.getAttribute("inputmode") == null) { this.setAttribute("inputmode", "numeric"); } } break; case "message": if (!this.elements.messageBox.classList.contains("error")) { this.elements.messageBox.innerHTML = value; } break; case "value": this.#setValue(value, "SET_VALUE"); break; case "placeholder": this.elements.input.placeholder = value; break; case "disabled": if (value === "" || value === "true") { this.disabled = true; } else if (value === "false" || value == null || value === undefined) { this.disabled = false; this.elements.input.removeAttribute("disabled"); } break; case "required": //to update validation result base on new requirement this.required = value ? value !== 'false' : false; break; case "error": //to check error and show or clear error message base on error attribute this.reportValidity(); } } #onInputKeyDown(e: KeyboardEvent): void { this.#dispatchKeydownEvent(e); } #dispatchKeydownEvent(e: KeyboardEvent) { e.stopPropagation(); //trigger component event const event = createKeyboardEvent("keydown", e, { cancelable: true }); const isPrevented = !this.dispatchEvent(event); if (isPrevented) { e.preventDefault(); } } #onInputKeyPress(e: KeyboardEvent): void { e.stopPropagation(); const event = createKeyboardEvent("keypress", e, { cancelable: false }); this.dispatchEvent(event); } #onInputKeyup(e: KeyboardEvent): void { this.#dispatchKeyupEvent(e); if (e.keyCode == 13) { this.#onInputEnter(); } } #dispatchKeyupEvent(e: KeyboardEvent) { e.stopPropagation(); const event = createKeyboardEvent("keyup", e, { cancelable: false }); this.dispatchEvent(event); } #onInputEnter(): void { const event = new KeyboardEvent("enter"); this.dispatchEvent(event); } /** * * @param {InputEvent} e */ #onInputInput(e: InputEvent): void { const endCaretPos = (e.target as HTMLInputElement).selectionEnd || 0; const startCaretPos = (e.target as HTMLInputElement).selectionStart || 0; const inputText = (e.target as HTMLInputElement).value; const target = (e.target as HTMLInputElement); //to standard value again this.#setValue(inputText, "INPUT"); //if user type in middle of text we will return the caret position to the middle of text because this.value = inputText will move caret to end if (endCaretPos !== inputText.length) { //because number input does not support setSelectionRange if (!['number'].includes(this.getAttribute('type'))) { target.setSelectionRange(endCaretPos, endCaretPos); } } //e.target.setSelectionRange(startCaretPos + e.data, endCaretPos); this.#checkValidity(false); this.#dispatchOnInputEvent(e); } #dispatchOnInputEvent(e: InputEvent): void { e.stopPropagation(); const event = createInputEvent('input', e, { cancelable: true }); this.dispatchEvent(event); } #onInputBeforeInput(e: InputEvent): void { this.#dispatchBeforeInputEvent(e); } #dispatchBeforeInputEvent(e: InputEvent): boolean { e.stopPropagation(); const event = createInputEvent('beforeinput', e, { cancelable: true }); this.dispatchEvent(event); if (event.defaultPrevented) { e.preventDefault(); } return event.defaultPrevented; } #onInputChange(e: Event): void { const inputText = (e.target as HTMLInputElement).value; //here is the rare time we update value directly because we want trigger event that may read value directly from dom const oldValue = this.#value; this.#setValue(inputText, "CHANGE"); this.#checkValidity(true); const isCanceled = this.#dispatchOnChangeEvent(e); if (isCanceled) { this.#value = oldValue; e.preventDefault(); } } #dispatchOnChangeEvent(e: Event): boolean { e.stopPropagation(); const eventInit: EventInit = { bubbles: e.bubbles, cancelable: e.cancelable, composed: e.composed }; const event = new Event("change", eventInit); this.dispatchEvent(event); if (event.defaultPrevented) { return true; } return false; } showValidationError(error: ShowValidationErrorParameters) { this.elements.messageBox.innerHTML = error.message; //invalid state is used for ui purpose (this.#internals as any).states?.add("invalid"); } clearValidationError() { const text = this.getAttribute("message") || ""; this.elements.messageBox.innerHTML = text; (this.#internals as any).states?.delete("invalid"); } /** * @public */ focus() { //public method this.elements.input.focus(); } setSelectionRange(start: number | null, end: number | null, direction?: "forward" | "backward" | "none") { this.elements.input.setSelectionRange(start, end, direction); } #getInsideValidation(): ValidationItem<ValidationValue>[] { const validationList: ValidationItem<ValidationValue>[] = []; if (this.required) { validationList.push({ validator: /.{1}/g, message: `${this.getAttribute("label")} میبایست حتما وارد شود`, stateType: "valueMissing" }); } if(this.getAttribute("error") !== null && this.getAttribute("error").trim().length > 0){ validationList.push({ validator: undefined, message: this.getAttribute("error"), stateType: "customError" }); } return validationList; } /** * @public * @description this method used to check for validity but doesn't show error to user and just return the result * this method used by #internal of component */ checkValidity(): boolean { const validationResult = this.#validation.checkValiditySync({ showError: false }); if (!validationResult.isAllValid) { const event = new CustomEvent('invalid'); this.dispatchEvent(event); } return validationResult.isAllValid; } /** * @public * @description this method used to check for validity and show error to user */ reportValidity(): boolean { const validationResult = this.#validation.checkValiditySync({ showError: true }); if (!validationResult.isAllValid) { const event = new CustomEvent('invalid'); this.dispatchEvent(event); } return validationResult.isAllValid; } /** * @description this method called on every checkValidity calls and update validation result of #internal */ #setValidationResult(result: ValidationResult<ValidationValue>) { if (result.isAllValid) { this.#internals.setValidity({}, ''); } else { const states: ValidityStateFlags = {}; let message = ""; result.validationList.forEach((res) => { if (!res.isValid) { if (res.validation.stateType) { states[res.validation.stateType] = true; } if (message == '') { message = res.message; } } }); this.#internals.setValidity(states, message); } } get validationMessage() { return this.#internals.validationMessage; } } const myElementNotExists = !customElements.get("jb-input"); if (myElementNotExists) { window.customElements.define("jb-input", JBInputWebComponent); }