@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.
@@ -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 };