little refactore

This commit is contained in:
2026-04-01 16:31:59 +02:00
parent e97d1ddd31
commit 83b65812d0
12 changed files with 942 additions and 748 deletions

View File

@@ -244,7 +244,7 @@ const bootRuntime = async () => {
// Phase C: high-risk (search, side panel interactions).
runtime.register('global-search', initGlobalSearch, {
selector: '[data-app-component="global-search"]',
selector: '[data-app-search-dialog]',
configPath: 'components.globalSearch',
});

View File

@@ -1,218 +1,324 @@
/**
* Global search sidebar — debounced fetch with live result counts and Ctrl+K shortcut.
* Global search command-palette dialog.
*
* Opens via Ctrl+K / Cmd+K or topbar trigger. Fetches live results from the
* preview API and renders them grouped by category with keyboard navigation.
*/
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { isEditableTarget } from '../core/app-form-utils.js';
import { requestAsidePanelOpen } from '../core/app-ui-channels.js';
const ICON_MAP = {
'users': 'bi-person-badge',
'tenants': 'bi-buildings',
'departments': 'bi-diagram-3',
'roles': 'bi-people',
'permissions': 'bi-shield-lock',
'scheduled-jobs': 'bi-calendar-check',
'pages': 'bi-file-text',
'mail-log': 'bi-envelope-paper',
'docs': 'bi-journal-text',
'hotkeys': 'bi-keyboard',
'contacts': 'bi-person-lines-fill',
'bookmarks': 'bi-bookmark',
'api-audit': 'bi-journal-code',
'system-audit': 'bi-journal-check',
};
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 dialog = host.matches?.('[data-app-search-dialog]') ? host : host.querySelector('[data-app-search-dialog]');
if (!dialog) {return { destroy: () => {} };}
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;
if (dialog.dataset.globalSearchBound === '1' && dialog._globalSearchApi) {
return dialog._globalSearchApi;
}
input.dataset.globalSearchBound = '1';
dialog.dataset.globalSearchBound = '1';
const input = dialog.querySelector('#search-dialog-input');
const resultsEl = dialog.querySelector('[data-search-dialog-results]');
const emptyStateEl = dialog.querySelector('[data-search-dialog-empty]');
const loadingEl = dialog.querySelector('[data-search-dialog-loading]');
const errorEl = dialog.querySelector('[data-search-dialog-error]');
const footerEl = dialog.querySelector('[data-search-dialog-footer]');
const viewAllLink = dialog.querySelector('[data-search-dialog-view-all]');
const closeButton = dialog.querySelector('[data-app-search-dialog-close]');
const shortcutEl = dialog.querySelector('[data-search-shortcut]');
const emptyHintEl = dialog.querySelector('[data-search-empty-hint-template]');
const triggerButton = document.querySelector('[data-app-search-trigger]');
const triggerShortcutEl = document.querySelector('[data-search-trigger-shortcut]');
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 endpointAttr = dialog.getAttribute('data-search-endpoint') || 'admin/search/data';
const resultsPageAttr = dialog.getAttribute('data-search-results-page') || 'search';
const endpoint = new URL(endpointAttr, appBase);
const resultsPage = new URL(resultsPageAttr, appBase);
const minLength = 2;
let pending = null;
let activeIndex = -1;
let flatItems = [];
const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent)
? '⌘K'
: 'Ctrl+K';
if (shortcutEl) {shortcutEl.textContent = shortcutLabel;}
if (triggerShortcutEl) {triggerShortcutEl.textContent = shortcutLabel;}
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' });
}
// --- State management ---
const setLoadingState = (value) => {
resultsEl.querySelectorAll('[data-search-count]').forEach((el) => {
el.textContent = value;
const setState = (state) => {
if (emptyStateEl) {emptyStateEl.hidden = state !== 'empty';}
if (loadingEl) {loadingEl.hidden = state !== 'loading';}
if (errorEl) {errorEl.hidden = state !== 'error';}
if (resultsEl) {resultsEl.hidden = state !== 'success';}
if (footerEl) {footerEl.hidden = state !== 'success';}
};
const clearHighlight = () => {
activeIndex = -1;
resultsEl.querySelectorAll('[role="option"]').forEach((el) => {
el.removeAttribute('aria-selected');
el.classList.remove('app-search-dialog-item-active');
});
if (input) {input.removeAttribute('aria-activedescendant');}
};
const setIdleState = (idle) => {
if (emptyStateEl) {
emptyStateEl.hidden = !idle;
}
resultsEl.hidden = idle;
const setHighlight = (index) => {
clearHighlight();
if (index < 0 || index >= flatItems.length) {return;}
activeIndex = index;
const item = flatItems[index];
item.setAttribute('aria-selected', 'true');
item.classList.add('app-search-dialog-item-active');
item.scrollIntoView({ block: 'nearest' });
if (input) {input.setAttribute('aria-activedescendant', item.id);}
};
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;
// --- Rendering ---
const iconForKey = (key) => ICON_MAP[key] || 'bi-search';
const render = (categories, query) => {
resultsEl.innerHTML = '';
flatItems = [];
let itemIndex = 0;
let hasResults = false;
categories.forEach((category) => {
if (category.count === 0 && (!category.items || category.items.length === 0)) {return;}
hasResults = true;
const group = document.createElement('li');
group.setAttribute('role', 'presentation');
group.classList.add('app-search-dialog-category');
const heading = document.createElement('div');
heading.classList.add('app-search-dialog-category-heading');
heading.innerHTML = `<i class="bi ${iconForKey(category.key)}" aria-hidden="true"></i> <span>${escapeHtml(category.label || category.key)}</span> <span class="app-search-dialog-category-count">${category.count}</span>`;
group.appendChild(heading);
if (Array.isArray(category.items) && category.items.length > 0) {
const list = document.createElement('ul');
list.classList.add('app-search-dialog-category-items');
list.setAttribute('role', 'group');
category.items.forEach((item) => {
if (!item || !item.url) {return;}
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('id', `search-item-${itemIndex}`);
li.classList.add('app-search-dialog-item');
li.dataset.searchUrl = item.url;
const link = document.createElement('a');
link.href = item.url;
link.textContent = item.label || '';
link.tabIndex = -1;
li.appendChild(link);
list.appendChild(li);
flatItems.push(li);
itemIndex++;
});
group.appendChild(list);
}
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') {
if (category.url && category.count > 0) {
const viewCategoryLink = document.createElement('a');
viewCategoryLink.classList.add('app-search-dialog-category-view-all');
viewCategoryLink.href = category.url;
viewCategoryLink.innerHTML = `${escapeHtml(category.label || category.key)} <i class="bi bi-arrow-right" aria-hidden="true"></i>`;
group.appendChild(viewCategoryLink);
}
resultsEl.appendChild(group);
});
if (hasResults) {
setState('success');
if (viewAllLink) {
const url = new URL(resultsPage.toString());
url.searchParams.set('search', query);
viewAllLink.href = url.toString();
}
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);
});
} else {
setState('empty');
if (emptyStateEl) {
const p = emptyStateEl.querySelector('p');
if (p) {
p.textContent = emptyStateEl.dataset.noResultsText || '';
}
}
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;
});
}
clearHighlight();
};
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;
});
};
// --- Fetch ---
const fetchCounts = async (value) => {
setIdleState(false);
const fetchResults = async (value) => {
setState('loading');
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;
try {
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' });
setState('error');
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 });
setState('error');
}
} catch (err) {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
setState('error');
}
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();
};
// --- Dialog open/close ---
const openDialog = () => {
if (dialog.open) {return;}
dialog.showModal();
setState('empty');
if (emptyStateEl) {
const p = emptyStateEl.querySelector('p');
if (p) {
p.textContent = emptyHintEl
? ''
: (dialog.dataset.emptyText || '');
}
}
resetEmptyState();
window.requestAnimationFrame(() => {
if (input) {
input.focus();
input.select();
}
});
// If input already has a value, trigger search
const currentValue = input ? input.value.trim() : '';
if (currentValue.length >= minLength) {
fetchResults(currentValue);
}
};
const closeDialog = () => {
if (!dialog.open) {return;}
dialog.close();
clearHighlight();
};
const resetEmptyState = () => {
if (!emptyStateEl) {return;}
const p = emptyStateEl.querySelector('p');
if (p) {
p.textContent = p.dataset.originalText || '';
}
};
// Store original text for empty state
if (emptyStateEl) {
const p = emptyStateEl.querySelector('p');
if (p) {p.dataset.originalText = p.textContent;}
}
// --- Navigation ---
const navigateToItem = (index) => {
if (index < 0 || index >= flatItems.length) {return;}
const item = flatItems[index];
const url = item.dataset.searchUrl || item.querySelector('a')?.href;
if (url) {
closeDialog();
window.location.href = url;
}
};
const submitSearch = () => {
const value = input.value.trim();
const value = input ? input.value.trim() : '';
if (value.length < minLength) {return;}
resultsPage.searchParams.set('search', value);
window.location.href = resultsPage.toString();
const url = new URL(resultsPage.toString());
url.searchParams.set('search', value);
closeDialog();
window.location.href = url.toString();
};
const focusSearch = () => {
window.requestAnimationFrame(() => {
input.focus();
input.select();
});
};
const openSearch = () => {
requestAsidePanelOpen('search', { fromUser: true });
focusSearch();
};
// --- Event handlers (named for cleanup) ---
// --- Event handlers ---
const onInput = () => {
const value = input.value.trim();
if (value.length < minLength) {
clear();
setState('empty');
resetEmptyState();
resultsEl.innerHTML = '';
flatItems = [];
clearHighlight();
if (footerEl) {footerEl.hidden = true;}
return;
}
if (pending) {window.clearTimeout(pending);}
pending = window.setTimeout(() => {
fetchCounts(value).catch((err) => {
fetchResults(value).catch((err) => {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
clear();
setState('error');
});
}, 300);
};
const onInputKeydown = (event) => {
if (event.key !== 'Enter') {return;}
event.preventDefault();
submitSearch();
if (event.key === 'ArrowDown') {
event.preventDefault();
if (flatItems.length === 0) {return;}
setHighlight(activeIndex < flatItems.length - 1 ? activeIndex + 1 : 0);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (flatItems.length === 0) {return;}
setHighlight(activeIndex > 0 ? activeIndex - 1 : flatItems.length - 1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (activeIndex >= 0) {
navigateToItem(activeIndex);
} else {
submitSearch();
}
return;
}
};
const onDocumentKeydown = (event) => {
@@ -221,83 +327,86 @@ export function initGlobalSearch(root = document, options = {}) {
if (key !== 'k' && key !== '/') {return;}
if (isEditableTarget(event.target) && event.target !== input) {return;}
event.preventDefault();
openSearch();
openDialog();
};
const onEmptyStateClick = () => {
focusSearch();
const onCloseClick = () => {
closeDialog();
};
const onTriggerClick = () => {
openDialog();
};
const onDialogClick = (event) => {
// Close on backdrop click (click on <dialog> itself, not its children)
if (event.target === dialog) {
closeDialog();
}
};
const onResultClick = (event) => {
const item = event.target.closest('[role="option"]');
if (!item) {return;}
const link = item.querySelector('a');
if (link && event.target !== link) {
closeDialog();
window.location.href = link.href;
event.preventDefault();
} else if (link) {
closeDialog();
}
};
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');
if (activeIndex >= 0) {
navigateToItem(activeIndex);
} else {
submitSearch();
}
};
// --- Bind listeners ---
input.addEventListener('input', onInput);
input.addEventListener('keydown', onInputKeydown);
if (input) {
input.addEventListener('input', onInput);
input.addEventListener('keydown', onInputKeydown);
}
document.addEventListener('keydown', onDocumentKeydown);
if (closeButton) {closeButton.addEventListener('click', onCloseClick);}
if (triggerButton) {triggerButton.addEventListener('click', onTriggerClick);}
dialog.addEventListener('click', onDialogClick);
if (resultsEl) {resultsEl.addEventListener('click', onResultClick);}
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();
}
const form = input ? input.closest('form') : null;
if (form) {form.addEventListener('submit', onFormSubmit);}
// --- Cleanup ---
const destroy = () => {
if (pending) {window.clearTimeout(pending);}
input.removeEventListener('input', onInput);
input.removeEventListener('keydown', onInputKeydown);
if (input) {
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;
if (closeButton) {closeButton.removeEventListener('click', onCloseClick);}
if (triggerButton) {triggerButton.removeEventListener('click', onTriggerClick);}
dialog.removeEventListener('click', onDialogClick);
if (resultsEl) {resultsEl.removeEventListener('click', onResultClick);}
if (form) {form.removeEventListener('submit', onFormSubmit);}
delete dialog.dataset.globalSearchBound;
delete dialog._globalSearchApi;
};
const api = { destroy, openSearch, focusSearch };
input._globalSearchApi = api;
const api = { destroy, openDialog, closeDialog };
dialog._globalSearchApi = api;
return api;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}