/* eslint-disable */ /* * Vanilla TypeScript port of bootstrap-notify v3.1.3 * * Original project: https://github.com/mouse0270/bootstrap-growl * Original author: Robert McIntosh (mouse0270) * Original license: MIT * * This is a behaviour-preserving rewrite — same defaults, same placement / * stacking math, same lifecycle events — but with the jQuery dependency * removed. The API surface kept is what `components/ui/notification.ts` * actually exercises: `notify(content, options)` returns a handle with * `update(commandOrObject, value?)` and `close()`. */ import { globalState } from '../../../app/global-state'; export interface NotifyContent { message?: string; title?: string; icon?: string; url?: string; target?: string; } export interface NotifyPlacement { from?: 'top' | 'bottom'; align?: 'left' | 'center' | 'right'; } export interface NotifyOffset { x: number; y: number; } export interface NotifyAnimate { enter?: string; exit?: string; } export interface NotifyOptions { element?: string | HTMLElement; position?: string | null; type?: string; allow_dismiss?: boolean; newest_on_top?: boolean; showProgressbar?: boolean; placement?: NotifyPlacement; offset?: number | NotifyOffset; spacing?: number; z_index?: number; delay?: number; timer?: number; url_target?: string; mouse_over?: 'pause' | null; animate?: NotifyAnimate; onShow?: (this: HTMLElement) => void; onShown?: (this: HTMLElement) => void; onClose?: (this: HTMLElement) => void; onClosed?: (this: HTMLElement) => void; icon_type?: 'class' | 'image'; template?: string; } export interface NotifyHandle { $ele: HTMLElement; update: (commandOrObject: string | Record, value?: any) => void; close: () => void; } interface ResolvedSettings extends Required> { element: string | HTMLElement; position: string | null; offset: NotifyOffset; placement: Required; animate: Required; mouse_over: 'pause' | null; onShow: ((this: HTMLElement) => void) | null; onShown: ((this: HTMLElement) => void) | null; onClose: ((this: HTMLElement) => void) | null; onClosed: ((this: HTMLElement) => void) | null; template: string; content: Required; } let defaults: NotifyOptions = { element: 'body', position: null, type: 'info', allow_dismiss: true, newest_on_top: false, showProgressbar: false, placement: { from: 'top', align: 'right' }, offset: 20, spacing: 10, z_index: 1031, delay: 5000, timer: 1000, url_target: '_blank', mouse_over: null, animate: { enter: 'animated fadeInDown', exit: 'animated fadeOutUp', }, onShow: null, onShown: null, onClose: null, onClosed: null, icon_type: 'class', template: '', }; // Replace bootstrap-notify's String.format({0} placeholders) without polluting // the String prototype. function formatTemplate(tpl: string, ...args: any[]): string { return args.reduce(( acc, val, i, ) => acc.replace(new RegExp(`\\{${i}\\}`, 'gm'), String(val ?? '')), tpl); } function htmlToElement(html: string): HTMLElement { const template = document.createElement('template'); template.innerHTML = html.trim(); const node = template.content.firstElementChild; if (!(node instanceof HTMLElement)) { throw new TypeError('notify: template did not produce an element'); } return node; } function resolveElement(el: string | HTMLElement): HTMLElement { if (el instanceof HTMLElement) { return el; } const found = document.querySelector(el); return found ?? document.body; } const ANIMATION_END_EVENTS = [ 'webkitAnimationEnd', 'oanimationend', 'MSAnimationEnd', 'animationend', ]; const ANIMATION_START_EVENTS = [ 'webkitAnimationStart', 'oanimationstart', 'MSAnimationStart', 'animationstart', ]; function addOnceListeners( el: HTMLElement, events: string[], handler: (e: Event) => void, ): void { const wrapped = (e: Event) => { events.forEach(ev => el.removeEventListener(ev, wrapped)); handler(e); }; events.forEach(ev => el.addEventListener(ev, wrapped)); } class Notify { private settings: ResolvedSettings; private $ele!: HTMLElement; private hoverData: 'true' | 'false' = 'false'; private delayRemaining = 0; private isClosing = false; public handle: NotifyHandle; constructor(content: string | NotifyContent, options: NotifyOptions = {}) { const normalisedContent: Required = { message: typeof content === 'object' ? (content.message ?? '') : content, title: typeof content === 'object' && content.title ? content.title : '', icon: typeof content === 'object' && content.icon ? content.icon : '', url: typeof content === 'object' && content.url ? content.url : '#', target: typeof content === 'object' && content.target ? content.target : '-', }; const merged: NotifyOptions = deepMerge( {}, defaults, { content: normalisedContent } as any, options, ); this.settings = merged as unknown as ResolvedSettings; this.settings.content = normalisedContent; if (this.settings.content.target === '-') { this.settings.content.target = this.settings.url_target ?? '_blank'; } if (typeof this.settings.offset === 'number') { const n = this.settings.offset as unknown as number; this.settings.offset = { x: n, y: n }; } this.init(); this.handle = { $ele: this.$ele, update: (cmdOrObj, value) => this.update(cmdOrObj, value), close: () => this.close(), }; } private init() { this.buildNotify(); if (this.settings.content.icon) { this.setIcon(); } if (this.settings.content.url !== '#') { this.styleURL(); } this.styleDismiss(); this.placement(); this.bind(); } private buildNotify() { const c = this.settings.content; this.$ele = htmlToElement(formatTemplate( this.settings.template, this.settings.type, c.title, c.message, c.url, c.target, )); this.$ele.setAttribute('data-notify-position', `${this.settings.placement.from}-${this.settings.placement.align}`); if (!this.settings.allow_dismiss) { const dismiss = this.$ele.querySelector('[data-notify="dismiss"]'); if (dismiss != null) { dismiss.style.display = 'none'; } } if ((this.settings.delay <= 0 && !this.settings.showProgressbar) || !this.settings.showProgressbar) { this.$ele.querySelector('[data-notify="progressbar"]')?.remove(); } } private setIcon() { const iconEl = this.$ele.querySelector('[data-notify="icon"]'); if (iconEl == null) { return; } if ((this.settings.icon_type ?? 'class').toLowerCase() === 'class') { // jQuery's .addClass accepted space-separated class lists; classList.add // rejects classes that contain whitespace, so split first. const classes = (this.settings.content.icon ?? '').split(/\s+/).filter(Boolean); classes.forEach(c => iconEl.classList.add(c)); } else { if (iconEl.tagName === 'IMG') { iconEl.setAttribute('src', this.settings.content.icon); } else { iconEl.innerHTML = `Notify Icon`; } } } private styleDismiss() { const dismiss = this.$ele.querySelector('[data-notify="dismiss"]'); if (dismiss == null) { return; } dismiss.style.position = 'absolute'; dismiss.style.right = '10px'; dismiss.style.top = '5px'; dismiss.style.zIndex = String((this.settings.z_index ?? 1031) + 2); } private styleURL() { const url = this.$ele.querySelector('[data-notify="url"]'); if (url == null) { return; } url.style.backgroundImage = 'url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)'; url.style.height = '100%'; url.style.left = '0px'; url.style.position = 'absolute'; url.style.top = '0px'; url.style.width = '100%'; url.style.zIndex = String((this.settings.z_index ?? 1031) + 1); } private placement() { const s = this.settings; let offsetAmt = s.offset.y; const positionAttr = `[data-notify-position="${s.placement.from}-${s.placement.align}"]:not([data-closing="true"])`; const siblings = Array.from(document.querySelectorAll(positionAttr)); siblings.forEach((el) => { const fromVal = parseInt(getComputedStyle(el).getPropertyValue(s.placement.from), 10) || 0; offsetAmt = Math.max(offsetAmt, fromVal + el.offsetHeight + (s.spacing ?? 10)); }); if (s.newest_on_top === true) { offsetAmt = s.offset.y; } this.$ele.style.display = 'inline-block'; this.$ele.style.margin = '0px auto'; this.$ele.style.position = s.position ?? (s.element === 'body' ? 'fixed' : 'absolute'); this.$ele.style.transition = 'all .5s ease-in-out'; this.$ele.style.zIndex = String(s.z_index ?? 1031); this.$ele.style.setProperty(s.placement.from, `${offsetAmt}px`); if (s.placement.align === 'left' || s.placement.align === 'right') { this.$ele.style.setProperty(s.placement.align, `${s.offset.x}px`); } else { this.$ele.style.left = '0'; this.$ele.style.right = '0'; } const enterClasses = (s.animate.enter ?? '').split(/\s+/).filter(Boolean); enterClasses.forEach(c => this.$ele.classList.add(c)); // Force animations to run exactly once across vendor prefixes const prefixes = [ 'webkit-', 'moz-', 'o-', 'ms-', '', ]; prefixes.forEach((prefix) => { (this.$ele.style as any)[`${prefix}AnimationIterationCount`] = 1; }); const host = resolveElement(s.element); host.appendChild(this.$ele); if (s.newest_on_top === true) { offsetAmt = offsetAmt + (s.spacing ?? 10) + this.$ele.offsetHeight; this.reposition(offsetAmt); } if (typeof s.onShow === 'function') { s.onShow.call(this.$ele); } let hasAnimation = false; addOnceListeners( this.$ele, ANIMATION_START_EVENTS, () => { hasAnimation = true; }, ); addOnceListeners( this.$ele, ANIMATION_END_EVENTS, (e) => { if (typeof s.onShown === 'function') { s.onShown.call(e.currentTarget as HTMLElement); } }, ); setTimeout(() => { if (!hasAnimation && typeof s.onShown === 'function') { s.onShown.call(this.$ele); } }, 600); } private bind() { const s = this.settings; this.$ele.querySelector('[data-notify="dismiss"]') ?.addEventListener('click', () => this.close()); this.$ele.addEventListener('mouseover', () => { this.hoverData = 'true'; }); this.$ele.addEventListener('mouseout', () => { this.hoverData = 'false'; }); if ((s.delay ?? 0) > 0) { this.delayRemaining = s.delay ?? 0; const total = s.delay ?? 0; const tick = s.timer ?? 1000; const intervalId = setInterval(() => { if (this.isClosing) { clearInterval(intervalId); return; } const newRemaining = this.delayRemaining - tick; if ((this.hoverData === 'false' && s.mouse_over === 'pause') || s.mouse_over !== 'pause') { const percent = ((total - newRemaining) / total) * 100; this.delayRemaining = newRemaining; const bar = this.$ele.querySelector('[data-notify="progressbar"] > div'); if (bar != null) { bar.setAttribute('aria-valuenow', String(percent)); bar.style.width = `${percent}%`; } } if (newRemaining <= -tick) { clearInterval(intervalId); this.close(); } }, tick); } } private update(cmdOrObj: string | Record, value?: any) { const s = this.settings; const commands: Record = typeof cmdOrObj === 'string' ? { [cmdOrObj]: value } : cmdOrObj; for (const cmd of Object.keys(commands)) { const val = commands[cmd]; switch (cmd) { case 'type': { this.$ele.classList.remove(`alert-${s.type}`); this.$ele.querySelector('[data-notify="progressbar"] > .progress-bar')?.classList.remove(`progress-bar-${s.type}`); s.type = val; this.$ele.classList.add(`alert-${val}`); this.$ele.querySelector('[data-notify="progressbar"] > .progress-bar')?.classList.add(`progress-bar-${val}`); break; } case 'icon': { const iconEl = this.$ele.querySelector('[data-notify="icon"]'); if (iconEl == null) { break; } if ((s.icon_type ?? 'class').toLowerCase() === 'class') { (s.content.icon ?? '').split(/\s+/).filter(Boolean).forEach(c => iconEl.classList.remove(c)); String(val).split(/\s+/).filter(Boolean).forEach(c => iconEl.classList.add(c)); } else { if (iconEl.tagName !== 'IMG') { iconEl.querySelector('img'); // mirrors the original (no-op) — kept for behavioral parity } iconEl.setAttribute('src', String(val)); } break; } case 'progress': { const newDelay = (s.delay ?? 0) - ((s.delay ?? 0) * (Number(val) / 100)); this.delayRemaining = newDelay; const bar = this.$ele.querySelector('[data-notify="progressbar"] > div'); if (bar != null) { bar.setAttribute('aria-valuenow', String(val)); bar.style.width = `${val}%`; } break; } case 'url': this.$ele.querySelector('[data-notify="url"]')?.setAttribute('href', String(val)); break; case 'target': this.$ele.querySelector('[data-notify="url"]')?.setAttribute('target', String(val)); break; default: { const target = this.$ele.querySelector(`[data-notify="${cmd}"]`); if (target != null) { target.innerHTML = String(val); } } } } const posX = this.$ele.offsetHeight + (s.spacing ?? 10) + s.offset.y; this.reposition(posX); } private close() { if (this.isClosing) { return; } this.isClosing = true; const s = this.settings; const posX = parseInt(getComputedStyle(this.$ele).getPropertyValue(s.placement.from), 10) || 0; this.$ele.setAttribute('data-closing', 'true'); (s.animate.exit ?? '').split(/\s+/).filter(Boolean).forEach(c => this.$ele.classList.add(c)); this.reposition(posX); if (typeof s.onClose === 'function') { s.onClose.call(this.$ele); } let hasAnimation = false; addOnceListeners( this.$ele, ANIMATION_START_EVENTS, () => { hasAnimation = true; }, ); addOnceListeners( this.$ele, ANIMATION_END_EVENTS, (e) => { const target = e.currentTarget as HTMLElement; target.remove(); if (typeof s.onClosed === 'function') { s.onClosed.call(target); } }, ); setTimeout(() => { if (!hasAnimation) { this.$ele.remove(); if (typeof s.onClosed === 'function') { s.onClosed.call(this.$ele); } } }, 600); } private reposition(startPosX: number) { const s = this.settings; const positionAttr = `[data-notify-position="${s.placement.from}-${s.placement.align}"]:not([data-closing="true"])`; const all = Array.from(document.querySelectorAll(positionAttr)); const idx = all.indexOf(this.$ele); // Original used nextAll() (later siblings in DOM order); when newest_on_top is // true it used prevAll(). We don't actually have a sibling tree to walk — // notifies live in `body` — so use index-based ordering instead. const targets = s.newest_on_top ? all.slice(0, Math.max(idx, 0)).reverse() : all.slice(idx + 1); let posX = startPosX; targets.forEach((el) => { el.style.setProperty(s.placement.from, `${posX}px`); posX = posX + (s.spacing ?? 10) + el.offsetHeight; }); } } function deepMerge(target: any, ...sources: any[]): any { for (const src of sources) { if (src == null) { continue; } for (const k of Object.keys(src)) { const v = src[k]; if (v != null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Element)) { target[k] = target[k] && typeof target[k] === 'object' ? target[k] : {}; deepMerge(target[k], v); } else { target[k] = v; } } } return target; } export function notify(content: string | NotifyContent, options: NotifyOptions = {}): NotifyHandle { if (!globalState.windowExists) { // SSR no-op so callers don't crash. return { $ele: null as any, update: () => { }, close: () => { }, }; } const plugin = new Notify(content, options); return plugin.handle; } export function notifyDefaults(options: NotifyOptions): NotifyOptions { defaults = deepMerge( {}, defaults, options, ); return defaults; } export function notifyClose(command?: string): void { if (!globalState.windowExists) { return; } const selector = command == null || command === 'all' ? '[data-notify]' : `[data-notify-position="${command}"]`; document.querySelectorAll(selector).forEach((root) => { const dismiss = root.querySelector('[data-notify="dismiss"]'); dismiss?.click(); }); }