Files
meshero.fr/en.search-dialog.js
T
2026-11-08 15:17:21 +01:00

506 lines
16 KiB
JavaScript

// Search command palette UI. Owns the <dialog>, keyboard shortcuts, result
// rendering, and animations. Reads data from `window.hextraSearch` (defined
// in flexsearch.js).
//
(function () {
const resultsFoundTemplate = '%d results found';
const noResultsText = 'No results found.';
const EDITABLE_TAGS = ['INPUT', 'SELECT', 'BUTTON', 'TEXTAREA'];
const isMac = /iPad|iPhone|Macintosh/.test(navigator.userAgent);
let dialog;
let input;
let resultsEl;
let emptyEl;
let statusEl;
let viewportEl;
let innerEl;
let closeAnimationListener = null;
let closeTimer = null;
let collapseTimer = null;
document.addEventListener('DOMContentLoaded', init);
function init() {
dialog = document.getElementById('hextra-search-dialog');
if (!dialog) return;
input = dialog.querySelector('.hextra-search-input');
resultsEl = dialog.querySelector('.hextra-search-results');
emptyEl = dialog.querySelector('[data-search-empty]');
statusEl = dialog.querySelector('.hextra-search-status');
viewportEl = dialog.querySelector('[data-search-viewport]');
innerEl = dialog.querySelector('[data-search-inner]');
if (isMac) {
document.querySelectorAll('.hextra-search-trigger-kbd').forEach(el => {
while (el.firstChild) el.removeChild(el.firstChild);
const cmd = document.createElement('span');
cmd.style.fontSize = '0.75rem';
cmd.textContent = '⌘';
el.appendChild(cmd);
el.appendChild(document.createTextNode('K'));
});
}
document.querySelectorAll('[data-search-open]').forEach(btn => {
btn.addEventListener('click', () => openDialog());
});
const closeBtn = dialog.querySelector('[data-search-close]');
if (closeBtn) closeBtn.addEventListener('click', () => closeDialog());
document.addEventListener('keydown', handleGlobalKeyDown);
input.addEventListener('input', runSearch);
dialog.addEventListener('keydown', handleDialogKeyDown);
// Click on backdrop closes the dialog.
dialog.addEventListener('click', (e) => {
if (e.target === dialog) closeDialog();
});
// Keep the previous query and results when reopening — only flip aria state.
dialog.addEventListener('close', () => {
input.setAttribute('aria-expanded', 'false');
});
// Intercept the native Escape "cancel" so we can play the close animation.
dialog.addEventListener('cancel', (e) => {
e.preventDefault();
closeDialog();
});
// Delegate result clicks so we can close the dialog on navigation.
resultsEl.addEventListener('click', (e) => {
const anchor = e.target.closest('a[role="option"]');
if (anchor) closeDialog();
});
// Hover updates selection like a standard command palette.
resultsEl.addEventListener('mousemove', (e) => {
const anchor = e.target.closest('a[role="option"]');
if (anchor) setActiveOption(anchor, { scroll: false });
});
// Result anchors are in the Tab sequence; keep aria-selected in sync with
// the focused option so the highlighted row matches keyboard focus.
resultsEl.addEventListener('focusin', (e) => {
const anchor = e.target.closest('a[role="option"]');
if (anchor) setActiveOption(anchor, { scroll: false });
});
// Dialog-level keyboard handling keeps input and result navigation in sync.
// Enter on focused result anchors falls through to native anchor activation.
}
function isEditableElement(element) {
const tag = element && element.tagName;
return !!element && (EDITABLE_TAGS.includes(tag) || element.isContentEditable);
}
function setActiveDescendant(id) {
if (id) input.setAttribute('aria-activedescendant', id);
else input.removeAttribute('aria-activedescendant');
}
function handleGlobalKeyDown(e) {
const inEditable = isEditableElement(document.activeElement);
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
if (inEditable) return;
e.preventDefault();
// A dialog mid-dismiss is logically "closed" for toggle purposes — without
// this, a rapid second Cmd+K is eaten by closeDialog's early-return.
const isClosing = dialog.dataset.state === 'closing';
if (dialog.open && !isClosing) closeDialog();
else openDialog();
return;
}
if (dialog.open) return;
if (e.key === '/') {
if (inEditable) return;
e.preventDefault();
openDialog();
}
}
function openDialog() {
// Mid-dismiss? Cancel the close animation and stay open — the in animation
// re-plays so the dialog visibly snaps back instead of completing the fade.
if (dialog.open && dialog.dataset.state === 'closing') {
cancelClose();
input.focus();
input.select();
return;
}
if (dialog.open) return;
dialog.showModal();
input.setAttribute('aria-expanded', 'true');
// showModal autofocuses the first focusable element, but be explicit.
// Select existing query so typing immediately replaces it.
requestAnimationFrame(() => {
input.focus();
input.select();
});
if (window.hextraSearch) window.hextraSearch.preload().catch(() => {});
}
function cancelClose() {
if (closeAnimationListener) {
dialog.removeEventListener('animationend', closeAnimationListener);
closeAnimationListener = null;
}
if (closeTimer !== null) {
clearTimeout(closeTimer);
closeTimer = null;
}
delete dialog.dataset.state;
}
function closeDialog() {
if (!dialog.open || dialog.dataset.state === 'closing') return;
cancelCollapse();
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
dialog.close();
return;
}
dialog.dataset.state = 'closing';
const finalize = () => {
if (closeTimer !== null) {
clearTimeout(closeTimer);
closeTimer = null;
}
if (closeAnimationListener) {
dialog.removeEventListener('animationend', closeAnimationListener);
closeAnimationListener = null;
}
if (dialog.dataset.state === 'closing') {
delete dialog.dataset.state;
dialog.close();
}
};
// The backdrop animation is the longest — wait for it so neither layer
// gets cut off when the dialog is removed from the top layer.
closeAnimationListener = (e) => {
if (e.animationName !== 'hextra-search-backdrop-out') return;
finalize();
};
dialog.addEventListener('animationend', closeAnimationListener);
// animationend on ::backdrop doesn't bubble reliably across browsers
// (Firefox in particular); force-close shortly after the 300ms animation
// so the dialog can't get stranded.
closeTimer = setTimeout(finalize, 400);
}
function setViewportExpanded(expanded) {
if (!viewportEl) return;
viewportEl.dataset.expanded = expanded ? 'true' : 'false';
}
function cancelCollapse() {
if (collapseTimer !== null) {
clearTimeout(collapseTimer);
collapseTimer = null;
}
}
function clearResults() {
while (resultsEl.firstChild) resultsEl.removeChild(resultsEl.firstChild);
}
function handleDialogKeyDown(e) {
const anchor = e.target.closest('a[role="option"]');
const fromInput = e.target === input;
if (!fromInput && !anchor) return;
// During IME composition the user is still selecting a candidate; Enter
// commits the candidate (not the result), arrow keys cycle candidates.
if (fromInput && (e.isComposing || e.keyCode === 229)) return;
const focusResults = !!anchor;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
moveSelection(1, { current: anchor, focus: focusResults });
break;
case 'ArrowUp':
e.preventDefault();
moveSelection(-1, { current: anchor, focus: focusResults });
break;
case 'Home': {
const all = getOptions();
if (!all.length) return;
e.preventDefault();
selectOption(all[0], { focus: focusResults });
break;
}
case 'End': {
const all = getOptions();
if (!all.length) return;
e.preventDefault();
selectOption(all[all.length - 1], { focus: focusResults });
break;
}
case 'Enter': {
if (!fromInput) return;
const opt = getActiveOption();
if (!opt) return;
e.preventDefault();
opt.click();
break;
}
case 'Escape':
// input[type=search] clears its value on Escape and swallows the event,
// so close the dialog explicitly here.
e.preventDefault();
closeDialog();
break;
}
}
function getOptions() {
return Array.from(resultsEl.querySelectorAll('a[role="option"]'));
}
function getActiveOption() {
return resultsEl.querySelector('a[aria-selected="true"]');
}
function setActiveOption(el, opts) {
const scroll = !opts || opts.scroll !== false;
getOptions().forEach((o) => {
o.setAttribute('aria-selected', o === el ? 'true' : 'false');
});
if (el) {
setActiveDescendant(el.id);
if (scroll) el.scrollIntoView({ block: 'nearest' });
} else {
setActiveDescendant();
}
}
function focusOption(el) {
setActiveOption(el, { scroll: false });
el.focus({ preventScroll: true });
el.scrollIntoView({ block: 'nearest' });
}
function selectOption(el, opts) {
if (opts && opts.focus) focusOption(el);
else setActiveOption(el);
}
function moveSelection(delta, opts) {
const options = getOptions();
if (!options.length) return;
const current = (opts && opts.current) || getActiveOption();
let i = options.indexOf(current);
if (i === -1) {
i = delta > 0 ? 0 : options.length - 1;
} else {
i = (i + delta + options.length) % options.length;
}
selectOption(options[i], opts);
}
async function runSearch(e) {
if (e && e.isComposing) return;
const query = input.value.trim();
if (!query) {
setActiveDescendant();
if (statusEl) statusEl.textContent = '';
setViewportExpanded(false);
// Defer DOM clear until the viewport finishes collapsing — otherwise the
// results vanish first and the viewport snaps to the inner padding before
// animating the last few pixels to 0.
cancelCollapse();
collapseTimer = setTimeout(() => {
collapseTimer = null;
clearResults();
if (emptyEl) emptyEl.hidden = true;
}, 250);
return;
}
if (!window.hextraSearch) return;
let results;
try {
results = await window.hextraSearch.search(query);
} catch (err) {
// Stale failure for an old query — ignore. Otherwise log and fall
// through to an empty render so the user sees feedback instead of stale
// results.
if (input.value.trim() !== query) return;
console.warn('[hextra-search]', err);
results = [];
}
// Drop stale results if the input changed during the await.
if (input.value.trim() !== query) return;
renderResults(results, query);
}
function appendHighlightedText(container, text, query) {
if (!text) return;
if (!query) {
container.textContent = text;
return;
}
const escapedQuery = query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
if (!escapedQuery) {
container.textContent = text;
return;
}
const regex = new RegExp(escapedQuery, 'gi');
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
container.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const span = document.createElement('span');
span.className = 'hextra-search-match';
span.textContent = match[0];
container.appendChild(span);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
container.appendChild(document.createTextNode(text.slice(lastIndex)));
}
}
function createBreadcrumbSeparator() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('hextra-search-crumb-separator');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'm9 18 6-6-6-6');
svg.appendChild(path);
return svg;
}
function appendBreadcrumb(container, parts) {
for (let i = 0; i < parts.length; i++) {
if (i > 0) container.appendChild(createBreadcrumbSeparator());
const part = document.createElement('span');
part.textContent = parts[i];
container.appendChild(part);
}
}
function renderResults(results, query) {
cancelCollapse();
clearResults();
if (innerEl) innerEl.scrollTop = 0;
if (!results.length) {
if (emptyEl) emptyEl.hidden = false;
if (statusEl) statusEl.textContent = noResultsText;
setActiveDescendant();
setViewportExpanded(true);
return;
}
if (emptyEl) emptyEl.hidden = true;
const fragment = document.createDocumentFragment();
let isFirstOption = true;
let optionCount = 0;
for (let i = 0; i < results.length; i++) {
const result = results[i];
const li = document.createElement('li');
li.setAttribute('role', 'presentation');
const link = document.createElement('a');
link.id = result.id;
link.href = result.route;
link.setAttribute('role', 'option');
link.setAttribute('aria-selected', isFirstOption ? 'true' : 'false');
isFirstOption = false;
optionCount++;
if (result.breadcrumbs && result.breadcrumbs.length) {
const crumb = document.createElement('div');
crumb.className = 'hextra-search-crumb';
crumb.setAttribute('aria-label', result.breadcrumbs.join(' > '));
appendBreadcrumb(crumb, result.breadcrumbs);
link.appendChild(crumb);
}
const title = document.createElement('div');
title.className = 'hextra-search-title';
appendHighlightedText(title, result.title, query);
link.appendChild(title);
li.appendChild(link);
fragment.appendChild(li);
for (let j = 0; j < result.matches.length; j++) {
const match = result.matches[j];
const hasExcerpt = match.content && match.content !== match.title;
if (match.title === result.title && !hasExcerpt) continue;
const excerptLi = document.createElement('li');
excerptLi.setAttribute('role', 'presentation');
excerptLi.classList.add('hextra-search-child');
const excerptLink = document.createElement('a');
excerptLink.id = match.id;
excerptLink.href = match.route;
excerptLink.setAttribute('role', 'option');
excerptLink.setAttribute('aria-selected', isFirstOption ? 'true' : 'false');
isFirstOption = false;
optionCount++;
const label = hasExcerpt ? `${match.title} - ${match.content}` : match.title;
if (label) excerptLink.setAttribute('aria-label', label);
if (match.title && match.title !== result.title) {
const title = document.createElement('div');
title.className = 'hextra-search-title';
appendHighlightedText(title, match.title, query);
excerptLink.appendChild(title);
}
if (hasExcerpt) {
const excerpt = document.createElement('div');
excerpt.className = 'hextra-search-excerpt';
appendHighlightedText(excerpt, match.content, query);
excerptLink.appendChild(excerpt);
}
excerptLi.appendChild(excerptLink);
fragment.appendChild(excerptLi);
}
}
resultsEl.appendChild(fragment);
const firstOption = resultsEl.querySelector('a[role="option"]');
setActiveDescendant(firstOption && firstOption.id);
if (statusEl) {
statusEl.textContent = resultsFoundTemplate.replace('%d', optionCount.toString());
}
setViewportExpanded(true);
}
})();