jb-input
Version:
input web component with built in validation
480 lines (473 loc) • 17.6 kB
text/typescript
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);
}