Files
breadcrumb-the-shire/web/js/components/app-global-search.js
fs c7b8fd516a feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00

304 lines
9.9 KiB
JavaScript

/**
* Global search sidebar — debounced fetch with live result counts and Ctrl+K shortcut.
*/
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { isEditableTarget } from '../core/app-form-utils.js';
import { requestAsidePanelOpen } from '../core/app-ui-channels.js';
export function initGlobalSearch(root = document, options = {}) {
const host = resolveHost(root);
const inputSelector = String(options.inputSelector || '#side-search').trim() || '#side-search';
const resultsSelector = String(options.resultsSelector || '[data-global-search-results]').trim() || '[data-global-search-results]';
const input = host.matches?.(inputSelector) ? host : host.querySelector(inputSelector);
const resultsEl = host.matches?.(resultsSelector) ? host : host.querySelector(resultsSelector);
if (!input || !resultsEl) {return { destroy: () => {} };}
if (input.dataset.globalSearchBound === '1' && input._globalSearchApi) {
return input._globalSearchApi;
}
input.dataset.globalSearchBound = '1';
const emptyStateEl = host.querySelector('[data-global-search-empty]');
const emptyHintEl = host.querySelector('[data-search-empty-hint-template]');
const shortcutEl = host.querySelector('[data-search-shortcut]');
const panel = host.matches?.('#aside-panel-search') ? host : host.querySelector('#aside-panel-search');
const toggleButton = host.querySelector('[data-search-details-toggle]');
const toggleIcon = host.querySelector('[data-search-details-icon]');
const appBase = String(options.appBase || document.baseURI || window.location.origin).trim() || document.baseURI;
const endpoint = new URL('admin/search/data', appBase);
const resultsPage = new URL('search', appBase);
const minLength = 2;
let pending = null;
const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent)
? '⌘K'
: 'Ctrl+K';
if (emptyHintEl) {
const template = emptyHintEl.getAttribute('data-search-empty-hint-template') || '';
emptyHintEl.textContent = template.replace('{shortcut}', shortcutLabel);
}
if (shortcutEl) {
shortcutEl.textContent = shortcutLabel;
}
const itemsByKey = new Map();
resultsEl.querySelectorAll('[data-search-key]').forEach((item) => {
const key = item.getAttribute('data-search-key');
if (key) {itemsByKey.set(key, item);}
});
if (itemsByKey.size === 0) {
warnOnce('UI_EL_MISSING', 'No [data-search-key] elements found in results container', { module: 'global-search' });
}
const setLoadingState = (value) => {
resultsEl.querySelectorAll('[data-search-count]').forEach((el) => {
el.textContent = value;
});
};
const setIdleState = (idle) => {
if (emptyStateEl) {
emptyStateEl.hidden = !idle;
}
resultsEl.hidden = idle;
};
const render = (items, query) => {
const renderedKeys = new Set();
items.forEach((item) => {
const li = itemsByKey.get(item.key);
if (!li) {
warnOnce('UI_EL_MISSING', `No element found for key: ${item.key}`, { module: 'global-search' });
return;
}
renderedKeys.add(item.key);
const countEl = li.querySelector('[data-search-count]');
const link = li.querySelector('a');
const previewEl = li.querySelector('[data-search-preview]');
const base = item.key === 'pages' && item.url ? item.url : (li.getAttribute('data-search-base') || item.url);
const url = new URL(base, appBase);
if (item.key !== 'pages') {
url.searchParams.set('search', query);
}
if (countEl) {countEl.textContent = item.count.toString();}
if (link) {
link.href = item.count > 0 ? url.toString() : base;
link.setAttribute('aria-disabled', item.count > 0 ? 'false' : 'true');
}
if (previewEl) {
previewEl.innerHTML = '';
if (Array.isArray(item.items) && item.items.length > 0) {
item.items.forEach((previewItem, idx) => {
if (!previewItem || !previewItem.url) {
warnOnce('UI_DATA_MISSING', `Preview item missing url at index ${idx} for key: ${item.key}`, {
module: 'global-search',
item: previewItem
});
return;
}
const previewLi = document.createElement('li');
const previewLink = document.createElement('a');
previewLink.href = previewItem.url;
previewLink.textContent = previewItem.label || '';
previewLi.appendChild(previewLink);
previewEl.appendChild(previewLi);
});
}
}
if (item.count > 0) {
li.hidden = false;
} else {
li.hidden = true;
}
});
itemsByKey.forEach((li, key) => {
if (renderedKeys.has(key)) {
return;
}
const countEl = li.querySelector('[data-search-count]');
const link = li.querySelector('a');
const previewEl = li.querySelector('[data-search-preview]');
const base = li.getAttribute('data-search-base') || '#';
if (countEl) {countEl.textContent = '0';}
if (link) {
link.href = base;
link.setAttribute('aria-disabled', 'true');
}
if (previewEl) {
previewEl.innerHTML = '';
}
li.hidden = true;
});
};
const clear = () => {
setIdleState(true);
setLoadingState('0');
resultsEl.querySelectorAll('a').forEach((link) => {
link.setAttribute('aria-disabled', 'true');
const base = link.closest('[data-search-base]')?.getAttribute('data-search-base');
link.setAttribute('href', base || '#');
});
resultsEl.querySelectorAll('[data-search-preview]').forEach((list) => {
list.innerHTML = '';
});
resultsEl.querySelectorAll('[data-search-key]').forEach((li) => {
li.hidden = false;
});
};
const fetchCounts = async (value) => {
setIdleState(false);
const url = new URL(endpoint.toString());
url.searchParams.set('q', value);
setLoadingState('…');
const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
if (!response.ok) {
warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, {
module: 'global-search'
});
clear();
return;
}
const data = await response.json();
if (Array.isArray(data)) {
render(data, value);
} else {
warnOnce('FETCH_INVALID', `Invalid response format for global search`, {
module: 'global-search',
type: typeof data
});
clear();
}
};
const submitSearch = () => {
const value = input.value.trim();
if (value.length < minLength) {return;}
resultsPage.searchParams.set('search', value);
window.location.href = resultsPage.toString();
};
const focusSearch = () => {
window.requestAnimationFrame(() => {
input.focus();
input.select();
});
};
const openSearch = () => {
requestAsidePanelOpen('search', { fromUser: true });
focusSearch();
};
// --- Event handlers (named for cleanup) ---
const onInput = () => {
const value = input.value.trim();
if (value.length < minLength) {
clear();
return;
}
if (pending) {window.clearTimeout(pending);}
pending = window.setTimeout(() => {
fetchCounts(value).catch((err) => {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
clear();
});
}, 300);
};
const onInputKeydown = (event) => {
if (event.key !== 'Enter') {return;}
event.preventDefault();
submitSearch();
};
const onDocumentKeydown = (event) => {
if (!(event.metaKey || event.ctrlKey)) {return;}
const key = event.key.toLowerCase();
if (key !== 'k' && key !== '/') {return;}
if (isEditableTarget(event.target) && event.target !== input) {return;}
event.preventDefault();
openSearch();
};
const onEmptyStateClick = () => {
focusSearch();
};
const onFormSubmit = (event) => {
event.preventDefault();
submitSearch();
};
const onToggleClick = () => {
panel.classList.toggle('search-details-hidden');
if (toggleIcon) {
toggleIcon.classList.toggle('bi-dash-square');
toggleIcon.classList.toggle('bi-plus-square');
}
};
// --- Bind listeners ---
input.addEventListener('input', onInput);
input.addEventListener('keydown', onInputKeydown);
document.addEventListener('keydown', onDocumentKeydown);
if (emptyStateEl) {
emptyStateEl.addEventListener('click', onEmptyStateClick);
}
const form = input.closest('form');
if (form) {
form.addEventListener('submit', onFormSubmit);
}
if (toggleButton && panel instanceof HTMLElement) {
toggleButton.addEventListener('click', onToggleClick);
}
// Seed from current URL search param if present.
const currentUrl = new URL(window.location.href);
const preset = (currentUrl.searchParams.get('search') || '').trim();
if (preset && !input.value) {
input.value = preset;
}
const initialValue = input.value.trim();
if (initialValue.length >= minLength) {
fetchCounts(initialValue).catch((err) => {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
clear();
});
} else {
clear();
}
// --- Cleanup ---
const destroy = () => {
if (pending) {window.clearTimeout(pending);}
input.removeEventListener('input', onInput);
input.removeEventListener('keydown', onInputKeydown);
document.removeEventListener('keydown', onDocumentKeydown);
if (emptyStateEl) {
emptyStateEl.removeEventListener('click', onEmptyStateClick);
}
if (form) {
form.removeEventListener('submit', onFormSubmit);
}
if (toggleButton && panel instanceof HTMLElement) {
toggleButton.removeEventListener('click', onToggleClick);
}
delete input.dataset.globalSearchBound;
delete input._globalSearchApi;
};
const api = { destroy, openSearch, focusSearch };
input._globalSearchApi = api;
return api;
}