feat(search): refresh global search UI and align qa/docs updates
This commit is contained in:
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user