@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
JavaScript
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