@underpostnet/underpost 2.97.0 → 2.97.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/baremetal/commission-workflows.json +33 -3
- package/bin/deploy.js +1 -1
- package/cli.md +5 -2
- package/conf.js +1 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/package.json +1 -1
- package/packer/scripts/fuse-tar-root +3 -3
- package/scripts/disk-clean.sh +23 -23
- package/scripts/gpu-diag.sh +2 -2
- package/scripts/ip-info.sh +11 -11
- package/scripts/maas-upload-boot-resource.sh +1 -1
- package/scripts/nvim.sh +1 -1
- package/scripts/packer-setup.sh +13 -13
- package/scripts/rocky-setup.sh +2 -2
- package/scripts/rpmfusion-ffmpeg-setup.sh +4 -4
- package/scripts/ssl.sh +7 -7
- package/src/api/document/document.model.js +30 -1
- package/src/api/document/document.router.js +1 -0
- package/src/api/document/document.service.js +339 -25
- package/src/cli/baremetal.js +689 -329
- package/src/cli/cluster.js +50 -52
- package/src/cli/deploy.js +1 -1
- package/src/cli/index.js +4 -1
- package/src/cli/lxd.js +3 -3
- package/src/cli/run.js +1 -1
- package/src/client/components/core/Css.js +16 -2
- package/src/client/components/core/Modal.js +125 -159
- package/src/client/components/core/Panel.js +276 -17
- package/src/client/components/core/PanelForm.js +24 -2
- package/src/client/components/core/SearchBox.js +801 -0
- package/src/client/services/document/document.service.js +23 -0
- package/src/index.js +1 -1
- package/src/server/dns.js +4 -4
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable search component with extensible plugin architecture.
|
|
3
|
+
* Provides typeahead search functionality with support for multiple search providers,
|
|
4
|
+
* custom rendering, keyboard navigation, and theme-aware styling.
|
|
5
|
+
* @module src/client/components/core/SearchBox.js
|
|
6
|
+
* @namespace SearchBoxClient
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loggerFactory } from './Logger.js';
|
|
10
|
+
import { s, getAllChildNodes, htmls } from './VanillaJs.js';
|
|
11
|
+
import { Translate } from './Translate.js';
|
|
12
|
+
import { darkTheme, ThemeEvents, subThemeManager, lightenHex, darkenHex } from './Css.js';
|
|
13
|
+
|
|
14
|
+
const logger = loggerFactory(import.meta);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SearchBox singleton object providing extensible search functionality.
|
|
18
|
+
* Supports default menu/route search and pluggable search providers with
|
|
19
|
+
* custom rendering, click handlers, and result merging.
|
|
20
|
+
* @memberof SearchBoxClient
|
|
21
|
+
*/
|
|
22
|
+
const SearchBox = {
|
|
23
|
+
/**
|
|
24
|
+
* Internal data storage for search state and handlers.
|
|
25
|
+
* @type {object}
|
|
26
|
+
* @memberof SearchBoxClient.SearchBox
|
|
27
|
+
*/
|
|
28
|
+
Data: {},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Registry of registered search provider plugins.
|
|
32
|
+
* Each provider implements the search provider interface:
|
|
33
|
+
* - id: Unique identifier string
|
|
34
|
+
* - search: async (query, context) => Promise<Array<result>>
|
|
35
|
+
* - renderResult: (result, index, context) => string (HTML)
|
|
36
|
+
* - onClick: (result, context) => void
|
|
37
|
+
* - priority: number (lower = higher priority)
|
|
38
|
+
* @type {Array<object>}
|
|
39
|
+
* @memberof SearchBoxClient.SearchBox
|
|
40
|
+
*/
|
|
41
|
+
providers: [],
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Registers a search provider plugin for extensible search functionality.
|
|
45
|
+
* Replaces any existing provider with the same ID.
|
|
46
|
+
* @memberof SearchBoxClient.SearchBox
|
|
47
|
+
* @param {object} provider - The search provider object to register.
|
|
48
|
+
* @param {string} provider.id - Unique identifier for the provider.
|
|
49
|
+
* @param {Function} provider.search - Async function: (query, context) => Promise<Array<result>>.
|
|
50
|
+
* @param {Function} [provider.renderResult] - Custom renderer: (result, index, context) => HTML string.
|
|
51
|
+
* @param {Function} [provider.onClick] - Click handler: (result, context) => void.
|
|
52
|
+
* @param {number} [provider.priority=50] - Priority for result ordering (lower = higher priority).
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*/
|
|
55
|
+
registerProvider: function (provider) {
|
|
56
|
+
if (!provider.id || !provider.search) {
|
|
57
|
+
logger.error('Invalid provider. Must have id and search function');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Remove existing provider with same id
|
|
62
|
+
this.providers = this.providers.filter((p) => p.id !== provider.id);
|
|
63
|
+
|
|
64
|
+
// Add new provider
|
|
65
|
+
this.providers.push({
|
|
66
|
+
id: provider.id,
|
|
67
|
+
search: provider.search,
|
|
68
|
+
renderResult: provider.renderResult || ((result) => this.defaultRenderResult(result)),
|
|
69
|
+
onClick: provider.onClick || (() => {}),
|
|
70
|
+
priority: provider.priority || 50, // Lower number = higher priority in results
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
logger.info(`Registered search provider: ${provider.id}`);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Unregisters a search provider by its ID.
|
|
78
|
+
* @memberof SearchBoxClient.SearchBox
|
|
79
|
+
* @param {string} providerId - The ID of the provider to unregister.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
unregisterProvider: function (providerId) {
|
|
83
|
+
this.providers = this.providers.filter((p) => p.id !== providerId);
|
|
84
|
+
logger.info(`Unregistered search provider: ${providerId}`);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Default result renderer with support for tags and badges.
|
|
89
|
+
* Used when a provider doesn't supply a custom renderResult function.
|
|
90
|
+
* @memberof SearchBoxClient.SearchBox
|
|
91
|
+
* @param {object} result - The search result object to render.
|
|
92
|
+
* @param {string} result.id - Result identifier.
|
|
93
|
+
* @param {string} [result.icon] - HTML for icon display.
|
|
94
|
+
* @param {string} [result.title] - Result title text.
|
|
95
|
+
* @param {string} [result.subtitle] - Result subtitle text.
|
|
96
|
+
* @param {Array<string>} [result.tags] - Array of tag strings.
|
|
97
|
+
* @param {string} result.type - Result type identifier.
|
|
98
|
+
* @param {string} result.providerId - Provider ID that generated this result.
|
|
99
|
+
* @returns {string} HTML string for the search result.
|
|
100
|
+
*/
|
|
101
|
+
defaultRenderResult: function (result) {
|
|
102
|
+
const icon = result.icon || '<i class="fas fa-file"></i>';
|
|
103
|
+
const title = result.title || result.id || 'Untitled';
|
|
104
|
+
const subtitle = result.subtitle || '';
|
|
105
|
+
const tags = result.tags || [];
|
|
106
|
+
|
|
107
|
+
// Render tags if available
|
|
108
|
+
const tagsHtml =
|
|
109
|
+
tags.length > 0
|
|
110
|
+
? `<div class="search-result-tags">
|
|
111
|
+
${tags.map((tag) => `<span class="search-result-tag">${tag}</span>`).join('')}
|
|
112
|
+
</div>`
|
|
113
|
+
: '';
|
|
114
|
+
|
|
115
|
+
return html`
|
|
116
|
+
<div
|
|
117
|
+
class="search-result-item"
|
|
118
|
+
data-result-id="${result.id}"
|
|
119
|
+
data-result-type="${result.type}"
|
|
120
|
+
data-provider-id="${result.providerId}"
|
|
121
|
+
>
|
|
122
|
+
<div class="search-result-icon">${icon}</div>
|
|
123
|
+
<div class="search-result-content">
|
|
124
|
+
<div class="search-result-title">${title}</div>
|
|
125
|
+
${subtitle ? `<div class="search-result-subtitle">${subtitle}</div>` : ''} ${tagsHtml}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
`;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Navigates through search results using keyboard arrow keys.
|
|
133
|
+
* Optimized for performance with direct DOM manipulation and efficient scrolling.
|
|
134
|
+
* Supports wrap-around navigation (top to bottom and vice versa).
|
|
135
|
+
* @memberof SearchBoxClient.SearchBox
|
|
136
|
+
* @param {string} direction - Navigation direction: 'up' or 'down'.
|
|
137
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
138
|
+
* @param {number} currentIndex - Current active result index (0-based).
|
|
139
|
+
* @param {number} totalItems - Total number of result items.
|
|
140
|
+
* @returns {number} New active index after navigation.
|
|
141
|
+
*/
|
|
142
|
+
navigateResults: function (direction, containerId, currentIndex, totalItems) {
|
|
143
|
+
if (!containerId || totalItems === 0) return currentIndex;
|
|
144
|
+
|
|
145
|
+
const container = s(`#${containerId}`) || s(`.${containerId}`);
|
|
146
|
+
const allItems = container ? container.querySelectorAll('.search-result-item') : [];
|
|
147
|
+
|
|
148
|
+
if (!allItems || allItems.length === 0) return currentIndex;
|
|
149
|
+
|
|
150
|
+
// Remove active class from current item (efficient DOM manipulation)
|
|
151
|
+
if (allItems[currentIndex]) {
|
|
152
|
+
allItems[currentIndex].classList.remove('active-search-result');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Calculate new index with wrap-around
|
|
156
|
+
let newIndex = currentIndex;
|
|
157
|
+
if (direction === 'up') {
|
|
158
|
+
newIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
|
|
159
|
+
} else if (direction === 'down') {
|
|
160
|
+
newIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Add active class to new item and ensure visibility
|
|
164
|
+
if (allItems[newIndex]) {
|
|
165
|
+
allItems[newIndex].classList.add('active-search-result');
|
|
166
|
+
// Use optimized scroll method (no animation, instant positioning)
|
|
167
|
+
this.scrollIntoViewIfNeeded(allItems[newIndex], container);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return newIndex;
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Searches through default application routes for matches.
|
|
175
|
+
* Backward compatible with Modal.js search functionality.
|
|
176
|
+
* Matches route IDs and translated route names against the query string.
|
|
177
|
+
* @memberof SearchBoxClient.SearchBox
|
|
178
|
+
* @param {string} query - The search query string.
|
|
179
|
+
* @param {object} context - Search context object.
|
|
180
|
+
* @param {object} [context.RouterInstance] - Router instance containing routes.
|
|
181
|
+
* @param {object} [context.options] - Additional search options.
|
|
182
|
+
* @param {string} [context.options.searchCustomImgClass] - Custom image class to search for.
|
|
183
|
+
* @returns {Array<object>} Array of route search results.
|
|
184
|
+
*/
|
|
185
|
+
searchRoutes: function (query, context) {
|
|
186
|
+
const results = [];
|
|
187
|
+
const { RouterInstance, options = {} } = context;
|
|
188
|
+
|
|
189
|
+
if (!RouterInstance) return results;
|
|
190
|
+
|
|
191
|
+
const routerInstance = RouterInstance.Routes();
|
|
192
|
+
for (const _routerId of Object.keys(routerInstance)) {
|
|
193
|
+
const routerId = _routerId.slice(1);
|
|
194
|
+
if (routerId) {
|
|
195
|
+
if (
|
|
196
|
+
s(`.main-btn-${routerId}`) &&
|
|
197
|
+
(routerId.toLowerCase().match(query.toLowerCase()) ||
|
|
198
|
+
(Translate.Data[routerId] &&
|
|
199
|
+
Object.keys(Translate.Data[routerId]).filter((keyLang) =>
|
|
200
|
+
Translate.Data[routerId][keyLang].toLowerCase().match(query.toLowerCase()),
|
|
201
|
+
).length > 0))
|
|
202
|
+
) {
|
|
203
|
+
const fontAwesomeIcon = getAllChildNodes(s(`.main-btn-${routerId}`)).find((e) => {
|
|
204
|
+
return e.classList && Array.from(e.classList).find((e) => e.match('fa-') && !e.match('fa-grip-vertical'));
|
|
205
|
+
});
|
|
206
|
+
const imgElement = getAllChildNodes(s(`.main-btn-${routerId}`)).find((e) => {
|
|
207
|
+
return (
|
|
208
|
+
e.classList &&
|
|
209
|
+
Array.from(e.classList).find((e) =>
|
|
210
|
+
options.searchCustomImgClass ? e.match(options.searchCustomImgClass) : e.match('img-btn-square-menu'),
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
if (imgElement || fontAwesomeIcon) {
|
|
215
|
+
results.push({
|
|
216
|
+
id: routerId,
|
|
217
|
+
routerId,
|
|
218
|
+
fontAwesomeIcon,
|
|
219
|
+
imgElement,
|
|
220
|
+
type: 'route',
|
|
221
|
+
providerId: 'default-routes',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return results;
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Executes search across all registered providers and default routes.
|
|
232
|
+
* Combines results from multiple sources and sorts by priority.
|
|
233
|
+
* @memberof SearchBoxClient.SearchBox
|
|
234
|
+
* @param {string} query - The search query string.
|
|
235
|
+
* @param {object} [context={}] - Search context object passed to all providers.
|
|
236
|
+
* @returns {Promise<Array<object>>} Promise resolving to combined, priority-sorted results array.
|
|
237
|
+
*/
|
|
238
|
+
search: async function (query, context = {}) {
|
|
239
|
+
const allResults = [];
|
|
240
|
+
|
|
241
|
+
// Always include default route search (backward compatible)
|
|
242
|
+
const routeResults = this.searchRoutes(query, context);
|
|
243
|
+
allResults.push(...routeResults);
|
|
244
|
+
|
|
245
|
+
// Execute all registered providers
|
|
246
|
+
const providerPromises = this.providers.map(async (provider) => {
|
|
247
|
+
try {
|
|
248
|
+
const results = await provider.search(query, context);
|
|
249
|
+
return results.map((result) => ({
|
|
250
|
+
...result,
|
|
251
|
+
providerId: provider.id,
|
|
252
|
+
priority: provider.priority,
|
|
253
|
+
}));
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.error(`Error in provider ${provider.id}:`, error);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const providerResults = await Promise.all(providerPromises);
|
|
261
|
+
providerResults.forEach((results) => {
|
|
262
|
+
allResults.push(...results);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Sort by priority
|
|
266
|
+
allResults.sort((a, b) => (a.priority || 50) - (b.priority || 50));
|
|
267
|
+
|
|
268
|
+
return allResults;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Renders search results into a container element.
|
|
273
|
+
* Delegates rendering to provider-specific renderers or default route renderer.
|
|
274
|
+
* Automatically attaches click handlers and calls provider post-render hooks.
|
|
275
|
+
* @memberof SearchBoxClient.SearchBox
|
|
276
|
+
* @param {Array<object>} results - Array of search results to render.
|
|
277
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
278
|
+
* @param {object} [context={}] - Render context passed to renderers and handlers.
|
|
279
|
+
* @returns {void}
|
|
280
|
+
*/
|
|
281
|
+
renderResults: function (results, containerId, context = {}) {
|
|
282
|
+
const container = s(`#${containerId}`) || s(`.${containerId}`);
|
|
283
|
+
if (!container) {
|
|
284
|
+
logger.warn(`Container ${containerId} not found`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!results || results.length === 0) {
|
|
289
|
+
container.innerHTML = '';
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let html = '';
|
|
294
|
+
results.forEach((result, index) => {
|
|
295
|
+
const provider = this.providers.find((p) => p.id === result.providerId);
|
|
296
|
+
|
|
297
|
+
if (result.type === 'route' || !provider) {
|
|
298
|
+
// Default route rendering (backward compatible)
|
|
299
|
+
html += this.renderRouteResult(result, index, context);
|
|
300
|
+
} else {
|
|
301
|
+
// Custom provider rendering
|
|
302
|
+
html += provider.renderResult(result, index, context);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
container.innerHTML = html;
|
|
307
|
+
|
|
308
|
+
// Attach click handlers
|
|
309
|
+
this.attachClickHandlers(results, containerId, context);
|
|
310
|
+
|
|
311
|
+
// Call post-render callbacks from providers
|
|
312
|
+
results.forEach((result) => {
|
|
313
|
+
const provider = this.providers.find((p) => p.id === result.providerId);
|
|
314
|
+
if (provider && provider.attachTagHandlers) {
|
|
315
|
+
provider.attachTagHandlers();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Renders a default route search result.
|
|
322
|
+
* Backward compatible with Modal.js search functionality.
|
|
323
|
+
* Displays route icon and translated route name.
|
|
324
|
+
* @memberof SearchBoxClient.SearchBox
|
|
325
|
+
* @param {object} result - The route result object to render.
|
|
326
|
+
* @param {string} result.routerId - Route identifier.
|
|
327
|
+
* @param {HTMLElement} [result.fontAwesomeIcon] - FontAwesome icon element.
|
|
328
|
+
* @param {HTMLElement} [result.imgElement] - Image icon element.
|
|
329
|
+
* @param {number} index - The index of this result in the results array.
|
|
330
|
+
* @param {object} [context={}] - Render context object.
|
|
331
|
+
* @param {object} [context.options] - Additional rendering options.
|
|
332
|
+
* @returns {string} HTML string for the route search result.
|
|
333
|
+
*/
|
|
334
|
+
renderRouteResult: function (result, index, context = {}) {
|
|
335
|
+
const { options = {} } = context;
|
|
336
|
+
const routerId = result.routerId;
|
|
337
|
+
const fontAwesomeIcon = result.fontAwesomeIcon;
|
|
338
|
+
const imgElement = result.imgElement;
|
|
339
|
+
|
|
340
|
+
let iconHtml = '';
|
|
341
|
+
if (imgElement) {
|
|
342
|
+
iconHtml = imgElement.outerHTML;
|
|
343
|
+
} else if (fontAwesomeIcon) {
|
|
344
|
+
iconHtml = fontAwesomeIcon.outerHTML;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const translatedText = Translate.Render(routerId);
|
|
348
|
+
|
|
349
|
+
return html`
|
|
350
|
+
<div
|
|
351
|
+
class="search-result-item search-result-route"
|
|
352
|
+
data-result-id="${routerId}"
|
|
353
|
+
data-result-type="route"
|
|
354
|
+
data-result-index="${index}"
|
|
355
|
+
data-provider-id="default-routes"
|
|
356
|
+
>
|
|
357
|
+
<div class="search-result-icon">${iconHtml}</div>
|
|
358
|
+
<div class="search-result-content">
|
|
359
|
+
<div class="search-result-title">${translatedText}</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
`;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Attaches click event handlers to all rendered search results.
|
|
367
|
+
* Routes trigger menu button clicks; custom providers call their onClick handlers.
|
|
368
|
+
* @memberof SearchBoxClient.SearchBox
|
|
369
|
+
* @param {Array<object>} results - Array of search results.
|
|
370
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
371
|
+
* @param {object} [context={}] - Context object with callbacks.
|
|
372
|
+
* @param {Function} [context.onResultClick] - Callback invoked after any result is clicked.
|
|
373
|
+
* @returns {void}
|
|
374
|
+
*/
|
|
375
|
+
attachClickHandlers: function (results, containerId, context = {}) {
|
|
376
|
+
results.forEach((result, index) => {
|
|
377
|
+
const element = s(`[data-result-index="${index}"]`);
|
|
378
|
+
if (!element) return;
|
|
379
|
+
|
|
380
|
+
element.onclick = (e) => {
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
e.stopPropagation();
|
|
383
|
+
|
|
384
|
+
const provider = this.providers.find((p) => p.id === result.providerId);
|
|
385
|
+
|
|
386
|
+
if (result.type === 'route') {
|
|
387
|
+
// Default route behavior - click the menu button
|
|
388
|
+
const btnSelector = `.main-btn-${result.routerId}`;
|
|
389
|
+
if (s(btnSelector)) {
|
|
390
|
+
s(btnSelector).click();
|
|
391
|
+
}
|
|
392
|
+
} else if (provider && provider.onClick) {
|
|
393
|
+
// Custom provider click handler
|
|
394
|
+
provider.onClick(result, context);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Dismiss search box if callback provided
|
|
398
|
+
if (context.onResultClick) {
|
|
399
|
+
context.onResultClick(result);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Scrolls an element into view within a scrollable container if needed.
|
|
407
|
+
* Performance-critical for keyboard navigation - uses direct scrollTop manipulation
|
|
408
|
+
* instead of smooth scrolling to reduce overhead and ensure instant visibility.
|
|
409
|
+
*
|
|
410
|
+
* ROBUST IMPLEMENTATION:
|
|
411
|
+
* - Auto-detects the actual scrollable parent container
|
|
412
|
+
* - Uses getBoundingClientRect() for accurate viewport-aware positioning
|
|
413
|
+
* - Handles complex DOM structures (modals, positioned elements, transforms)
|
|
414
|
+
* - Includes fallback to native scrollIntoView() if custom logic fails
|
|
415
|
+
*
|
|
416
|
+
* Algorithm:
|
|
417
|
+
* 1. Find actual scrollable container (may be parent of passed container)
|
|
418
|
+
* 2. Calculate element position relative to container's visible area
|
|
419
|
+
* 3. Determine scroll adjustment needed (up, down, or none)
|
|
420
|
+
* 4. Apply scroll adjustment
|
|
421
|
+
* 5. Verify visibility and use native scrollIntoView as fallback if needed
|
|
422
|
+
*
|
|
423
|
+
* @memberof SearchBoxClient.SearchBox
|
|
424
|
+
* @param {HTMLElement} element - The element to scroll into view.
|
|
425
|
+
* @param {HTMLElement} container - The scrollable container (or parent of scrollable).
|
|
426
|
+
* @returns {void}
|
|
427
|
+
*/
|
|
428
|
+
scrollIntoViewIfNeeded: function (element, container) {
|
|
429
|
+
if (!element || !container) return;
|
|
430
|
+
|
|
431
|
+
// CRITICAL FIX: Find the actual scrollable container
|
|
432
|
+
// The passed container might not be scrollable; we need to find the parent that is
|
|
433
|
+
let scrollableContainer = container;
|
|
434
|
+
|
|
435
|
+
// Check if current container is scrollable
|
|
436
|
+
const isScrollable = (el) => {
|
|
437
|
+
if (!el) return false;
|
|
438
|
+
const hasScroll = el.scrollHeight > el.clientHeight;
|
|
439
|
+
const overflowY = window.getComputedStyle(el).overflowY;
|
|
440
|
+
return hasScroll && (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay');
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// If container is not scrollable, traverse up to find scrollable parent
|
|
444
|
+
if (!isScrollable(container)) {
|
|
445
|
+
let parent = container.parentElement;
|
|
446
|
+
while (parent && parent !== document.body) {
|
|
447
|
+
if (isScrollable(parent)) {
|
|
448
|
+
scrollableContainer = parent;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
parent = parent.parentElement;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ROBUST POSITION CALCULATION
|
|
456
|
+
// Get element's position relative to scrollable container using getBoundingClientRect
|
|
457
|
+
// This handles all edge cases including transformed elements, scrolled parents, etc.
|
|
458
|
+
const elementRect = element.getBoundingClientRect();
|
|
459
|
+
const containerRect = scrollableContainer.getBoundingClientRect();
|
|
460
|
+
|
|
461
|
+
// Calculate element position relative to container's visible area
|
|
462
|
+
const elementTopRelative = elementRect.top - containerRect.top;
|
|
463
|
+
const elementBottomRelative = elementRect.bottom - containerRect.top;
|
|
464
|
+
const containerVisibleHeight = scrollableContainer.clientHeight;
|
|
465
|
+
|
|
466
|
+
// Add padding to avoid elements being exactly at edges (better UX)
|
|
467
|
+
const padding = 10;
|
|
468
|
+
|
|
469
|
+
// Determine scroll adjustment needed
|
|
470
|
+
let scrollAdjustment = 0;
|
|
471
|
+
|
|
472
|
+
// Element is ABOVE visible area
|
|
473
|
+
if (elementTopRelative < padding) {
|
|
474
|
+
// Need to scroll up
|
|
475
|
+
scrollAdjustment = elementTopRelative - padding;
|
|
476
|
+
}
|
|
477
|
+
// Element is BELOW visible area
|
|
478
|
+
else if (elementBottomRelative > containerVisibleHeight - padding) {
|
|
479
|
+
// Need to scroll down
|
|
480
|
+
scrollAdjustment = elementBottomRelative - containerVisibleHeight + padding;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Apply scroll adjustment if needed
|
|
484
|
+
if (scrollAdjustment !== 0) {
|
|
485
|
+
scrollableContainer.scrollTop += scrollAdjustment;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// FALLBACK: If custom scroll didn't work, use native scrollIntoView
|
|
489
|
+
// This ensures visibility even if our calculation fails
|
|
490
|
+
setTimeout(() => {
|
|
491
|
+
const rectCheck = element.getBoundingClientRect();
|
|
492
|
+
const containerRectCheck = scrollableContainer.getBoundingClientRect();
|
|
493
|
+
const stillAbove = rectCheck.top < containerRectCheck.top;
|
|
494
|
+
const stillBelow = rectCheck.bottom > containerRectCheck.bottom;
|
|
495
|
+
|
|
496
|
+
if (stillAbove || stillBelow) {
|
|
497
|
+
element.scrollIntoView({
|
|
498
|
+
behavior: 'auto',
|
|
499
|
+
block: stillAbove ? 'start' : 'end',
|
|
500
|
+
inline: 'nearest',
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}, 0);
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Debounce helper for search-while-typing
|
|
508
|
+
*/
|
|
509
|
+
debounce: function (func, wait) {
|
|
510
|
+
let timeout;
|
|
511
|
+
return function executedFunction(...args) {
|
|
512
|
+
const later = () => {
|
|
513
|
+
clearTimeout(timeout);
|
|
514
|
+
func(...args);
|
|
515
|
+
};
|
|
516
|
+
clearTimeout(timeout);
|
|
517
|
+
timeout = setTimeout(later, wait);
|
|
518
|
+
};
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Sets up a search input element with automatic search on typing.
|
|
523
|
+
* Attaches debounced input event handler and manages search lifecycle.
|
|
524
|
+
* @memberof SearchBoxClient.SearchBox
|
|
525
|
+
* @param {string} inputId - Input element ID or class name.
|
|
526
|
+
* @param {string} resultsContainerId - Results container element ID or class name.
|
|
527
|
+
* @param {object} [context={}] - Configuration context object.
|
|
528
|
+
* @param {number} [context.debounceTime=300] - Debounce delay in milliseconds.
|
|
529
|
+
* @param {number} [context.minQueryLength=1] - Minimum query length to trigger search.
|
|
530
|
+
* @returns {Function} Cleanup function to remove event listeners.
|
|
531
|
+
*/
|
|
532
|
+
setupSearchInput: function (inputId, resultsContainerId, context = {}) {
|
|
533
|
+
const input = s(`#${inputId}`) || s(`.${inputId}`);
|
|
534
|
+
if (!input) {
|
|
535
|
+
logger.warn(`Input ${inputId} not found`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const debounceTime = context.debounceTime || 300;
|
|
540
|
+
|
|
541
|
+
const performSearch = this.debounce(async (query) => {
|
|
542
|
+
const trimmedQuery = query ? query.trim() : '';
|
|
543
|
+
const minLength = context.minQueryLength !== undefined ? context.minQueryLength : 1;
|
|
544
|
+
|
|
545
|
+
// Support single character searches by default (minQueryLength: 1)
|
|
546
|
+
// Can be configured via context.minQueryLength for different use cases
|
|
547
|
+
if (trimmedQuery.length < minLength) {
|
|
548
|
+
this.renderResults([], resultsContainerId, context);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const results = await this.search(trimmedQuery, context);
|
|
553
|
+
this.renderResults(results, resultsContainerId, context);
|
|
554
|
+
}, debounceTime);
|
|
555
|
+
|
|
556
|
+
// Store the handler reference
|
|
557
|
+
const handlerId = `search-handler-${inputId}`;
|
|
558
|
+
if (this.Data[handlerId]) {
|
|
559
|
+
input.removeEventListener('input', this.Data[handlerId]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.Data[handlerId] = (e) => {
|
|
563
|
+
performSearch(e.target.value);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
input.addEventListener('input', this.Data[handlerId]);
|
|
567
|
+
|
|
568
|
+
logger.info(`Setup search input: ${inputId}`);
|
|
569
|
+
|
|
570
|
+
return () => {
|
|
571
|
+
input.removeEventListener('input', this.Data[handlerId]);
|
|
572
|
+
delete this.Data[handlerId];
|
|
573
|
+
};
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Debounces a function call to reduce excessive invocations.
|
|
578
|
+
* Used for search input to prevent searching on every keystroke.
|
|
579
|
+
* @memberof SearchBoxClient.SearchBox
|
|
580
|
+
* @param {Function} func - The function to debounce.
|
|
581
|
+
* @param {number} wait - Delay in milliseconds before invoking the function.
|
|
582
|
+
* @returns {Function} Debounced function that delays invocation.
|
|
583
|
+
*/
|
|
584
|
+
debounce: function (func, wait) {
|
|
585
|
+
let timeout;
|
|
586
|
+
|
|
587
|
+
const later = function (...args) {
|
|
588
|
+
timeout = null;
|
|
589
|
+
func(...args);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
return function (...args) {
|
|
593
|
+
if (timeout) clearTimeout(timeout);
|
|
594
|
+
timeout = setTimeout(() => later(...args), wait);
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Clears all registered search providers.
|
|
600
|
+
* Useful for cleanup or resetting search functionality.
|
|
601
|
+
* @memberof SearchBoxClient.SearchBox
|
|
602
|
+
* @returns {void}
|
|
603
|
+
*/
|
|
604
|
+
clearProviders: function () {
|
|
605
|
+
this.providers = [];
|
|
606
|
+
logger.info('Cleared all search providers');
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Gets base CSS styles for SearchBox with theme-aware styling.
|
|
611
|
+
* Uses subThemeManager colors for consistent theming across light and dark modes.
|
|
612
|
+
* Styles include search result items, icons, tags, and active states.
|
|
613
|
+
* @memberof SearchBoxClient.SearchBox
|
|
614
|
+
* @returns {string} CSS string containing all base SearchBox styles.
|
|
615
|
+
*/
|
|
616
|
+
getBaseStyles: () => {
|
|
617
|
+
// Get theme color from subThemeManager
|
|
618
|
+
const themeColor = darkTheme ? subThemeManager.darkColor : subThemeManager.lightColor;
|
|
619
|
+
const hasThemeColor = themeColor && themeColor !== null;
|
|
620
|
+
|
|
621
|
+
// Calculate theme-based colors
|
|
622
|
+
let activeBg, activeBorder, hoverBg, iconColor, tagBg, tagColor, tagBorder;
|
|
623
|
+
|
|
624
|
+
if (darkTheme) {
|
|
625
|
+
// Dark theme styling - solid white icons for better visibility
|
|
626
|
+
iconColor = '#ffffff';
|
|
627
|
+
if (hasThemeColor) {
|
|
628
|
+
activeBg = darkenHex(themeColor, 0.7);
|
|
629
|
+
activeBorder = lightenHex(themeColor, 0.4);
|
|
630
|
+
hoverBg = `${darkenHex(themeColor, 0.8)}33`; // 20% opacity
|
|
631
|
+
tagBg = darkenHex(themeColor, 0.6);
|
|
632
|
+
tagColor = lightenHex(themeColor, 0.7);
|
|
633
|
+
tagBorder = lightenHex(themeColor, 0.3);
|
|
634
|
+
} else {
|
|
635
|
+
activeBg = '#2a2a2a';
|
|
636
|
+
activeBorder = '#444';
|
|
637
|
+
hoverBg = 'rgba(255, 255, 255, 0.05)';
|
|
638
|
+
tagBg = '#333';
|
|
639
|
+
tagColor = '#aaa';
|
|
640
|
+
tagBorder = '#555';
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
// Light theme styling - solid black icons for better visibility
|
|
644
|
+
iconColor = '#000000';
|
|
645
|
+
if (hasThemeColor) {
|
|
646
|
+
activeBg = lightenHex(themeColor, 0.85);
|
|
647
|
+
activeBorder = lightenHex(themeColor, 0.5);
|
|
648
|
+
hoverBg = `${lightenHex(themeColor, 0.9)}33`; // 20% opacity
|
|
649
|
+
tagBg = lightenHex(themeColor, 0.8);
|
|
650
|
+
tagColor = darkenHex(themeColor, 0.3);
|
|
651
|
+
tagBorder = lightenHex(themeColor, 0.6);
|
|
652
|
+
} else {
|
|
653
|
+
activeBg = '#f0f0f0';
|
|
654
|
+
activeBorder = '#ccc';
|
|
655
|
+
hoverBg = 'rgba(0, 0, 0, 0.05)';
|
|
656
|
+
tagBg = '#e8e8e8';
|
|
657
|
+
tagColor = '#555';
|
|
658
|
+
tagBorder = '#d0d0d0';
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return css`
|
|
663
|
+
/* Search result items - simplified, consistent borders */
|
|
664
|
+
.search-result-item {
|
|
665
|
+
display: flex;
|
|
666
|
+
align-items: center;
|
|
667
|
+
gap: 10px;
|
|
668
|
+
padding: 8px 10px;
|
|
669
|
+
margin: 4px 0;
|
|
670
|
+
cursor: pointer;
|
|
671
|
+
border-radius: 4px;
|
|
672
|
+
transition: all 0.15s ease;
|
|
673
|
+
border: 1px solid transparent;
|
|
674
|
+
background: transparent;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.search-result-item:hover {
|
|
678
|
+
background: ${hoverBg};
|
|
679
|
+
border-color: ${activeBorder}44;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.search-result-item.active-search-result,
|
|
683
|
+
.search-result-item.main-btn-menu-active {
|
|
684
|
+
background: ${activeBg} !important;
|
|
685
|
+
border: 1px solid ${activeBorder} !important;
|
|
686
|
+
box-shadow: 0 0 0 1px ${activeBorder}66 !important;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.search-result-route {
|
|
690
|
+
padding: 3px;
|
|
691
|
+
margin: 2px;
|
|
692
|
+
text-align: left;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.search-result-icon {
|
|
696
|
+
display: flex;
|
|
697
|
+
align-items: center;
|
|
698
|
+
justify-content: center;
|
|
699
|
+
min-width: 24px;
|
|
700
|
+
color: ${iconColor} !important;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.search-result-icon i {
|
|
704
|
+
color: ${iconColor} !important;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.search-result-icon .fa,
|
|
708
|
+
.search-result-icon .fas,
|
|
709
|
+
.search-result-icon .far,
|
|
710
|
+
.search-result-icon .fab {
|
|
711
|
+
color: ${iconColor} !important;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.search-result-icon img {
|
|
715
|
+
width: 25px;
|
|
716
|
+
height: 25px;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.search-result-content {
|
|
720
|
+
flex: 1;
|
|
721
|
+
min-width: 0;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.search-result-title {
|
|
725
|
+
font-size: 14px;
|
|
726
|
+
font-weight: normal;
|
|
727
|
+
margin-bottom: 2px;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.search-result-subtitle {
|
|
731
|
+
font-size: 12px;
|
|
732
|
+
color: ${darkTheme ? '#999' : '#666'};
|
|
733
|
+
margin-top: 2px;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/* Tags/Badges - themed with subThemeManager colors */
|
|
737
|
+
.search-result-tag,
|
|
738
|
+
.search-result-badge {
|
|
739
|
+
display: inline-block;
|
|
740
|
+
padding: 2px 8px;
|
|
741
|
+
margin: 2px 4px 2px 0;
|
|
742
|
+
font-size: 11px;
|
|
743
|
+
border-radius: 3px;
|
|
744
|
+
background: ${tagBg};
|
|
745
|
+
color: ${tagColor};
|
|
746
|
+
border: 1px solid ${tagBorder};
|
|
747
|
+
white-space: nowrap;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.search-result-tags {
|
|
751
|
+
display: flex;
|
|
752
|
+
flex-wrap: wrap;
|
|
753
|
+
gap: 4px;
|
|
754
|
+
margin-top: 4px;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* Active item tags have stronger accent */
|
|
758
|
+
.search-result-item.active-search-result .search-result-tag,
|
|
759
|
+
.search-result-item.active-search-result .search-result-badge {
|
|
760
|
+
background: ${hasThemeColor ? (darkTheme ? darkenHex(themeColor, 0.5) : lightenHex(themeColor, 0.75)) : tagBg};
|
|
761
|
+
border-color: ${activeBorder};
|
|
762
|
+
color: ${hasThemeColor ? (darkTheme ? lightenHex(themeColor, 0.8) : darkenHex(themeColor, 0.4)) : tagColor};
|
|
763
|
+
}
|
|
764
|
+
`;
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Injects base SearchBox styles into the document head.
|
|
769
|
+
* Creates a style tag if it doesn't exist, ensuring styles are loaded once.
|
|
770
|
+
* Automatically called when SearchBox is first used.
|
|
771
|
+
* @memberof SearchBoxClient.SearchBox
|
|
772
|
+
* @returns {void}
|
|
773
|
+
*/
|
|
774
|
+
injectStyles: function () {
|
|
775
|
+
const styleId = 'search-box-base-styles';
|
|
776
|
+
let styleTag = document.getElementById(styleId);
|
|
777
|
+
|
|
778
|
+
if (!styleTag) {
|
|
779
|
+
styleTag = document.createElement('style');
|
|
780
|
+
styleTag.id = styleId;
|
|
781
|
+
document.head.appendChild(styleTag);
|
|
782
|
+
logger.info('Injected SearchBox base styles');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Always update styles (for theme changes and subThemeManager color changes)
|
|
786
|
+
styleTag.textContent = this.getBaseStyles();
|
|
787
|
+
|
|
788
|
+
// Register theme change handler if not already registered
|
|
789
|
+
if (typeof ThemeEvents !== 'undefined' && !ThemeEvents['searchBoxBaseStyles']) {
|
|
790
|
+
ThemeEvents['searchBoxBaseStyles'] = () => {
|
|
791
|
+
const tag = document.getElementById(styleId);
|
|
792
|
+
if (tag) {
|
|
793
|
+
tag.textContent = this.getBaseStyles();
|
|
794
|
+
logger.info('Updated SearchBox styles for theme change');
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
export { SearchBox };
|