import { ValidationHelper, type ShowValidationErrorParameters, type ValidationItem, type ValidationResult, type WithValidation } from "jb-validation"; import CSS from "./jb-file-input.css"; import VariablesCSS from "./variables.css"; import type { ElementObjects, FileInputStatus, ValidationValue } from "./types"; import type { JBButtonWebComponent } from "jb-button"; import { registerDefaultVariables } from 'jb-core/theme'; import { renderHTML } from "./render"; import { dictionary } from "./i18n"; import { i18n } from "jb-core/i18n"; export * from "./types.js"; export class JBFileInputWebComponent extends HTMLElement implements WithValidation { static formAssociated = true; #value: File | null = null; #elements!: ElementObjects; #required = false; set required(value: boolean) { this.#required = value; this.#validation.checkValidity({ showError: false }); } get required() { return this.#required; } #internals?: ElementInternals; #fileInputStatus: FileInputStatus = "empty"; #acceptTypes = "application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, text/plain, application/pdf, image/*"; get name() { return this.getAttribute('name') || ''; } set name(value: string | null | undefined) { if (value) { this.setAttribute('name', value) } else { this.removeAttribute('name') } } get acceptTypes() { return this.#acceptTypes; } set acceptTypes(value: string) { if (value) { this.#acceptTypes = value; this.#elements.virtualInput.accept = value; } } get value() { return this.#value; } set value(value) { if (value == null) { this.resetValue(); } else if (value instanceof File) { this.#value = value; this.#elements.file.fileName.innerHTML = value.name; this.#internals?.states?.add("fill"); this.#internals?.states?.delete("empty") this.#internals?.setFormValue(value); this.#validation.checkValidity({ showError: false }); } } get status() { //it is read only variable return this.#fileInputStatus; } get selectedFileType(): string | null { if (this.#value) { this.#value.type; } return null; } #uploadPercent: number | null = null; get uploadPercent() { return this.#uploadPercent; } set uploadPercent(value: number | null) { this.#elements this.#uploadPercent = value; this.#elements.uploader.bg.style.setProperty("--upload-percent", `${value}%`); } #validation = new ValidationHelper({ clearValidationError: this.clearValidationError.bind(this), getValue: () => ({ file: this.#value }), getValidations: this.#getInsideValidation.bind(this), getValueString: (val) => (val.file?.name ?? ""), setValidationResult: this.#setValidationResult.bind(this), showValidationError: this.showValidationError.bind(this) }); get validation() { return this.#validation; } #isAutoValidationDisabled = false; get isAutoValidationDisabled() { return this.#isAutoValidationDisabled; } set isAutoValidationDisabled(value: boolean) { this.#isAutoValidationDisabled = value; } constructor() { super(); if (typeof this.attachInternals == "function") { //some browser dont support attachInternals this.#internals = this.attachInternals(); this.#internals.role = "group"; } this.initWebComponent(); if (this.#internals) this.#internals.ariaLabel = dictionary.get(i18n, "chooseFile"); this.initProp(); this.registerEventListener(); } initWebComponent() { const shadowRoot = this.attachShadow({ mode: "open", delegatesFocus: true, slotAssignment: "named", serializable: true, }); registerDefaultVariables(); const html = `\n${renderHTML()}`; const element = document.createElement("template"); element.innerHTML = html; shadowRoot.appendChild(element.content.cloneNode(true)); this.#elements = { componentWrapper: shadowRoot.querySelector(".jb-file-input-web-component") as HTMLDivElement, placeholder: { section: shadowRoot.querySelector(".placeholder-section") as HTMLButtonElement, wrapper: shadowRoot.querySelector(".placeholder-wrapper") as HTMLSpanElement, title: shadowRoot.querySelector(".placeholder-title") as HTMLSpanElement }, file: { section: shadowRoot.querySelector(".file-section") as HTMLDivElement, wrapper: shadowRoot.querySelector(".file-wrapper") as HTMLButtonElement, fileName: shadowRoot.querySelector(".file-wrapper .file-name") as HTMLSpanElement }, virtualInput: this.#createVirtualInputFile(), uploader: { bg: shadowRoot.querySelector(".upload-bg") as HTMLDivElement }, overlay: { delete: shadowRoot.querySelector(".delete-button") as JBButtonWebComponent, download: shadowRoot.querySelector(".download-button") as JBButtonWebComponent, wrapper: shadowRoot.querySelector(".file-overlay") as HTMLDivElement, reselect: shadowRoot.querySelector(".reselect-button") as HTMLButtonElement } }; } initProp() { this.setStatus("empty"); this.#internals?.states?.add("empty") this.#value = null; this.#required = false; } registerEventListener() { this.#elements.placeholder.section.addEventListener("click", this.openFileSelector.bind(this)); this.#elements.file.wrapper.addEventListener("click", this.openFileSelector.bind(this)); this.#elements.overlay.reselect.addEventListener("click", this.openFileSelector.bind(this)); this.#elements.overlay.delete.addEventListener("click", this.#onDeleteClick.bind(this)); this.#elements.overlay.download.addEventListener("click", this.#onDownloadClick.bind(this)); } #createVirtualInputFile() { const virtualInputFile = document.createElement( "input" ) as HTMLInputElement; virtualInputFile.type = "file"; virtualInputFile.accept = this.acceptTypes; virtualInputFile.addEventListener("change", (e) => this.#onFileSelected(e)); return virtualInputFile; } openFileSelector() { this.#elements.virtualInput.click(); } static get observedAttributes() { return ["required", "placeholder-title", "accept"]; } attributeChangedCallback(name: string, oldValue: string, newValue: string) { // do something when an attribute has changed this.onAttributeChange(name, newValue); } onAttributeChange(name: string, value: string) { switch (name) { case "required": if (value == "" || value == "true") { this.#required = true; if (this.#internals) { this.#internals.ariaRequired = "true"; } } else { this.#required = false; if (this.#internals) { this.#internals.ariaRequired = "false"; } } break; case "placeholder-title": this.#elements.placeholder.title.innerHTML = value; if (this.#internals) { this.#internals.ariaPlaceholder = value; } if (this.#internals) { this.#internals.ariaLabel = value; } break; case "accept": this.acceptTypes = value; break; } } #onFileSelected(e: Event) { const target = e.target as HTMLInputElement; if (target.files && target.files.length > 0) { //if user select file and not click on cancel //when user select a image from his computer but dont want to edit const file = target.files[0]; this.value = file; this.#elements.file.fileName.innerHTML = file.name; this.setStatus("selected"); this.#triggerOnChangeEvent(); } else { //user click on cancel button of file select dialog } this.#validation.checkValiditySync({ showError: true }); } setStatus(status: FileInputStatus) { // this.#elements.componentWrapper.setAttribute("status", status); this.#fileInputStatus = status; } showValidationError(error: ShowValidationErrorParameters) { this.#elements.componentWrapper.classList.add("--has-error"); if (this.#internals) { this.#internals.ariaInvalid = "true"; this.#internals.ariaDescription = error.message; } } clearValidationError() { this.#elements.componentWrapper.classList.remove("--has-error"); if (this.#internals) this.#internals.ariaInvalid = "false"; } resetValue() { //this function is public and called outside of web component and call inside if user set value = null this.#value = null; this.#internals?.states?.add("empty") this.#internals?.states?.delete("fill") this.#internals?.setFormValue(null); this.setStatus("empty"); this.#elements.virtualInput.value = ""; } #triggerOnChangeEvent() { const event = new Event("change"); this.dispatchEvent(event); } #getInsideValidation() { const ValidationList: ValidationItem[] = []; if (this.#required) { const message = dictionary.get(i18n, "requiredMessage"); ValidationList.push({ validator: ({ file }) => { return file !== null; }, message: message, stateType: "valueMissing" }); } //TODO: add validation for file size 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) { 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 ?? null; } #onDownloadClick(e: MouseEvent) { e.stopPropagation(); const event = new CustomEvent("download", { cancelable: false }); this.dispatchEvent(event); } #onDeleteClick(e: MouseEvent) { e.stopPropagation(); this.value = null; this.validation.checkValiditySync({ showError: true }); this.#triggerOnChangeEvent(); this.#dispatchDeleteEvent(); } #dispatchDeleteEvent() { const e = new CustomEvent("delete", { cancelable: false }); this.dispatchEvent(e); } } const myElementNotExists = !customElements.get("jb-file-input"); if (myElementNotExists) { window.customElements.define("jb-file-input", JBFileInputWebComponent); }