UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

1,049 lines (993 loc) 124 kB
import { getId, newInstance, s4 } from './CommonJs.js'; import { Draggable } from '@neodrag/vanilla'; import { append, s, prepend, htmls, sa, getAllChildNodes, isActiveElement } from './VanillaJs.js'; import { BtnIcon } from './BtnIcon.js'; import { Responsive } from './Responsive.js'; import { loggerFactory } from './Logger.js'; import { Css, ThemeEvents, Themes, ThemesScope, darkTheme, renderStyleTag, renderStatus, renderCssAttr, } from './Css.js'; import { setDocTitle, closeModalRouteChangeEvent, handleModalViewRoute, getProxyPath, setPath, coreUI, sanitizeRoute, getQueryParams, setRouterReady, } from './Router.js'; import { NotificationManager } from './NotificationManager.js'; import { EventsUI } from './EventsUI.js'; import { Translate } from './Translate.js'; import { Input, isTextInputFocused } from './Input.js'; import { DropDown } from './DropDown.js'; import { Keyboard } from './Keyboard.js'; import { Badge } from './Badge.js'; import { Worker } from './Worker.js'; import { Scroll } from './Scroll.js'; import { windowGetH, windowGetW } from './windowGetDimensions.js'; import { SearchBox } from './SearchBox.js'; import { createModalEvents } from './ClientEvents.js'; const logger = loggerFactory(import.meta, { trace: true }); /** * @typedef {object} ModalBarButton * @property {string} [label] - Button label HTML * @property {boolean} [disabled] - Whether the button is hidden/disabled * @property {Function} [onClick] - Optional click handler override */ /** * @typedef {object} ModalBarConfig * @property {{ minimize: ModalBarButton, restore: ModalBarButton, maximize: ModalBarButton, close: ModalBarButton, menu: ModalBarButton }} buttons */ /** * @typedef {object} ModalRenderOptions * @property {string} [id=''] - Modal element id/class name. Auto-generated if omitted. * @property {ModalBarConfig} [barConfig={}] - Bar button configuration. * @property {string} [title=''] - Modal title HTML. * @property {string|Function} [html=''] - Modal body HTML or async factory. * @property {string} [handleType='bar'] - Drag handle type ('bar' or default full). * @property {string} [mode=''] - Layout mode: 'view', 'slide-menu', 'slide-menu-right', 'slide-menu-left', 'dropNotification'. * @property {object} [RouterInstance={}] - Router instance for route-aware modals. * @property {string[]} [disableTools=[]] - Tool ids to hide ('app-icon', 'text-box', 'profile', 'center', 'lang', 'theme', 'navigator'). * @property {boolean} [observer=false] - Attach a ResizeObserver to the modal element. * @property {boolean} [disableBoxShadow=false] - Remove box shadow from the modal. * @property {boolean} [dragDisabled=false] - Disable dragging. * @property {boolean} [maximize=false] - Immediately maximize the modal after render. * @property {boolean} [disableCenter=false] - Skip auto-centering. * @property {object} [style={}] - Inline style overrides applied via renderStyleTag. * @property {string} [class=''] - Extra CSS class(es) on the modal wrapper. * @property {string} [titleClass=''] - Class for the title element. * @property {string} [btnBarModalClass=''] - Class override for the button bar container. * @property {string} [btnContainerClass=''] - Class for each bar button. * @property {string} [btnIconContainerClass=''] - Class for each bar button inner icon div. * @property {string} [barClass=''] - Class for the top/bottom bar flex row. * @property {string} [barMode=''] - Bar layout variant ('top-bottom-bar'). * @property {string} [renderType=''] - Insertion strategy ('prepend' or default append). * @property {string} [selector='body'] - Parent selector for insertion. * @property {string} [slideMenu=''] - Id of the slide-menu modal this view should attach to. * @property {string} [route=''] - URL path segment for view-mode route tracking. * @property {string} [status=''] - Status icon descriptor rendered in the bar. * @property {boolean} [zIndexSync=false] - Enable z-index management for stacked view modals. * @property {boolean} [query=false] - Snapshot the current query string into modal data. * @property {string[]} [homeModals=[]] - Modal ids that belong to the home screen (not closed on home nav). * @property {Function} [titleRender] - Function returning title HTML (takes priority over title string). * @property {Function} [htmlMainBody] - Factory for main-body modal html (slide-menu mode). * @property {Function} [slideMenuTopBarBannerFix] - Async factory for top-bar banner content. * @property {number} [minSearchQueryLength=1] - Minimum search query length. * @property {Function} [onCollapseMenu] - Callback when slide menu collapses. * @property {Function} [onExtendMenu] - Callback when slide menu extends. */ /** * @typedef {object} ModalDataEntry * @property {ModalRenderOptions} options - Original render options. * @property {import('./EventBus.js').EventBus} events - Per-modal event bus backing the listener channels below. * * The `onX` channels are EventBus-backed proxies that preserve the historical * `{ [key]: listener }` map API: assign to register, read+call to invoke a single * listener, `delete` to remove, and `Object.keys` to enumerate registered keys. * @property {Object.<string, Function>} onCloseListener - Close event listeners keyed by id. * @property {Object.<string, Function>} onMenuListener - Menu button event listeners. * @property {Object.<string, Function>} onCollapseMenuListener - Collapse menu listeners. * @property {Object.<string, Function>} onExtendMenuListener - Extend menu listeners. * @property {Object.<string, Function>} onDragEndListener - Drag-end listeners. * @property {Object.<string, Function>} onObserverListener - ResizeObserver listeners. * @property {Object.<string, Function>} onClickListener - Click listeners. * @property {Object.<string, Function>} onExpandUiListener - UI expand/collapse listeners. * @property {Object.<string, Function>} onBarUiOpen - Bar UI open listeners. * @property {Object.<string, Function>} onBarUiClose - Bar UI close listeners. * @property {Object.<string, Function>} onReloadModalListener - Reload listeners. * @property {Object.<string, Function>} onHome - Home navigation listeners. * @property {string[]} homeModals - Home modal ids. * @property {string} [query] - Snapshotted query string. * @property {Function} getTop - Returns computed top offset. * @property {Function} getHeight - Returns computed modal height. * @property {Function} getMenuLeftStyle - Returns slide menu left CSS value. * @property {Function} center - Centers the modal in the viewport. * @property {object} [slideMenu] - Active slide-menu link data. * @property {ResizeObserver} [observer] - Attached ResizeObserver. * @property {Function} [observerCallBack] - ResizeObserver callback. * @property {Function} [setDragInstance] - Updates drag options and re-creates the Draggable. * @property {object} [dragInstance] - Active Draggable instance. * @property {object} [dragOptions] - Current drag configuration. */ class Modal { /** @type {Object.<string, ModalDataEntry>} */ static Data = {}; /** * Bottom offset of the slide-menu area for the given options. * @param {ModalRenderOptions} options * @param {number} [heightDefaultBottomBar=0] * @returns {number} */ static getModalTop(options, heightDefaultBottomBar = 0) { return windowGetH() - (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar); } /** * Available modal height, accounting for the top/bottom bars when the UI is expanded. * @param {ModalRenderOptions} options * @param {number} [heightDefaultTopBar=50] * @param {number} [heightDefaultBottomBar=0] * @returns {number} */ static getModalHeight(options, heightDefaultTopBar = 50, heightDefaultBottomBar = 0) { const barsVisible = s(`.main-body-btn-ui-close`) && !s(`.main-body-btn-ui-close`).classList.contains('hide'); return ( windowGetH() - (barsVisible ? (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) + (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar) : 0) ); } /** * Left CSS value for a slide menu in its open/closed state. * @param {ModalRenderOptions} options * @param {{ originSlideMenuWidth: number, collapseSlideMenuWidth: number }} widths * @param {{ open: boolean }} [ops={ open: false }] * @returns {string} */ static getModalMenuLeftStyle(options, { originSlideMenuWidth, collapseSlideMenuWidth }, ops = { open: false }) { return `${ options.mode === 'slide-menu-right' ? `${ windowGetW() + (ops?.open ? -1 * originSlideMenuWidth + (options.mode === 'slide-menu-right' && s(`.btn-icon-menu-mode-right`).classList.contains('hide') ? originSlideMenuWidth - collapseSlideMenuWidth : 0) : originSlideMenuWidth) }px` : `-${ops?.open ? '0px' : originSlideMenuWidth}px` }`; } /** * Viewport-centered top/left CSS values for a modal of the given size. * @param {{ width: number, height: number }} param0 * @returns {{ top: string, left: string }} */ static getModalCenter({ width, height }) { return { top: `${windowGetH() / 2 - height / 2}px`, left: `${windowGetW() / 2 - width / 2}px`, }; } /** * Create or reload a modal. When the modal already exists in the DOM the * existing instance is reloaded via its onReloadModalListener callbacks. * @param {ModalRenderOptions} options * @returns {Promise<ModalDataEntry & { id: string }>} */ static async instance( options = { id: '', barConfig: {}, title: '', html: '', handleType: 'bar', mode: '', RouterInstance: {}, disableTools: [], observer: false, disableBoxShadow: false, dragDisabled: false, maximize: false, disableCenter: false, style: {}, class: '', titleClass: '', barMode: '', route: '', slideMenu: '', zIndexSync: false, query: false, homeModals: [], }, ) { const originHeightBottomBar = 50; const originHeightTopBar = 50; options.heightBottomBar = 0; options.heightTopBar = 100; if (options && options.barMode && options.barMode === 'top-bottom-bar') { options.heightTopBar = 50; options.heightBottomBar = 50; } let width = 300; let height = 400; let top = options.style?.top ? options.style.top : 0; let left = options.style?.left ? options.style.left : 0; let transition = `opacity 0.3s, box-shadow 0.3s, bottom 0.3s`; const originSlideMenuWidth = 320; const collapseSlideMenuWidth = 50; let slideMenuWidth = originSlideMenuWidth; const minWidth = width; const heightDefaultTopBar = 50; const heightDefaultBottomBar = 0; const idModal = options.id ? options.id : getId(this.Data, 'modal-'); const { bus: eventBus, channels: eventChannels } = createModalEvents(); this.Data[idModal] = { options, events: eventBus, ...eventChannels, homeModals: options.homeModals ? options.homeModals : [], query: options.query ? `${window.location.search}` : undefined, getTop: () => Modal.getModalTop(options, heightDefaultBottomBar), getHeight: () => Modal.getModalHeight(options, heightDefaultTopBar, heightDefaultBottomBar), getMenuLeftStyle: (ops = { open: false }) => Modal.getModalMenuLeftStyle(options, { originSlideMenuWidth, collapseSlideMenuWidth }, ops), center: () => { const { top: centeredTop, left: centeredLeft } = Modal.getModalCenter({ width, height }); top = centeredTop; left = centeredLeft; }, ...this.Data[idModal], }; if (options && 'mode' in options) { Modal.Data[idModal][options.mode] = {}; switch (options.mode) { case 'view': // if (options && options.slideMenu) s(`.btn-close-${options.slideMenu}`).click(); options.zIndexSync = true; options.style = { width: '100%', ...options.style, 'min-width': `${minWidth}px` }; if (Modal.mobileModal()) { options.barConfig.buttons.restore.disabled = true; options.barConfig.buttons.minimize.disabled = true; options.dragDisabled = true; options.style.resize = 'none'; setTimeout(() => { s(`.btn-close-modal-menu`).click(); }); } Responsive.onChanged( () => { if (!this.Data[idModal]) return Responsive.offChanged(`view-${idModal}`); if (this.Data[idModal].slideMenu) s(`.${idModal}`).style.height = `${this.Data[idModal].getHeight()}px`; }, { key: `view-${idModal}` }, ); Responsive.triggerChanged(`view-${idModal}`); // Handle view mode modal route if (options.route) { handleModalViewRoute({ route: options.route, RouterInstance: options.RouterInstance, }); } break; case 'slide-menu': case 'slide-menu-right': case 'slide-menu-left': (async () => { if (!options.slideMenuTopBarBannerFix) { options.slideMenuTopBarBannerFix = async () => { let style = html``; if (options.barMode === 'top-bottom-bar') { style = html`<style> .default-slide-menu-top-bar-fix-logo-container { width: 50px; height: 50px; } .default-slide-menu-top-bar-fix-logo { width: 40px; height: 40px; padding: 5px; } .default-slide-menu-top-bar-fix-title-container-text { font-size: 26px; top: 8px; color: ${darkTheme ? '#ffffff' : '#000000'}; } </style>`; } else { style = html`<style> .default-slide-menu-top-bar-fix-logo-container { width: 100px; height: 100px; } .default-slide-menu-top-bar-fix-logo { width: 50px; height: 50px; padding: 24px; } .default-slide-menu-top-bar-fix-title-container-text { font-size: 30px; top: 30px; color: ${darkTheme ? '#ffffff' : '#000000'}; } </style>`; } setTimeout(() => { if (s(`.top-bar-app-icon`) && s(`.top-bar-app-icon`).src) { s(`.default-slide-menu-top-bar-fix-logo`).src = s(`.top-bar-app-icon`).src; if (s(`.top-bar-app-icon`).classList.contains('negative-color')) s(`.default-slide-menu-top-bar-fix-logo`).classList.add('negative-color'); htmls( `.default-slide-menu-top-bar-fix-title-container`, html` <div class="inl default-slide-menu-top-bar-fix-title-container-text">${options.title}</div> `, ); } else htmls( `.default-slide-menu-top-bar-fix-logo-container`, html`<div class="abs center">${s(`.action-btn-app-icon-render`).innerHTML}</div>`, ); }); return html`${style} <div class="in fll default-slide-menu-top-bar-fix-logo-container"> <img class="default-slide-menu-top-bar-fix-logo in fll" /> </div> <div class="in fll default-slide-menu-top-bar-fix-title-container"></div>`; }; } const { barConfig } = options; options.style = { height: `${windowGetH() - options.heightTopBar - options.heightBottomBar}px`, width: `${slideMenuWidth}px`, // 'overflow-x': 'hidden', // overflow: 'visible', // required for tooltip 'z-index': 6, resize: 'none', top: `${options.heightTopBar ? options.heightTopBar : heightDefaultTopBar}px`, }; const contentIconClass = 'abs center'; top = 'auto'; left = Modal.Data[idModal].getMenuLeftStyle(); transition = '.3s'; options.dragDisabled = true; options.titleClass = 'hide'; options.disableCenter = true; // barConfig.buttons.maximize.disabled = true; // barConfig.buttons.minimize.disabled = true; // barConfig.buttons.restore.disabled = true; // barConfig.buttons.menu.disabled = true; // barConfig.buttons.close.disabled = true; options.btnBarModalClass = 'hide'; Responsive.onChanged( () => { for (const _idModal of Object.keys(this.Data)) { if (this.Data[_idModal].slideMenu && this.Data[_idModal].slideMenu.id === idModal) this.Data[_idModal].slideMenu.callBack(); } s(`.${idModal}`).style.height = `${Modal.Data[idModal].getHeight()}px`; s(`.${idModal}`).style.left = Modal.Data[idModal].getMenuLeftStyle({ open: s(`.btn-bar-center-icon-menu`).classList.contains('hide') ? true : false, }); if (s(`.main-body-top`)) { if (Modal.mobileModal()) { if ( s(`.btn-menu-${idModal}`).classList.contains('hide') && collapseSlideMenuWidth !== slideMenuWidth ) s(`.main-body-top`).classList.remove('hide'); if (s(`.btn-close-${idModal}`).classList.contains('hide')) s(`.main-body-top`).classList.add('hide'); } else if (!s(`.main-body-top`).classList.contains('hide')) s(`.main-body-top`).classList.add('hide'); } }, { key: `slide-menu-${idModal}` }, ); barConfig.buttons.menu.onClick = () => { Modal.Data[idModal][options.mode].width = slideMenuWidth; s(`.btn-menu-${idModal}`).classList.add('hide'); s(`.btn-close-${idModal}`).classList.remove('hide'); // s(`.${idModal}`).style.width = `${this.Data[idModal][options.mode].width}px`; s(`.html-${idModal}`).style.display = 'block'; // s(`.title-modal-${idModal}`).style.display = 'block'; s(`.main-body-btn-ui-menu-menu`).classList.add('hide'); s(`.main-body-btn-ui-menu-close`).classList.remove('hide'); if (s(`.btn-bar-center-icon-menu`)) { s(`.btn-bar-center-icon-close`).classList.remove('hide'); s(`.btn-bar-center-icon-menu`).classList.add('hide'); } s(`.main-body-btn-container`).style[ true || (options.mode && options.mode.match('right')) ? 'right' : 'left' ] = options.mode && options.mode.match('right') ? `${slideMenuWidth}px` : '0px'; if (options.mode === 'slide-menu-right') { s(`.${idModal}`).style.left = `${windowGetW() - originSlideMenuWidth}px`; } else { s(`.${idModal}`).style.left = `0px`; } Responsive.triggerChanged(`slide-menu-${idModal}`); }; barConfig.buttons.close.onClick = () => { Modal.Data[idModal][options.mode].width = 0; s(`.btn-close-${idModal}`).classList.add('hide'); s(`.btn-menu-${idModal}`).classList.remove('hide'); // s(`.${idModal}`).style.width = `${this.Data[idModal][options.mode].width}px`; // s(`.html-${idModal}`).style.display = 'none'; // s(`.title-modal-${idModal}`).style.display = 'none'; s(`.main-body-btn-ui-menu-close`).classList.add('hide'); s(`.main-body-btn-ui-menu-menu`).classList.remove('hide'); if (s(`.btn-bar-center-icon-menu`)) { s(`.btn-bar-center-icon-menu`).classList.remove('hide'); s(`.btn-bar-center-icon-close`).classList.add('hide'); } s(`.main-body-btn-container`).style[ true || (options.mode && options.mode.match('right')) ? 'right' : 'left' ] = `${0}px`; if (options.mode === 'slide-menu-right') { s(`.${idModal}`).style.left = `${windowGetW() + originSlideMenuWidth}px`; } else { s(`.${idModal}`).style.left = `-${originSlideMenuWidth}px`; } Responsive.triggerChanged(`slide-menu-${idModal}`); }; transition += `, width 0.3s`; setTimeout(() => { setTimeout(btnCloseEvent); append( 'body', html` <div class="abs main-body-btn-container" style="top: ${options.heightTopBar + 50}px; z-index: 9; ${true || (options.mode && options.mode.match('right')) ? 'right' : 'left'}: 0px; width: 50px; height: 150px; transition: .3s" > <div class="abs main-body-btn main-body-btn-ui" style="top: 0px; ${true || (options.mode && options.mode.match('right')) ? 'right' : 'left'}: 0px" > <div class="abs center"> <i class="fas fa-caret-down main-body-btn-ui-open hide"></i> <i class="fas fa-caret-up main-body-btn-ui-close"></i> </div> </div> <div class="abs main-body-btn main-body-btn-menu" style="top: 50px; ${true || (options.mode && options.mode.match('right')) ? 'right' : 'left'}: 0px" > <div class="abs center"> <i class="fa-solid fa-xmark hide main-body-btn-ui-menu-close"></i> <i class="fa-solid fa-bars main-body-btn-ui-menu-menu"></i> </div> </div> <div class="abs main-body-btn main-body-btn-bar-custom ${options?.slideMenuTopBarBannerFix ? '' : 'hide'}" style="top: 100px; ${true || (options.mode && options.mode.match('right')) ? 'right' : 'left'}: 0px" > <div class="abs center"> <i class="fa-solid fa-magnifying-glass main-body-btn-ui-bar-custom-open"></i> <i class="fa-solid fa-home hide main-body-btn-ui-bar-custom-close"></i> </div> </div> </div> `, ); s(`.main-body-btn-menu`).onclick = () => { Modal.actionBtnCenter(); }; s(`.main-body-btn-bar-custom`).onclick = () => { if (s(`.main-body-btn-ui-close`).classList.contains('hide')) { s(`.main-body-btn-ui`).click(); } if (s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide')) { s(`.main-body-btn-ui-bar-custom-open`).classList.remove('hide'); s(`.main-body-btn-ui-bar-custom-close`).classList.add('hide'); s(`.slide-menu-top-bar-fix`).style.top = '0px'; } else { s(`.main-body-btn-ui-bar-custom-open`).classList.add('hide'); s(`.main-body-btn-ui-bar-custom-close`).classList.remove('hide'); s(`.slide-menu-top-bar-fix`).style.top = '-100px'; s(`.top-bar-search-box-container`).click(); } if (Modal.mobileModal()) { btnCloseEvent(); } }; let _heightTopBar, _heightBottomBar, _topMenu; s(`.main-body-btn-ui`).onclick = () => { if (s(`.main-body-btn-ui-open`).classList.contains('hide')) { s(`.main-body-btn-ui-open`).classList.remove('hide'); s(`.main-body-btn-ui-close`).classList.add('hide'); _heightTopBar = newInstance(options.heightTopBar); _heightBottomBar = newInstance(options.heightBottomBar); _topMenu = newInstance(s(`.modal-menu`).style.top); options.heightTopBar = 0; options.heightBottomBar = 0; s(`.slide-menu-top-bar`).classList.add('hide'); s(`.bottom-bar`).classList.add('hide'); s(`.modal-menu`).style.top = '0px'; s(`.main-body-btn-container`).style.top = '50px'; s(`.main-body`).style.top = '0px'; s(`.main-body`).style.height = `${windowGetH()}px`; for (const event of Object.keys(Modal.Data[idModal].onBarUiClose)) Modal.Data[idModal].onBarUiClose[event](); } else { s(`.main-body-btn-ui-close`).classList.remove('hide'); s(`.main-body-btn-ui-open`).classList.add('hide'); options.heightTopBar = _heightTopBar; options.heightBottomBar = _heightBottomBar; s(`.modal-menu`).style.top = _topMenu; s(`.main-body-btn-container`).style.top = `${options.heightTopBar + 50}px`; s(`.slide-menu-top-bar`).classList.remove('hide'); s(`.bottom-bar`).classList.remove('hide'); s(`.main-body`).style.top = `${options.heightTopBar}px`; s(`.main-body`).style.height = `${windowGetH() - options.heightTopBar}px`; for (const event of Object.keys(Modal.Data[idModal].onBarUiOpen)) Modal.Data[idModal].onBarUiOpen[event](); } Responsive.triggerChanged(`slide-menu-modal-menu`); Object.keys(this.Data).map((_idModal) => { if (this.Data[_idModal].slideMenu) { if (s(`.btn-maximize-${_idModal}`)) s(`.btn-maximize-${_idModal}`).click(); } }); Responsive.triggerChanged(`view-${'main-body'}`); if (Responsive.hasChangedListener(`view-${'bottom-bar'}`)) Responsive.triggerChanged(`view-${'bottom-bar'}`); if (Responsive.hasChangedListener(`view-${'main-body-top'}`)) Responsive.triggerChanged(`view-${'main-body-top'}`); for (const keyEvent of Object.keys(this.Data[idModal].onExpandUiListener)) { this.Data[idModal].onExpandUiListener[keyEvent]( !s(`.main-body-btn-ui-open`).classList.contains('hide'), ); } }; Modal.setTopBannerLink(); }); const inputSearchBoxId = `top-bar-search-box`; append( 'body', html` <div class="fix modal slide-menu-top-bar"> <div class="fl top-bar ${options.barClass ? options.barClass : ''}" style="height: ${originHeightTopBar}px;" > ${await BtnIcon.instance({ style: `height: 100%`, class: 'in fll main-btn-menu action-bar-box action-btn-close hide', label: html` <div class="${contentIconClass} action-btn-close-render"> <i class="fa-solid fa-xmark"></i> </div>`, })} ${await BtnIcon.instance({ style: `height: 100%`, class: `in fll main-btn-menu action-bar-box action-btn-app-icon ${ options?.disableTools?.includes('app-icon') ? 'hide' : '' }`, label: html` <div class="${contentIconClass} action-btn-app-icon-render"></div>`, })} <form class="in fll top-bar-search-box-container hover ${options?.disableTools?.includes('text-box') ? 'hide' : ''}" > ${await Input.instance({ id: inputSearchBoxId, autocomplete: 'off', placeholder: Modal.mobileModal() ? Translate.instance('search', '.top-bar-search-box') : undefined, // html`<i class="fa-solid fa-magnifying-glass"></i> ${Translate.instance('search')}`, placeholderIcon: html`<div class="in fll" style="width: ${originHeightTopBar}px; height: ${originHeightTopBar}px;" > <div class="abs center"><i class="fa-solid fa-magnifying-glass"></i></div> ${!Modal.mobileModal() ? html` <div class="inl wfm key-shortcut-container-info" style="${renderCssAttr({ style: { top: '10px', left: '60px' } })}" > ${await Badge.instance({ id: 'shortcut-key-info-search', text: 'Shift', classList: 'inl', style: { 'z-index': 1 }, })} ${await Badge.instance({ id: 'shortcut-key-info-search', text: '+', classList: 'inl', style: { 'z-index': 1, background: 'none', color: '#5f5f5f' }, })} ${await Badge.instance({ id: 'shortcut-key-info-search', text: 'k', classList: 'inl', style: { 'z-index': 1 }, })} </div>` : ''} </div>`, inputClass: 'in fll', // containerClass: '', })} </form> <div class="abs top-box-profile-container ${options?.disableTools?.includes('profile') ? 'hide' : ''}" > ${await BtnIcon.instance({ style: `height: 100%`, class: 'in fll session-in-log-in main-btn-menu action-bar-box action-btn-profile-log-in', label: html` <div class="${contentIconClass} action-btn-profile-log-in-render"></div>`, })} ${await BtnIcon.instance({ style: `height: 100%`, class: 'in fll session-in-log-out main-btn-menu action-bar-box action-btn-profile-log-out', label: html` <div class="${contentIconClass} action-btn-profile-log-out-render"> <i class="fas fa-user-plus"></i> </div>`, })} </div> </div> ${options?.slideMenuTopBarBannerFix ? html`<div class="abs modal slide-menu-top-bar-fix" style="height: ${options.heightTopBar}px; top: 0px" > <a class="a-link-top-banner fl"> <div class="inl">${await options.slideMenuTopBarBannerFix()}</div></a > </div>` : ''} </div>`, ); EventsUI.onClick(`.action-btn-profile-log-in`, () => { if (Modal.mobileModal() && s(`.btn-close-search-box-history`)) { s(`.btn-close-search-box-history`).click(); } s(`.main-btn-account`).click(); }); EventsUI.onClick(`.action-btn-profile-log-out`, () => { if (Modal.mobileModal() && s(`.btn-close-search-box-history`)) { s(`.btn-close-search-box-history`).click(); } s(`.main-btn-sign-up`).click(); }); s(`.input-info-${inputSearchBoxId}`).style.textAlign = 'left'; htmls(`.input-info-${inputSearchBoxId}`, ''); const inputInfoNode = s(`.input-info-${inputSearchBoxId}`).cloneNode(true); s(`.input-info-${inputSearchBoxId}`).remove(); { // Inject SearchBox base styles SearchBox.injectStyles(); const id = 'search-box-history'; const searchBoxHistoryId = id; const formDataInfoNode = [ { model: 'search-box', id: inputSearchBoxId, rules: [] /*{ type: 'isEmpty' }, { type: 'isEmail' }*/, }, ]; // Reusable hover/focus controller for search history panel let unbindDocSearch = null; const hoverFocusCtl = EventsUI.HoverFocusController({ inputSelector: `.top-bar-search-box-container`, panelSelector: `.${id}`, activeElementId: inputSearchBoxId, onDismiss: () => dismissSearchBox(), }); let currentKeyBoardSearchBoxIndex = 0; let results = []; const checkHistoryBoxTitleStatus = () => { if (s(`.search-box-result-title`) && s(`.search-box-result-title`).classList) { if (!s(`.${inputSearchBoxId}`).value.trim()) { s(`.search-box-result-title`).classList.add('hide'); s(`.search-box-recent-title`).classList.remove('hide'); } else { s(`.search-box-recent-title`).classList.add('hide'); s(`.search-box-result-title`).classList.remove('hide'); } } }; const checkShortcutContainerInfoEnabled = () => { if (Modal.mobileModal() || !s(`.key-shortcut-container-info`)) return; if (!s(`.${inputSearchBoxId}`).value) { s(`.key-shortcut-container-info`).classList.remove('hide'); } else s(`.key-shortcut-container-info`).classList.add('hide'); }; const renderSearchResult = async (results, isRecentHistory = false) => { // Check if the search history modal still exists before rendering if (!s(`.html-${searchBoxHistoryId}`)) return; htmls(`.html-${searchBoxHistoryId}`, ''); // Show/hide clear-all button based on whether showing recent history // Use setTimeout to ensure the button is in the DOM after modal renders const updateClearAllBtn = () => { const clearAllBtn = s(`.btn-search-history-clear-all`); if (clearAllBtn) { if (isRecentHistory && results.length > 0) { clearAllBtn.style.display = 'flex'; } else { clearAllBtn.style.display = 'none'; } } }; // Try immediately and also with a delay to handle timing updateClearAllBtn(); setTimeout(updateClearAllBtn, 50); if (results.length === 0) { append( `.html-${searchBoxHistoryId}`, await BtnIcon.instance({ label: html`<i class="fas fa-exclamation-circle"></i> ${Translate.instance('no-result-found')}`, class: `wfa`, style: renderCssAttr({ style: { padding: '3px', margin: '2px', 'text-align': 'center', border: 'none', cursor: 'default', background: 'none !important', }, }), }), ); return; } // Use SearchBox component for rendering results const searchContext = { RouterInstance: Worker.RouterInstance, options: options, isRecentHistory: isRecentHistory, // Flag for delete button visibility onResultClick: () => { // Dismiss search box on result click if (s(`.${searchBoxHistoryId}`)) { Modal.removeModal(searchBoxHistoryId); } }, }; SearchBox.renderResults(results, `html-${searchBoxHistoryId}`, searchContext); }; const getResultSearchBox = async (validatorData) => { if (!s(`.html-${searchBoxHistoryId}`)) return; const { model, id } = validatorData; switch (model) { case 'search-box': { currentKeyBoardSearchBoxIndex = 0; results = []; const query = s(`.${id}`) ? s(`.${id}`).value : ''; const trimmedQuery = query.trim(); // Use SearchBox component for extensible search const searchContext = { RouterInstance: Worker.RouterInstance, options: options, minQueryLength: options?.minSearchQueryLength || 1, // Allow single character search by default onResultClick: () => { // Dismiss search box on result click if (s(`.${searchBoxHistoryId}`)) { Modal.removeModal(searchBoxHistoryId); } }, }; // Check minimum query length (default: 1 character) const minLength = searchContext.minQueryLength; if (trimmedQuery.length >= minLength) { results = await SearchBox.search(trimmedQuery, searchContext); renderSearchResult(results, false); // Search results - no delete buttons } else if (trimmedQuery.length === 0) { // Show recent results from persistent history when query is empty const recentResults = SearchBox.RecentResults.getAll(); renderSearchResult(recentResults, true); // Recent history - show delete buttons } else { // Query is too short - show nothing or a hint renderSearchResult([], false); } } break; default: break; } }; const searchBoxCallBack = async (validatorData) => { const isSearchBoxActiveElement = isActiveElement(inputSearchBoxId); checkHistoryBoxTitleStatus(); checkShortcutContainerInfoEnabled(); if (!isSearchBoxActiveElement && !hoverFocusCtl.shouldStay()) { Modal.removeModal(searchBoxHistoryId); return; } setTimeout(() => { getResultSearchBox(validatorData); if ( s(`.slide-menu-top-bar-fix`) && !s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide') ) { s(`.main-body-btn-bar-custom`).click(); } }); }; const updateSearchBoxValue = (selector) => { if (!selector) { // Get the currently active search result item const activeItem = s(`.html-${searchBoxHistoryId} .search-result-item.active-search-result`); if (activeItem) { const titleEl = activeItem.querySelector('.search-result-title'); if (titleEl && titleEl.textContent) { s(`.${inputSearchBoxId}`).value = titleEl.textContent.trim(); } } } else if (s(selector)) { const titleEl = s(selector).querySelector('.search-result-title'); if (titleEl && titleEl.textContent) { s(`.${inputSearchBoxId}`).value = titleEl.textContent.trim(); } } checkHistoryBoxTitleStatus(); checkShortcutContainerInfoEnabled(); }; const setSearchValue = (selector) => { // Get all search result items const allItems = sa(`.html-${searchBoxHistoryId} .search-result-item`); if (!allItems || allItems.length === 0) return; const activeItem = allItems[currentKeyBoardSearchBoxIndex]; if (!activeItem) return; const resultId = activeItem.getAttribute('data-result-id'); const resultType = activeItem.getAttribute('data-result-type'); if (resultType === 'route' && results[currentKeyBoardSearchBoxIndex]) { const result = results[currentKeyBoardSearchBoxIndex]; // Track in persistent history SearchBox.RecentResults.add(result); updateSearchBoxValue(); if (s(`.main-btn-${resultId}`)) { s(`.main-btn-${resultId}`).click(); } } else { // Track custom provider result in persistent history if (results[currentKeyBoardSearchBoxIndex]) { SearchBox.RecentResults.add(results[currentKeyBoardSearchBoxIndex]); } // Trigger click on custom result activeItem.click(); } Modal.removeModal(searchBoxHistoryId); }; let boxHistoryDelayRender = 0; const searchBoxHistoryOpen = async () => { if (boxHistoryDelayRender) return; if (Modal.mobileModal()) { btnCloseEvent(); } boxHistoryDelayRender = 1000; setTimeout(() => (boxHistoryDelayRender = 0)); if (!s(`.${searchBoxHistoryId}`)) { const { barConfig } = await Themes[Css.currentTheme](); barConfig.buttons.maximize.disabled = true; barConfig.buttons.minimize.disabled = true; barConfig.buttons.restore.disabled = true; barConfig.buttons.menu.disabled = true; barConfig.buttons.close.disabled = false; await Modal.instance({ id: searchBoxHistoryId, barConfig, title: html`<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;" > <div style="display: flex; align-items: center; gap: 8px;"> <div class="search-box-recent-title"> ${renderViewTitle({ icon: html`<i class="fas fa-history mini-title"></i>`, text: Translate.instance('recent'), })} </div> <div class="search-box-result-title hide"> ${renderViewTitle({ icon: html`<i class="far fa-list-alt mini-title"></i>`, text: Translate.instance('results'), })} </div> </div> </div>`, html: () => html``, titleClass: 'mini-title', style: { resize: 'none', 'max-width': '450px', height: this.mobileModal() && windowGetW() < 445 ? `${windowGetH() - originHeightTopBar}px !important` : '300px !important', 'z-index': 7, }, class: 'search-history-modal', dragDisabled: true, maximize: true, barMode: options.barMode, }); // Bind hover/focus and click-outside to dismiss hoverFocusCtl.bind(); unbindDocSearch = EventsUI.bindDismissOnDocumentClick({ shouldStay: hoverFocusCtl.shouldStay, onDismiss: () => dismissSearchBox(), anchors: [`.top-bar-search-box-container`, `.${id}`], }); // Ensure cleanup when modal closes Modal.Data[id].onCloseListener[`unbind-doc-${id}`] = () => unbindDocSearch && unbindDocSearch(); Modal.MoveTitleToBar(id); // Add styles for inline button layout in the search history modal bar const styleId = 'search-history-modal-bar-styles'; if (!s(`#${styleId}`)) { const styleTag = document.createElement('style'); styleTag.id = styleId; styleTag.textContent = ` .search-history-modal .btn-bar-modal-container .bar-default-modal { display: flex; flex-direction: row-reverse; align-items: center; } .search-history-modal .btn-bar-modal-container .btn-modal-default { display: inline-flex; align-items: center; justify-content: center; float: none; } .search-history-modal .html-${searchBoxHistoryId} { overflow-x: hidden; box-sizing: border-box; } .search-history-modal .html-${searchBoxHistoryId} .search-result-item, .search-history-modal .html-${searchBoxHistoryId} .search-result-history-item { box-sizing: border-box; max-width: 100%; } `; document.head.appendChild(styleTag); } // Add clear all button to the bar area, before the close button const clearAllBtnHtml = await BtnIcon.instance({ class: `btn-search-history-clear-all btn-modal-default btn-modal-default-${searchBoxHistoryId}`, label: html`<i class="fas fa-trash-alt"></i>`, attrs: `title="Clear all recent items"`, style: 'padding: 4px 8px; font-size: 12px; display: none;', }); // Insert before close button in the bar (with flex row-reverse, inserting after close button places our button to its left visually) const closeBtn = s(`.btn-close-${searchBoxHistoryId}`); if (closeBtn) { closeBtn.insertAdjacentHTML('afterend', clearAllBtnHtml); } // Add click handler for clear all history button const clearAllBtn = s(`.btn-search-history-clear-all`); if (clearAllBtn) { clearAllBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); // Clear all history from persistent storage SearchBox.RecentResults.clear(); // Re-render to show empty history (with isRecentHistory true to hide button) renderSearchResult([], true); }; } prepend(`.btn-bar-modal-container-${id}`, html`<div class="hide">${inputInfoNode.outerHTML}</div>`); } }; s('.top-ba