import CSS from './jb-notification.css'; import VariablesCSS from './variables.css'; import { renderHTML } from './render'; import type { ElementsObject, NotificationType, SwipeGestureData } from './types.js'; import { registerDefaultVariables } from 'jb-core/theme'; // export type { NotificationType }; export const notificationTypes: NotificationType[] = ["ERROR", "INFO", "SUCCESS", "WARNING"]; export class JBNotificationWebComponent extends HTMLElement { #state: "OPEN" | "CLOSE" = "CLOSE"; #title = ""; #description: string | null = null; #duration = 3000; #type: NotificationType = "INFO"; #internals?: ElementInternals; get title() { return this.#title; } set title(value: string) { this.#title = value; this.elements.titleWrapper.innerText = value; if (this.#internals) this.#internals.ariaLabel = value; } get description() { return this.#description; } set description(value: string | null) { this.#description = value; if (value) { this.elements.descWrapper.innerText = value; } else { this.elements.descWrapper.innerText = ""; } if (this.#internals) this.#internals.ariaDescription = value ?? ""; } get type(): NotificationType { return this.#type; } set type(value: NotificationType) { if (notificationTypes.includes(value as NotificationType)) { this.#type = value; this.updateUIBaseOnType(value); this.#syncTypeState(value); } else { console.error(`type ${value} is not a valid NotificationType`); } } get state() { return this.#state; } elements!: ElementsObject; constructor() { super(); if (typeof this.attachInternals == "function") { //some browser don't support attachInternals this.#internals = this.attachInternals(); this.#internals.role = "status"; this.#internals.ariaLive = "polite"; this.#internals.ariaAtomic = "true"; this.#internals.ariaHidden = "true"; this.#internals.states?.add("closed"); } this.initWebComponent(); this.type = 'INFO'; } initWebComponent() { const shadowRoot = this.attachShadow({ mode: 'open', 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-notification-web-component")! as HTMLDivElement, titleWrapper: shadowRoot.querySelector(".title-wrapper")! as HTMLDivElement, descWrapper: shadowRoot.querySelector(".desc-wrapper")! as HTMLDivElement, icon: { infoSymbol: shadowRoot.querySelector(".info-symbol")! as SVGGElement, errorSymbol: shadowRoot.querySelector(".error-symbol")! as SVGGElement, successSymbol: shadowRoot.querySelector(".success-symbol")! as SVGGElement, warningSymbol: shadowRoot.querySelector(".warning-symbol")! as SVGGElement, } }; this.initSwipe(); } static get observedAttributes() { return ['title', 'description', 'type']; } 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 'title': this.title = value; break; case 'description': this.description = value; break; case 'type': this.type = value as NotificationType; } } updateUIBaseOnType(type: NotificationType) { switch (type) { case "INFO": this.elements.icon.infoSymbol.classList.remove("--hidden"); this.elements.icon.errorSymbol.classList.add("--hidden"); this.elements.icon.warningSymbol.classList.add("--hidden"); this.elements.icon.successSymbol.classList.add("--hidden"); this.elements.componentWrapper.classList.add("--info"); this.elements.componentWrapper.classList.remove("--warning"); this.elements.componentWrapper.classList.remove("--success"); this.elements.componentWrapper.classList.remove("--error"); break; case 'ERROR': this.elements.icon.infoSymbol.classList.add("--hidden"); this.elements.icon.errorSymbol.classList.remove("--hidden"); this.elements.icon.warningSymbol.classList.add("--hidden"); this.elements.icon.successSymbol.classList.add("--hidden"); this.elements.componentWrapper.classList.remove("--info"); this.elements.componentWrapper.classList.remove("--warning"); this.elements.componentWrapper.classList.remove("--success"); this.elements.componentWrapper.classList.add("--error"); break; case "SUCCESS": this.elements.icon.infoSymbol.classList.add("--hidden"); this.elements.icon.errorSymbol.classList.add("--hidden"); this.elements.icon.warningSymbol.classList.add("--hidden"); this.elements.icon.successSymbol.classList.remove("--hidden"); this.elements.componentWrapper.classList.remove("--info"); this.elements.componentWrapper.classList.remove("--warning"); this.elements.componentWrapper.classList.add("--success"); this.elements.componentWrapper.classList.remove("--error"); break; case "WARNING": this.elements.icon.infoSymbol.classList.add("--hidden"); this.elements.icon.errorSymbol.classList.add("--hidden"); this.elements.icon.warningSymbol.classList.remove("--hidden"); this.elements.icon.successSymbol.classList.add("--hidden"); this.elements.componentWrapper.classList.remove("--info"); this.elements.componentWrapper.classList.add("--warning"); this.elements.componentWrapper.classList.remove("--success"); this.elements.componentWrapper.classList.remove("--error"); break; } } #syncTypeState(type: NotificationType) { this.#internals?.states?.delete("info"); this.#internals?.states?.delete("success"); this.#internals?.states?.delete("warning"); this.#internals?.states?.delete("error"); this.#internals?.states?.add(type.toLowerCase()); const isUrgent = type === "ERROR" || type === "WARNING"; if (this.#internals) { this.#internals.role = isUrgent ? "alert" : "status"; this.#internals.ariaLive = isUrgent ? "assertive" : "polite"; } } #timer: number | null = null; show() { this.#state = "OPEN"; this.#internals?.states?.add("open"); this.#internals?.states?.delete("closed"); if (this.#internals) this.#internals.ariaHidden = "false"; this.elements.componentWrapper.classList.add("--show"); this.#animateIcon(); this.#timer = setTimeout(() => { this.hide(); }, this.#duration); } #animateIcon() { if (this.#type == "SUCCESS") { this.#playSuccessAnimation(); } if (this.#type == "INFO") { this.#playInfoAnimation(); } if (this.#type == "WARNING") { this.#playWarningAnimation(); } if (this.#type == "ERROR") { this.#playErrorAnimation(); } } #playSuccessAnimation() { const keyframes: Keyframe[] = [ { strokeDashoffset: "1rem" }, { strokeDashoffset: "0px" }, ]; this.elements.icon.successSymbol.querySelector('.tik')!.animate(keyframes, { duration: 600, delay: 0, iterations: 1, easing: "cubic-bezier(0.925, 0.055, 0.595, 0.895)", composite: 'replace' }); } #playInfoAnimation() { //play dot animation const dotKeyframes: Keyframe[] = [ { d: `path("M12 12 L12 12")` }, { d: `path("M12 6 L12 6")` }, ]; const iDot = this.elements.icon.infoSymbol.querySelector('.i-dot')!; const dotAnimationConfig: KeyframeAnimationOptions = { duration: 500, delay: 0, iterations: 1, easing: "cubic-bezier(0.175, 0.885, 0.320, 1.275)", composite: 'replace' }; const dotAnime = iDot.animate(dotKeyframes, dotAnimationConfig); dotAnime.pause(); const lineKeyframes: Keyframe[] = [ { strokeDashoffset: "5px" }, { strokeDashoffset: "0px" }, ]; this.elements.icon.infoSymbol.querySelector('.i-line')!.animate(lineKeyframes, { duration: 500, delay: 0, iterations: 1, easing: "cubic-bezier(0.925, 0.055, 0.595, 0.895)", composite: 'replace' }).addEventListener("finish", () => { dotAnime.play(); }); } #playWarningAnimation() { //play dot animation const dotKeyframes: Keyframe[] = [ { d: `path("M12 13 L12 13")` }, { d: `path("M12 17 L12 17")` }, ]; const iDot = this.elements.icon.warningSymbol.querySelector('.ri-dot')!; const dotAnimationConfig: KeyframeAnimationOptions = { duration: 500, delay: 0, iterations: 1, easing: "cubic-bezier(0.175, 0.885, 0.320, 1.275)", composite: 'replace' }; const dotAnime = iDot.animate(dotKeyframes, dotAnimationConfig); dotAnime.pause(); const lineKeyframes: Keyframe[] = [ { strokeDashoffset: "0.4375rem" }, { strokeDashoffset: "0px" }, ]; this.elements.icon.warningSymbol.querySelector('.ri-line')!.animate(lineKeyframes, { duration: 500, delay: 0, iterations: 1, easing: "cubic-bezier(0.925, 0.055, 0.595, 0.895)", composite: 'replace' }).addEventListener("finish", () => { dotAnime.play(); }); } #playErrorAnimation() { const keyframes: Keyframe[] = [ { strokeDashoffset: "0.75rem" }, { strokeDashoffset: "0px" }, ] const animeConfig: KeyframeAnimationOptions = { duration: 600, delay: 0, iterations: 1, easing: "cubic-bezier(0.860, 0.000, 0.070, 1.000)", composite: 'replace' }; const anim2 = this.elements.icon.errorSymbol.querySelector('.line2')!.animate(keyframes, animeConfig); anim2.pause(); this.elements.icon.errorSymbol.querySelector('.line1')!.animate(keyframes, animeConfig).addEventListener("finish", () => { anim2.play(); }); } hide() { this.#playHideAnimation().then(() => { this.elements.componentWrapper.classList.remove("--show"); this.onClose(); }); } #playHideAnimation() { //TODO: change it to reduce width animation return new Promise((resolve) => { const keyframes: Keyframe[] = [ { transform: "scaleY(1.0)", opacity: "1" }, { transform: "scaleY(0.0)", opacity: "0" }, ] const animeConfig: KeyframeAnimationOptions = { duration: 800, delay: 0, iterations: 1, easing: "cubic-bezier(0.390, 0.575, 0.565, 1.000)", composite: 'replace' }; this.elements.componentWrapper.animate(keyframes, animeConfig).onfinish = (() => { resolve(null); }); }); } onClose() { this.#state = "CLOSE"; this.#internals?.states?.delete("open"); this.#internals?.states?.add("closed"); if (this.#internals) this.#internals.ariaHidden = "true"; if (this.#timer) { clearInterval(this.#timer); } this.#triggerOnCloseEvent(); } #triggerOnCloseEvent() { const event = new CustomEvent("close", {}); this.dispatchEvent(event); } updateDom() { //update dom } onTimerFinished() { const event = new CustomEvent("finish", {}); this.dispatchEvent(event); } // add swipe to clean notification initSwipe() { this.elements.componentWrapper.addEventListener("touchstart", this.#onTouchStart.bind(this)); this.elements.componentWrapper.addEventListener("touchmove", this.#onTouchMove.bind(this)); this.elements.componentWrapper.addEventListener("touchend", this.#onTouchEnd.bind(this)); } #swipeGestureData: SwipeGestureData = { startX: null, startTime: null, distance: null, endTime: null, } #onTouchStart(e: TouchEvent) { if (e.touches[0]?.clientX) { this.#swipeGestureData.startX = e.touches[0]?.clientX; this.#swipeGestureData.startTime = e.timeStamp; } } #onTouchMove(e: TouchEvent) { if (this.#swipeGestureData.startX) { const currentX = e.touches[0].clientX; this.elements.componentWrapper.style.transform = `translateX(${currentX - this.#swipeGestureData.startX}px)`; } } #onTouchEnd(e: TouchEvent) { let animationPromise: Promise | null = null; if (this.#swipeGestureData.startX) { //measure move distance const currentX = e.changedTouches[0].clientX; const distance = currentX - this.#swipeGestureData.startX; this.#swipeGestureData.distance = distance; this.#swipeGestureData.endTime = e.timeStamp; if (Math.abs(distance) > 100) { //close the notification animationPromise = this.#swipeHide(); } else { animationPromise = this.#moveBackToPos(); } } if (animationPromise) { animationPromise.then(() => { this.#resetSwipeGestureData(); }); } else { this.#resetSwipeGestureData(); } } #resetSwipeGestureData() { this.#swipeGestureData.startX = null; this.#swipeGestureData.distance = null; this.#swipeGestureData.startTime = null; this.#swipeGestureData.endTime = null; } #moveBackToPos() { //TODO: detect move direction so when user change direction reset start time return new Promise((resolve) => { const dom = this.elements.componentWrapper; dom.style.transition = `transform 0.3s 0s ease`; //remove above assigned animation setTimeout(() => { dom.style.transition = ''; resolve(null); }, 300); dom.style.transform = ``; }); } #swipeHide() { return new Promise((resolve) => { //this is like this.hide but with different animation this.#playSwipeHideAnimation().then(() => { this.elements.componentWrapper.classList.remove("--show"); this.onClose(); resolve(null); }); }); } #playSwipeHideAnimation() { const timeDistance = this.#swipeGestureData.endTime! - this.#swipeGestureData.startTime!; return new Promise((resolve) => { const keyframes: Keyframe[] = [ { transform: `translateX(${this.#swipeGestureData.distance!}px)`, opacity: "1" }, { transform: `translateX(${this.#swipeGestureData.distance! * 2}px)`, opacity: "0" }, ]; const animeConfig: KeyframeAnimationOptions = { duration: timeDistance, delay: 0, iterations: 1, composite: 'replace' }; this.elements.componentWrapper.animate(keyframes, animeConfig).onfinish = (() => { resolve(null); }); }); } } const myElementNotExists = !customElements.get('jb-notification'); if (myElementNotExists) { window.customElements.define('jb-notification', JBNotificationWebComponent); }