feat(search): refresh global search UI and align qa/docs updates

This commit is contained in:
2026-04-02 10:49:35 +02:00
parent 5699bc6c5b
commit 5d07236758
35 changed files with 504 additions and 213 deletions

View File

@@ -42,8 +42,6 @@ export function initGlobalSearch(root = document, options = {}) {
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]');
@@ -57,17 +55,13 @@ export function initGlobalSearch(root = document, options = {}) {
let pending = null;
let activeIndex = -1;
let flatItems = [];
let abortController = null;
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);
}
// --- State management ---
@@ -119,14 +113,14 @@ export function initGlobalSearch(root = document, options = {}) {
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>`;
heading.textContent = category.label || category.key;
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');
const list = document.createElement('ul');
list.classList.add('app-search-dialog-category-items');
list.setAttribute('role', 'group');
if (Array.isArray(category.items) && category.items.length > 0) {
category.items.forEach((item) => {
if (!item || !item.url) {return;}
const li = document.createElement('li');
@@ -137,26 +131,62 @@ export function initGlobalSearch(root = document, options = {}) {
const link = document.createElement('a');
link.href = item.url;
link.textContent = item.label || '';
link.tabIndex = -1;
const itemIcon = item.icon || iconForKey(category.key);
const icon = document.createElement('i');
icon.className = `bi ${safeCssClass(itemIcon)} app-search-dialog-item-icon`;
icon.setAttribute('aria-hidden', 'true');
link.appendChild(icon);
const textWrap = document.createElement('span');
textWrap.classList.add('app-search-dialog-item-text');
const titleEl = document.createElement('span');
titleEl.classList.add('app-search-dialog-item-title');
highlightText(titleEl, item.label || '', query);
textWrap.appendChild(titleEl);
if (item.subtitle) {
const subtitleEl = document.createElement('span');
subtitleEl.classList.add('app-search-dialog-item-subtitle');
highlightText(subtitleEl, item.subtitle, query);
textWrap.appendChild(subtitleEl);
}
link.appendChild(textWrap);
li.appendChild(link);
list.appendChild(li);
flatItems.push(li);
itemIndex++;
});
group.appendChild(list);
}
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);
if (category.url && category.count > (category.items?.length || 0)) {
const viewLi = document.createElement('li');
viewLi.classList.add('app-search-dialog-item', 'app-search-dialog-item-view-all');
const viewLink = document.createElement('a');
viewLink.href = category.url;
viewLink.tabIndex = -1;
const viewIcon = document.createElement('i');
viewIcon.className = 'bi bi-arrow-right app-search-dialog-item-icon';
viewIcon.setAttribute('aria-hidden', 'true');
viewLink.appendChild(viewIcon);
const viewText = document.createElement('span');
viewText.classList.add('app-search-dialog-item-text');
const viewTitle = document.createElement('span');
viewTitle.classList.add('app-search-dialog-item-title');
viewTitle.textContent = `${category.label || category.key}${category.count}`;
viewText.appendChild(viewTitle);
viewLink.appendChild(viewText);
viewLi.appendChild(viewLink);
list.appendChild(viewLi);
}
group.appendChild(list);
resultsEl.appendChild(group);
});
@@ -182,12 +212,16 @@ export function initGlobalSearch(root = document, options = {}) {
// --- Fetch ---
const fetchResults = async (value) => {
if (abortController) {abortController.abort();}
abortController = new AbortController();
const { signal } = abortController;
setState('loading');
const url = new URL(endpoint.toString());
url.searchParams.set('q', value);
try {
const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' }, signal });
if (!response.ok) {
warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, { module: 'global-search' });
setState('error');
@@ -201,6 +235,7 @@ export function initGlobalSearch(root = document, options = {}) {
setState('error');
}
} catch (err) {
if (err.name === 'AbortError') {return;}
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
setState('error');
}
@@ -212,14 +247,6 @@ export function initGlobalSearch(root = document, options = {}) {
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) {
@@ -280,6 +307,8 @@ export function initGlobalSearch(root = document, options = {}) {
const onInput = () => {
const value = input.value.trim();
if (value.length < minLength) {
if (pending) {window.clearTimeout(pending); pending = null;}
if (abortController) {abortController.abort(); abortController = null;}
setState('empty');
resetEmptyState();
resultsEl.innerHTML = '';
@@ -386,6 +415,7 @@ export function initGlobalSearch(root = document, options = {}) {
const destroy = () => {
if (pending) {window.clearTimeout(pending);}
if (abortController) {abortController.abort();}
if (input) {
input.removeEventListener('input', onInput);
input.removeEventListener('keydown', onInputKeydown);
@@ -410,3 +440,30 @@ function escapeHtml(text) {
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
function safeCssClass(value) {
return String(value).replace(/[^a-z0-9_-]/gi, '');
}
function highlightText(el, text, query) {
if (!query || query.length < 2) {
el.textContent = text;
return;
}
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(new RegExp(`(${escaped})`, 'gi'));
if (parts.length === 1) {
el.textContent = text;
return;
}
const queryLower = query.toLowerCase();
parts.forEach((part) => {
if (part.toLowerCase() === queryLower) {
const mark = document.createElement('mark');
mark.textContent = part;
el.appendChild(mark);
} else if (part !== '') {
el.appendChild(document.createTextNode(part));
}
});
}