import { PublisherManager } from "@supersoniks/concorde/core/utils/PublisherProxy"; import { property } from "lit/decorators.js"; import { SubscriberInterface } from "@supersoniks/concorde/core/mixins/Subscriber"; import Objects from "@supersoniks/concorde/core/utils/Objects"; import { traverseDotNotation } from "@supersoniks/concorde/core/utils/Objects"; import { PublisherInterface, HTMLFormControl, CoreJSType, } from "../_types/types"; import { MixinArgsType } from "../_types/types"; type Constructor = new (...args: MixinArgsType[]) => T; export type FormElementValue = string | string[] | object | null | undefined; export interface FormElementInterface extends SubscriberInterface { getFormPublisher(): PublisherInterface; updateDataValue(): void; handleChange(e?: Event): void; handleBlur(e?: Event): void; getValueForFormPublisher(): FormElementValue; setValueFromPublisher(value: FormElementValue): void; setFormPublisherValue(value: FormElementValue): void; getFormPublisherValue(): FormElementValue; unsetOnDisconnect(): boolean; unset(): void; validateFormElement(): void; focus?: () => void; forceAutoFill: boolean; shadowRoot?: ShadowRoot; error: boolean; autofocus: boolean; required: boolean; disabled: boolean; formDataProvider: string; ariaLabelledby?: string; ariaLabel?: string; _value: FormElementValue; get value(): FormElementValue; set value(value: FormElementValue); _name: string; get name(): string; set name(value: string); } const keyboardLoops = new Map>(); const Form = >(superClass: T) => { /** * ## FormElement est la mixin utilisée par les éléments de formulaire de concorde ainsi que par le composant sonic-button. * * La propriété value est remplie automatiquement a l'aide de l'attribut name renseigné, ceci en prenant la valeur de la propriété du même nom dans les données du dataprovider associé. * * Par défault lorsque l'on édite l'input, la valeur est également mise à jour via le même dataprovider * * On peut cependant décider de mettre à jour la donnée à une autre adresse en renseignent l'attribut *formDataProvider*. * * Par conséquent, on peut par exemple le lier à un composant *queue* (via sa propriété *dataFilterProvider*) de manière à appeller en auto une api avec des filtres. * */ class FormElement extends superClass implements FormElementInterface { @property({ type: Boolean, reflect: true }) touched = false; @property({ type: Boolean }) error = false; @property({ type: Boolean }) autofocus = false; @property({ type: Boolean }) required = false; @property({ type: Boolean }) forceAutoFill = false; @property({ type: Boolean, reflect: true }) disabled = false; /* Attribut data-aria-label pour passer aria-label */ @property({ type: String, attribute: "data-aria-label" }) ariaLabel?: string; @property({ type: String, attribute: "data-aria-labelledby" }) ariaLabelledby?: string; onValueAssign?: (v: string) => void; onFormValueAssign?: (v: string) => void; onFormDataInValidate?: VoidFunction; constructor(...args: MixinArgsType[]) { super(); args; this.onValueAssign = (value) => { this.setValueFromPublisher(value); }; this.onFormValueAssign = async (value) => { this.setFormValueFromPublisher(value); }; this.onFormDataInValidate = () => { const formPublisher = this.getFormPublisher(); if (!(formPublisher && formPublisher.isFormValid.get())) { return; } this.validateFormElement(); }; } formDataProvider = ""; /** * Le nom du champ avec des caractéristiques similaire à un input html classique. */ _name = ""; @property() get name(): string { return this._name; } set name(value: string) { if (this.hasAttribute("name") && !this.forceAutoFill) value = this.getAttribute("name"); this._name = value; this.requestUpdate(); } validateFormElement() { //Implémentation dans une sous classe } unsetOnDisconnect() { return this.hasAttribute("unsetOnDisconnect"); } updateDataValue() { const name = this.getAttribute("name"); if (name) { const formPublisher = this.getFormPublisher(); if (formPublisher) { this.setFormPublisherValue(this.getValueForFormPublisher()); this.setFormValueFromPublisher(this.getFormPublisherValue()); } } } getFormPublisher() { if (!this.formDataProvider) this.formDataProvider = this.getAncestorAttributeValue("formDataProvider"); if (this.formDataProvider) { return PublisherManager.get(this.formDataProvider); } return null; //else return this.publisher; } setFormPublisherValue(value: FormElementValue): void { const formPublisher = this.getFormPublisher(); if (formPublisher) { traverseDotNotation(formPublisher, this.name).set(value); } } getFormPublisherValue(): FormElementValue { const formPublisher = this.getFormPublisher(); if (formPublisher) { return traverseDotNotation(formPublisher, this.name).get(); } return null; } /** * Mise en forme de la valeur fournie au formPublisher associé au composant * Destinée à être surchargée si besoin (cf Form-checkable) */ getValueForFormPublisher() { return this.value; } /** * Mise à jour de la valeur interne du composant en fonction de la valeur venant du publisher associé au composant * Destinée à être surchargée si besoin (cf Form-checkable) */ setValueFromPublisher(value: FormElementValue) { this.value = value; } /** * Mise à jour de la valeur interne du composant en fonction de la valeur venant du formPublisher associé au composant * Destinée à être surchargée si besoin (cf Form-checkable) */ setFormValueFromPublisher(value: FormElementValue) { this.value = value; } /** * La valeur du champ avec des caractéristiques similaire à un input html classique : */ _value: FormElementValue = ""; @property() get value() { return this._value; } set value(value) { if (value == null) value = ""; if ( Objects.isObject(value) && Object.prototype.hasOwnProperty.call(value, "__value") && (value as { _value?: CoreJSType })._value == undefined ) value = ""; if (this._value == value) { return; } this._value = value; this.updateDataValue(); this.requestUpdate(); } initPublisher() { let formPublisher = this.getFormPublisher(); const value = this.hasAncestorAttribute("initFromPublisher") && this._name && this.getFormPublisherValue() ? this.getFormPublisherValue() : this.getAttribute("value"); if (this._name && this.publisher) traverseDotNotation(this.publisher, this._name).offAssign( this.onValueAssign ); if (this._name && formPublisher) traverseDotNotation(formPublisher, this._name).offAssign( this.onFormValueAssign ); super.initPublisher(); if (!this.name) this._name = this.getAttribute("name"); if (!this.value) this._value = this.getAttribute("value"); if (this.publisher && this._name) { traverseDotNotation(this.publisher, this._name).onAssign( this.onValueAssign ); } formPublisher = this.getFormPublisher(); if (this._name && formPublisher) { traverseDotNotation(formPublisher, this._name).onAssign( this.onFormValueAssign ); formPublisher.onFormInvalidate(this.onFormDataInValidate); } this.updateDataValue(); if (value) this.value = value; } handleBlur() { this.touched = true; // this.validateFormElement(); } handleChange(e: Event) { this.value = (e.target as HTMLFormControl).value; const event = new Event("change"); this.dispatchEvent(event); } /** * Ajoute un comportement de navigation au clavier pour les éléments de formulaire * data-keyboard-nav peut contenir plusieurs valeurs qui identifient les boucles de navigation auquelles est attaché cet élément. * Les valeurs sont séparées par des espaces. * En appuyant sur la touche "Down", le composant va se déplacer dans la boucle de navigation correspondante en suivant le flux. * En appuyant sur la touche "Up", le déplacement inverse est effectué. */ addKeyboardNavigation() { const keyboardLoopIds: string = this.getAncestorAttributeValue("data-keyboard-nav"); if (!keyboardLoopIds) return; const split = keyboardLoopIds.split(" "); const keyboardLoopId = split[0]; if (!keyboardLoopId) return; for (const keyboardLoopId2 of split) { if (!keyboardLoops.has(keyboardLoopId2)) { keyboardLoops.set(keyboardLoopId2, []); } const keyboardLoop = keyboardLoops.get(keyboardLoopId2); if (keyboardLoop?.indexOf(this) == -1) { keyboardLoop.push(this); } } const keyboardLoop = keyboardLoops.get(keyboardLoopId); this.addEventListener("keydown", (e) => { const keyboardEvent = e as KeyboardEvent; if (!["ArrowDown", "ArrowUp"].includes(keyboardEvent.key)) return; const selector = "input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled])"; const loop = keyboardLoop?.filter((el) => { const child = el.shadowRoot?.querySelector(selector); if (!child) return false; const cpStyle = window.getComputedStyle(child); return ( cpStyle.display !== "none" && cpStyle.display !== "" && cpStyle.pointerEvents != "none" && cpStyle.visibility !== "hidden" && child.getBoundingClientRect().width > 0 ); }); let next: SubscriberInterface | null = null; if (keyboardEvent.key == "ArrowDown" && loop) { const index = loop.indexOf(this); if (index == loop.length - 1) { next = loop[0]; } else { next = loop[index + 1]; } } else if (keyboardEvent.key == "ArrowUp" && loop) { const index = loop.indexOf(this); if (index == 0) { next = loop[loop.length - 1]; } else { next = loop[index - 1]; } } const elt = next?.shadowRoot?.querySelector( selector ) as FormElementInterface | null; if (elt && elt.focus) { elt.focus(); e.preventDefault(); e.stopPropagation(); } }); } focus() { const inputElement = this.shadowRoot?.querySelector( "[data-form-element], button" ) as | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement; inputElement?.focus(); } connectedCallback(): void { this.formDataProvider = this.getAncestorAttributeValue( "formDataProvider" ) as string; super.connectedCallback(); this.addKeyboardNavigation(); } unset() { this.value = null; } disconnectedCallback() { if (this.unsetOnDisconnect()) { this.unset(); } super.disconnectedCallback(); if (this._name && this.publisher) traverseDotNotation(this.publisher, this._name).offAssign( this.onValueAssign ); const formPublisher = this.getFormPublisher(); if (this._name && formPublisher) { traverseDotNotation(formPublisher, this._name).offAssign( this.onFormValueAssign ); formPublisher.offFormInvalidate(this.onFormDataInValidate); } } } return FormElement as Constructor & T; }; export default Form;