import CSS from './jb-switch.css'; import VariablesCSS from './variables.css'; import { ValidationHelper, type ValidationItem, type ValidationResult, type WithValidation, type ShowValidationErrorParameters } from 'jb-validation'; import type { JBFormInputStandards } from 'jb-form'; import type { ElementsObject, ValidationValue } from './types.js'; import { registerDefaultVariables } from 'jb-core/theme'; import { renderHTML } from './render'; import { dictionary } from './i18n'; import { i18n } from 'jb-core/i18n'; import { parseBooleanAttribute } from 'jb-core'; export * from './types.js'; export class JBSwitchWebComponent extends HTMLElement implements WithValidation, JBFormInputStandards { static get formAssociated() { return true; } #value = false; #isDirty = false; //when we call on before change we save new value here so when user use event.target.value he will see new value but after the event bubble done we null it. //it mostly defined here for react eco-system #ChangeEventPreservedValue: boolean | null = null; elements!: ElementsObject; #disabled = false; #internals?: ElementInternals; get value(): boolean { if (this.#ChangeEventPreservedValue !== null) { return this.#ChangeEventPreservedValue; } return this.#value; } set value(value: boolean) { this.#isDirty = true; this.#setValue(value); } #setValue(value: boolean) { const booleanValue = Boolean(value); if (this.#value !== booleanValue) { this.#value = booleanValue; } this.#updateDomForValueChange(); this.elements.componentWrapper.setAttribute("aria-checked", this.#value ? "true" : "false"); this.#setFormValue(); } #setFormValue() { if (this.#internals && typeof this.#internals.setFormValue === "function") { this.#internals.setFormValue(`${this.#value}`); } } #isLoading = false; get isLoading() { return this.#isLoading; } get form() { return this.#internals?.form ?? null; } set isLoading(value: boolean) { this.#isLoading = Boolean(value); this.#setState("loading", this.#isLoading); this.elements.componentWrapper.setAttribute("aria-busy", this.#isLoading ? "true" : "false"); if (this.#isLoading) { this.elements.triggerCircleBar.classList.add('--loading'); } else { this.elements.triggerCircleBar.classList.remove('--loading'); } } #validation = new ValidationHelper({ clearValidationError: this.clearValidationError.bind(this), getValue: () => (this.value), getValidations: this.#getInsideValidationsCallback.bind(this), getValueString: () => (this.value ? 'true' : 'false'), setValidationResult: this.#setValidationResult.bind(this), showValidationError: this.showValidationError.bind(this) }) get validation() { return this.#validation; } get name() { return this.getAttribute('name') || ''; } set name(value: string) { if (value) { this.setAttribute('name', value); } else { this.removeAttribute('name'); } } #initialValue = false; /** * Default and reset value. It initializes `value` until the live value is explicitly set. */ get initialValue(): boolean { return this.#initialValue; } set initialValue(value: boolean) { this.#initialValue = Boolean(value); if (!this.#isDirty) { this.#setValue(this.#initialValue); } } formResetCallback() { this.#isDirty = false; this.#setValue(this.initialValue); this.#validation.reset(); this.#internals?.setValidity({}, ''); } formDisabledCallback(disabled: boolean) { this.disabled = disabled; } get isDirty(): boolean { return this.#value !== this.initialValue; } #required = false; set required(value: boolean) { this.#required = Boolean(value); this.elements.componentWrapper.setAttribute("aria-required", this.#required ? "true" : "false"); this.#validation.checkValiditySync({ showError: false }); } get required() { return this.#required; } isAutoValidationDisabled = false; get disabled() { return this.#disabled; } set disabled(value: boolean) { this.#disabled = Boolean(value); this.#setState("disabled", this.#disabled); this.elements.componentWrapper.disabled = this.#disabled; } constructor() { super(); if (typeof this.attachInternals === "function") { //some browser don't support attachInternals this.#internals = this.attachInternals(); } this.initWebComponent(); } connectedCallback(): void { // standard web component event that called when all of dom is bound this.callOnLoadEvent(); this.initProp(); this.callOnInitEvent(); } callOnLoadEvent(): void { const event = new CustomEvent('load', { bubbles: true, composed: false }); this.dispatchEvent(event); } callOnInitEvent(): void { const event = new CustomEvent('init', { bubbles: true, composed: false }); this.dispatchEvent(event); } initWebComponent(): void { const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true, clonable: true, 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-switch-web-component')!, falseText: shadowRoot.querySelector('.false-text')!, trueText: shadowRoot.querySelector('.true-text')!, switch: shadowRoot.querySelector('.switch-svg')!, triggerCircleBar: shadowRoot.querySelector('.trigger-circle-bar')!, triggerButton: shadowRoot.querySelector('.trigger-button')!, }; this.elements.componentWrapper.setAttribute("aria-label", dictionary.get(i18n, "switchLabel")); this.registerEventListener(); } registerEventListener(): void { this.elements.componentWrapper.addEventListener('click', () => this.#onComponentClick()); } initProp() { if (this.hasAttribute('value')) { this.value = parseBooleanAttribute(this.getAttribute('value')); } this.required = parseBooleanAttribute(this.getAttribute('required')); this.disabled = parseBooleanAttribute(this.getAttribute('disabled')); this.isLoading = parseBooleanAttribute(this.getAttribute('loading')); } static get observedAttributes(): string[] { return ['true-title', "false-title", 'value', 'name', 'disabled', 'loading', 'required', 'label']; } attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { // do something when an attribute has changed this.onAttributeChange(name, newValue); } onAttributeChange(name: string, value: string | null): void { switch (name) { case 'value': this.value = parseBooleanAttribute(value); break; case 'true-title': this.#setCaptionText(this.elements.trueText, value); break; case 'false-title': this.#setCaptionText(this.elements.falseText, value); break; case 'disabled': this.disabled = parseBooleanAttribute(value); break; case 'loading': this.isLoading = parseBooleanAttribute(value); break; case 'required': this.required = parseBooleanAttribute(value); break; case 'label': this.elements.componentWrapper.setAttribute("aria-label", value ?? ""); break; } } #setCaptionText(element: HTMLSpanElement, value: string | null): void { const text = value ?? ""; element.innerText = text; element.dataset.text = text; } #onComponentClick(): void { if (this.#disabled) { return; } this.#ChangeEventPreservedValue = !this.#value; const isEventPrevented = this.#dispatchOnBeforeChangeEvent(); this.#ChangeEventPreservedValue = null; if (!isEventPrevented) { const wasDirty = this.#isDirty; this.#isDirty = true; this.#setValue(!this.#value); const DispatchedEvent = this.#dispatchOnChangeEvent(); if (DispatchedEvent.defaultPrevented) { this.#setValue(!this.#value); this.#isDirty = wasDirty; } } } #dispatchOnBeforeChangeEvent(): boolean { const event = new CustomEvent('before-change', { cancelable: true }); this.dispatchEvent(event); const prevented = event.defaultPrevented; return prevented; } #dispatchOnChangeEvent() { const event = new Event('change', { bubbles: true, cancelable: true, composed: true }); this.dispatchEvent(event); return event; } /** * @public */ //TODO: find a way to manage focus and keyboard control focus() { this.elements.componentWrapper.focus(); } #updateDomForValueChange() { this.#setState("active", this.value); this.#setState("inactive", !this.value); if (this.value) { this.elements.falseText.classList.remove("--active"); this.elements.trueText.classList.add("--active"); this.elements.switch.classList.add('--active'); } else { this.elements.trueText.classList.remove("--active"); this.elements.falseText.classList.add("--active"); this.elements.switch.classList.remove('--active'); } } #setState(state: string, isActive: boolean) { const states = this.#internals?.states; if (isActive) { states?.add(state); } else { states?.delete(state); } } /** * @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); } } #getInsideValidationsCallback(): ValidationItem[] { if (this.#required) { return [{ validator: (value) => value !== false, message: dictionary.get(i18n, 'requireMessage'), stateType: "valueMissing" }]; } return []; } showValidationError(params: ShowValidationErrorParameters) { this.#internals?.states?.add("invalid"); this.elements.componentWrapper.setAttribute("aria-invalid", "true"); } clearValidationError() { this.#internals?.states?.delete("invalid"); this.elements.componentWrapper.setAttribute("aria-invalid", "false"); } get validationMessage() { return this.#internals?.validationMessage ?? ""; } checkValidity() { return this.#validation.checkValiditySync({ showError: false }).isAllValid; } reportValidity() { return this.#validation.checkValiditySync({ showError: true }).isAllValid; } } const myElementNotExists = !customElements.get('jb-switch'); if (myElementNotExists) { window.customElements.define('jb-switch', JBSwitchWebComponent); }