1
0
mirror of https://github.com/php/web-php.git synced 2026-03-23 23:02:13 +01:00

Update navbar design and improve search UI (#1084)

Co-authored-by: Gina Peter Banyard <girgias@php.net>
Co-authored-by: Sergey Panteleev <sergey@php.net>
This commit is contained in:
Lucas Azevedo
2024-11-02 11:39:04 -03:00
committed by GitHub
parent 2f24f8eef1
commit b62f99f6de
31 changed files with 1671 additions and 774 deletions

View File

@@ -10,10 +10,11 @@ String.prototype.toInt = function () {
var PHP_NET = {};
PHP_NET.HEADER_HEIGHT = 52;
PHP_NET.HEADER_HEIGHT = 64;
Mousetrap.bind('up up down down left right left right b a enter', function () {
$(".brand img").attr("src", "/images/php_konami.gif");
$(".navbar__brand img").attr("src", "/images/php_konami.gif");
window.scrollTo(0, 0);
});
Mousetrap.bind("?", function () {
$("#trick").slideToggle();
@@ -100,12 +101,10 @@ Mousetrap.bind("b o r k", function () {
Mousetrap.unbind("b o r k");
});
var FIXED_HEADER_HEIGHT = 50;
function cycle(to, from) {
from.removeClass("current");
to.addClass("current");
$.scrollTo(to.offset().top - FIXED_HEADER_HEIGHT);
$.scrollTo(to.offset().top);
}
function getNextOrPreviousSibling(node, forward) {
@@ -248,33 +247,31 @@ function globalsearch(txt) {
return;
}
var key = "search-en";
var cache = window.localStorage.getItem(key);
const language = getLanguage()
const key = `search-${language}`;
let cache = window.localStorage.getItem(key);
cache = JSON.parse(cache);
if (cache) {
for (var type in cache.data) {
var elms = cache.data[type].elements;
for (var node in elms) {
if (elms[node].description.toLowerCase().contains(term) || elms[node].name.toLowerCase().contains(term)) {
$("#goto .results ul").append("<li><a href='/manual/en/" + elms[node].id + ".php'>" + elms[node].name + ": " + elms[node].description + "</a></li>");
if ($("#goto .results ul li") > 30) {
return;
}
for (const node of cache.data) {
if (
node.description.toLowerCase().contains(term) ||
node.name.toLowerCase().contains(term)
) {
$("#goto .results ul").append(`
<li>
<a href='/manual/${language}/${node.id}.php'>
${node.name}: ${node.description}
</a>
</li>`);
if ($("#goto .results ul li") > 30) {
return;
}
}
}
}
}
Mousetrap.bind("/", function (e) {
if (e.preventDefault) {
e.preventDefault();
} else {
// internet explorer
e.returnValue = false;
}
$("input[type=search]").focus();
});
var rotate = 0;
Mousetrap.bind("r o t a t e enter", function (e) {
rotate += 90;
@@ -308,7 +305,7 @@ Mousetrap.bind("I space l o v e space P H P enter", function (e) {
});
Mousetrap.bind("l o g o enter", function (e) {
var time = new Date().getTime();
$(".brand img").attr("src", "/images/logo.php?refresh&time=" + time);
$(".navbar__brand img").attr("src", "/images/logo.php?refresh&time=" + time);
});
Mousetrap.bind("u n r e a d a b l e enter", function (e) {
document.cookie = 'MD=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
@@ -462,6 +459,94 @@ $(document).ready(function () {
}
});
/*{{{ 2024 Navbar */
const offcanvasElement = document.getElementById("navbar__offcanvas");
const offcanvasSelectables =
offcanvasElement.querySelectorAll("input, button, a");
const backdropElement = document.getElementById("navbar__backdrop");
const documentWidth = document.documentElement.clientWidth
const scrollbarWidth = Math.abs(window.innerWidth - documentWidth)
const offcanvasFocusTrapHandler = (event) => {
if (event.key != "Tab") {
return;
}
const firstElement = offcanvasSelectables[0];
const lastElement =
offcanvasSelectables[offcanvasSelectables.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
};
const openOffcanvasNav = () => {
offcanvasElement.classList.add("show");
offcanvasElement.setAttribute("aria-modal", "true");
offcanvasElement.setAttribute("role", "dialog");
offcanvasElement.style.visibility = "visible";
backdropElement.classList.add("show");
document.body.style.overflow = "hidden";
// Disable scroll on the html element as well to prevent the offcanvas
// nav from being pushed off screen when the page has horizontal scroll,
// like downloads.php has.
document.documentElement.style.overflow = "hidden";
document.body.style.paddingRight = `${scrollbarWidth}px`
offcanvasSelectables[0].focus();
document.addEventListener("keydown", offcanvasFocusTrapHandler);
};
const closeOffcanvasNav = () => {
offcanvasElement.classList.remove("show");
offcanvasElement.removeAttribute("aria-modal");
offcanvasElement.removeAttribute("role");
backdropElement.classList.remove("show");
document.removeEventListener("keydown", offcanvasFocusTrapHandler);
offcanvasElement.addEventListener(
"transitionend",
() => {
document.body.style.overflow = "auto";
document.documentElement.style.overflow = "auto";
document.body.style.paddingRight = '0px'
offcanvasElement.style.removeProperty("visibility");
},
{ once: true },
);
};
const closeOffCanvasByClickOutside = (event) => {
if (
!offcanvasElement.contains(event.target) &&
!menuButton.contains(event.target)
) {
closeOffcanvasNav()
}
};
document
.getElementById("navbar__menu-link")
.setAttribute("hidden", "true");
const menuButton = document.getElementById("navbar__menu-button")
menuButton.removeAttribute("hidden");
menuButton.addEventListener("click", openOffcanvasNav);
document
.getElementById("navbar__close-button")
.addEventListener("click", closeOffcanvasNav);
document.addEventListener('click', closeOffCanvasByClickOutside);
/*}}}*/
/*{{{ Scroll to top */
(function () {
var settings = {
@@ -552,13 +637,13 @@ $(document).ready(function () {
});
/*}}}*/
// Search box autocomplete (for browsers that aren't IE <= 8, anyway).
if (typeof window.brokenIE === "undefined") {
jQuery("#topsearch .search-query").search({
language: getLanguage(),
limit: 30
});
}
/*{{{Search Modal*/
const language = getLanguage();
initSearchModal();
initPHPSearch(language).then((searchCallback) => {
initSearchUI({language, searchCallback, limit: 30});
});
/*}}}*/
/* {{{ Negative user notes fade-out */
var usernotes = document.getElementById('usernotes');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,378 +1,471 @@
/**
* A jQuery plugin to add typeahead search functionality to the navbar search
* box. This requires Hogan for templating and typeahead.js for the actual
* typeahead functionality.
* Initialize the PHP search functionality with a given language.
* Loads the search index, sets up FuzzySearch, and returns a search function.
*
* @param {string} language The language for which the search index should be
* loaded.
* @returns {Promise<(query: string) => Array>} A function that takes a query
* and performs a search using the loaded index.
*/
(function ($) {
/**
* A backend, which encapsulates a set of completions, such as a list of
* functions or classes.
*
* @constructor
* @param {String} label The label to show the user.
*/
var Backend = function (label) {
this.label = label;
this.elements = {};
};
const initPHPSearch = async (language) => {
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const CACHE_DAYS = 14;
/**
* Adds an item to the backend.
* Converts the structure from search-index.php into an array of objects,
* mapping the index entries to their respective types.
*
* @param {String} id The item ID. It would help if this was unique.
* @param {String} name The item name to use as a label.
* @param {String} description Explanatory text for item.
* @param {object} index
* @returns {Array}
*/
Backend.prototype.addItem = function (id, name, description) {
this.elements[id] = {
id: id,
name: name,
description: description
};
};
const processIndex = (index) => {
return Object.entries(index)
.map(([id, [name, description, tag]]) => {
if (!name) return null;
/**
* Returns the backend contents formatted as an array that typeahead.js can
* digest as a local data source.
*
* @return {Array}
*/
Backend.prototype.toTypeaheadArray = function () {
var array = [];
let type = "General";
switch (tag) {
case "phpdoc:varentry":
type = "Variable";
break;
$.each(this.elements, function (_, element) {
element.methodName = element.name.split('::');
if (element.methodName.length > 1) {
element.methodName = element.methodName.slice(-1)[0];
} else {
delete element.methodName;
}
array.push(element);
});
case "refentry":
type = "Function";
break;
/**
* Old pre-sorting has no effect on results sorted by score.
*/
return array;
};
case "phpdoc:exceptionref":
type = "Exception";
break;
/**
* The actual search plugin. Should be applied to the input that needs
* typeahead functionality.
*
* @param {Object} options The options object. This should include
* "language": the language to try to load,
* "limit": the maximum number of results
*/
$.fn.search = function (options) {
var element = this;
case "phpdoc:classref":
type = "Class";
break;
options.language = options.language || "en";
options.limit = options.limit || 30;
/**
* Utility function to check if the user's browser supports local
* storage and native JSON, in which case we'll use it to cache the
* search JSON.
*
* @return {Boolean}
*/
var canCache = function () {
try {
return ('localStorage' in window && window['localStorage'] !== null && "JSON" in window && window["JSON"] !== null);
} catch (e) {
return false;
}
};
/**
* Processes a data structure in the format of our search-index.php
* files and returns an object containing multiple Backend objects.
*
* @param {Object} index
* @return {Object}
*/
var processIndex = function (index) {
// The search types we want to support.
var backends = {
"function": new Backend("Functions"),
"variable": new Backend("Variables"),
"class": new Backend("Classes"),
"exception": new Backend("Exceptions"),
"extension": new Backend("Extensions"),
"general": new Backend("Other Matches")
};
$.each(index, function (id, item) {
/* If the item has a name, then we should figure out what type
* of data this is, and hence which backend this should go
* into. */
if (item[0]) {
var type = null;
switch(item[2]) {
case "phpdoc:varentry":
type = "variable";
break;
case "refentry":
type = "function";
break;
case "phpdoc:exceptionref":
type = "exception";
break;
case "phpdoc:classref":
type = "class";
break;
case "set":
case "book":
case "reference":
type = "extension";
break;
case "section":
case "chapter":
case "appendix":
case "article":
default:
type = "general";
}
if (type) {
backends[type].addItem(id, item[0], item[1]);
}
case "set":
case "book":
case "reference":
type = "Extension";
break;
}
});
return backends;
};
/**
* Attempt to asynchronously load the search JSON for a given language.
*
* @param {String} language The language to search for.
* @param {Function} success Success handler, which will be given an
* object containing multiple Backend
* objects on success.
* @param {Function} failure An optional failure handler.
*/
var loadLanguage = function (language, success, failure) {
var key = "search-" + language;
// Check if the cache has a recent enough search index.
if (canCache()) {
var cache = window.localStorage.getItem(key);
if (cache) {
var since = new Date();
// Parse the stored JSON.
cache = JSON.parse(cache);
// We'll use anything that's less than two weeks old.
since.setDate(since.getDate() - 14);
if (cache.time > since.getTime()) {
success($.map(cache.data, function (dataset, name) {
// Rehydrate the Backend objects.
var backend = new Backend(dataset.label);
backend.elements = dataset.elements;
return backend;
}));
return;
}
}
}
// OK, nothing cached.
$.ajax({
dataType: "json",
error: failure,
success: function (data) {
// Transform the data into something useful.
var backends = processIndex(data);
// Cache the data if we can.
if (canCache()) {
/* This may fail in IE 8 due to exceeding the local
* storage limit. If so, squash the exception: this
* isn't a required part of the system. */
try {
window.localStorage.setItem(key,
JSON.stringify({
data: backends,
time: new Date().getTime()
})
);
} catch (e) {
// Derp.
}
}
success(backends);
},
url: "/js/search-index.php?lang=" + language
});
};
/**
* Actually enables the typeahead on the DOM element.
*
* @param {Object} backends An array-like object containing backends.
*/
var enableSearchTypeahead = function (backends) {
var header = Hogan.compile(
'<h3 class="result-heading"><span class="collapsible"></span>{{ label }}' +
'<span class="result-count">{{ count }}</span></h3>' +
'<div class="tt-suggestions"></div>'
);
var template = Hogan.compile(
'<div>' +
'<h4>{{ name }}</h4>' +
'<span title="{{ description }}" class="description">{{ description }}</span>' +
'</div>'
);
// Build the typeahead options array.
var typeaheadOptions = $.map(backends, function (backend, name) {
var fuzzyhound = new FuzzySearch({
source: backend.toTypeaheadArray(),
token_sep: ' \t.,-_', // treat colon as part of token, ignore tabs (from pasted content)
score_test_fused: true,
keys: [
'name',
'methodName',
'description'
],
thresh_include: 5.0,
thresh_relative_to_best: 0.7,
bonus_match_start: 0.7,
bonus_token_order: 1.0,
bonus_position_decay: 0.3,
token_query_min_length: 1,
token_field_min_length: 2
});
return {
source: fuzzyhound,
name: name,
limit: options.limit,
display: 'name',
templates: {
header: function () {
return header.render({
label: backend.label,
count: fuzzyhound.results.length
});
},
suggestion: function (result) {
return template.render({
name: result.name,
description: result.description
});
}
}
id,
name,
description,
tag,
type,
methodName: name.split("::").pop(),
};
});
})
.filter(Boolean);
};
// Set up the typeahead and the various listeners we need.
var searchTypeahead = element.typeahead(
{
minLength: 1,
classNames: {
menu: 'tt-dropdown-menu',
cursor: 'tt-is-under-cursor'
}
},
typeaheadOptions
/**
* Looks up the search index cached in localStorage.
*
* @returns {Array|null}
*/
const lookupIndexCache = () => {
const key = `search-${language}`;
const cache = window.localStorage.getItem(key);
if (!cache) {
return null;
}
const { data, time: cachedDate } = JSON.parse(cache);
// Invalidate old search cache format (previously an object)
// TODO: Remove this check once the new search index (a single array)
// has been in use for a while.
if (!Array.isArray(data)) {
console.log("Invalidating old search cache format");
return null;
}
const expireDate = cachedDate + CACHE_DAYS * MILLISECONDS_PER_DAY;
if (Date.now() > expireDate) {
return null;
}
return data;
};
/**
* Fetch the search index.
*
* @returns {Promise<Array>} The search index.
*/
const fetchIndex = async () => {
const key = `search-${language}`;
const response = await fetch(`/js/search-index.php?lang=${language}`);
const data = await response.json();
const items = processIndex(data);
try {
localStorage.setItem(
key,
JSON.stringify({
data: items,
time: Date.now(),
}),
);
} catch (e) {
// Local storage might be full, or other error.
// Just continue without caching.
console.error("Failed to cache search index", e);
}
// Delegate click events to result-heading collapsible icons, and trigger the accordion action
$('.tt-dropdown-menu').delegate('.result-heading .collapsible', 'click', function () {
var el = $(this), suggestions = el.parent().parent().find('.tt-suggestions');
suggestions.stop();
if(!el.hasClass('closed')) {
suggestions.slideUp();
el.addClass('closed');
} else {
suggestions.slideDown();
el.removeClass('closed');
return items;
};
/**
* Loads the search index, using cache if available.
*
* @returns {Promise<Array>}
*/
const loadIndex = async () => {
const cached = lookupIndexCache();
return cached || fetchIndex();
};
/**
* Load the language index, falling back to English on error.
*
* @returns {Promise<Array>}
*/
const loadIndexWithFallback = async () => {
try {
const searchItems = await loadIndex();
return searchItems;
} catch (error) {
if (language !== "en") {
return loadIndexWithFallback("en");
}
throw error;
}
};
/**
* Perform a search using the given query and a FuzzySearch instance.
*
* @param {string} query The search query.
* @param {object} fuzzyhound The FuzzySearch instance to use for searching.
* @returns {Array} An array of search results.
*/
const search = (query, fuzzyhound) => {
return fuzzyhound
.search(query)
.map((result) => {
// Boost Language Reference matches.
if (result.item.id.startsWith("language")) {
result.score += 10;
}
return result;
})
.sort((a, b) => b.score - a.score);
};
const searchIndex = await loadIndexWithFallback();
if (!searchIndex) {
throw new Error("Failed to load search index");
}
fuzzyhound = new FuzzySearch({
source: searchIndex,
token_sep: " \t.,-_",
score_test_fused: true,
keys: ["name", "methodName", "description"],
thresh_include: 5.0,
thresh_relative_to_best: 0.7,
bonus_match_start: 0.7,
bonus_token_order: 1.0,
bonus_position_decay: 0.3,
token_query_min_length: 1,
token_field_min_length: 2,
output_map: "root",
});
return (query) => search(query, fuzzyhound);
};
/**
* Initialize the search modal, handling focus trap and modal transitions.
*/
const initSearchModal = () => {
const backdropElement = document.getElementById("search-modal__backdrop");
const modalElement = document.getElementById("search-modal");
const resultsElement = document.getElementById("search-modal__results");
const inputElement = document.getElementById("search-modal__input");
const focusTrapHandler = (event) => {
if (event.key != "Tab") {
return;
}
const selectable = modalElement.querySelectorAll("input, button, a");
const lastElement = selectable[selectable.length - 1];
if (event.shiftKey) {
if (document.activeElement === inputElement) {
event.preventDefault();
lastElement.focus();
}
} else if (document.activeElement === lastElement) {
event.preventDefault();
inputElement.focus();
}
};
const onModalTransitionEnd = (handler) => {
backdropElement.addEventListener("transitionend", handler, {
once: true,
});
};
const documentWidth = document.documentElement.clientWidth;
const scrollbarWidth = Math.abs(window.innerWidth - documentWidth);
const show = function () {
if (
backdropElement.classList.contains("show") ||
backdropElement.classList.contains("showing")
) {
return;
}
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
resultsElement.innerHTML = "";
document.body.style.paddingRight = `${scrollbarWidth}px`;
backdropElement.setAttribute("aria-modal", "true");
backdropElement.setAttribute("role", "dialog");
backdropElement.classList.add("showing");
inputElement.focus();
inputElement.value = "";
document.addEventListener("keydown", focusTrapHandler);
onModalTransitionEnd(() => {
backdropElement.classList.remove("showing");
backdropElement.classList.add("show");
});
};
const hide = function () {
if (!backdropElement.classList.contains("show")) {
return;
}
backdropElement.classList.add("hiding");
backdropElement.classList.remove("show");
backdropElement.removeAttribute("aria-modal");
backdropElement.removeAttribute("role");
onModalTransitionEnd(() => {
document.body.style.overflow = "auto";
document.documentElement.style.overflow = "auto";
document.body.style.paddingRight = "0px";
backdropElement.classList.remove("hiding");
document.removeEventListener("keydown", focusTrapHandler);
});
};
const searchLink = document.getElementById("navbar__search-link");
const searchButtonMobile = document.getElementById(
"navbar__search-button-mobile",
);
const searchButton = document.getElementById("navbar__search-button");
// Enhance mobile search
searchLink.setAttribute("hidden", "true");
searchButtonMobile.removeAttribute("hidden");
// Enhance desktop search
document
.querySelector(".navbar__search-form")
.setAttribute("hidden", "true");
searchButton.removeAttribute("hidden");
// Open when the search button is clicked
[searchButton, searchButtonMobile].forEach((button) =>
button.addEventListener("click", show),
);
// Open when / is pressed
document.addEventListener("keydown", (event) => {
if (event.key === "/") {
show();
event.preventDefault();
}
});
// Close when the close button is clicked
document
.querySelector(".search-modal__close")
.addEventListener("click", hide);
// Close when the escape key is pressed
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
hide();
}
});
// Close when the user clicks outside of it
backdropElement.addEventListener("click", (event) => {
if (event.target === backdropElement) {
hide();
}
});
};
/**
* Initialize the search modal UI, setting up search result rendering and
* input handling.
*
* @param {object} options An object containing the search callback, language,
* and result limit.
*/
const initSearchUI = ({ searchCallback, language, limit = 30 }) => {
const DEBOUNCE_DELAY = 200;
// https://pictogrammers.com/library/mdi/icon/code-braces/
const BRACES_ICON =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z" /></svg>';
// https://pictogrammers.com/library/mdi/icon/file-document-outline/
const DOCUMENT_ICON =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z" /></svg>';
const resultsElement = document.getElementById("search-modal__results");
const inputElement = document.getElementById("search-modal__input");
let selectedIndex = -1;
/**
* Update the selected result in the results container.
*/
const updateSelectedResult = () => {
const results = resultsElement.querySelectorAll(
".search-modal__result",
);
results.forEach((result, index) => {
const isSelected = index === selectedIndex;
result.setAttribute("aria-selected", isSelected ? "true" : "false");
if (!isSelected) {
result.classList.remove("selected");
return;
}
result.classList.add("selected");
result.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
});
};
// If the user has selected an autocomplete item and hits enter, we should take them straight to the page.
searchTypeahead.on("typeahead:select", function (_, item) {
window.location = "/manual/" + options.language + "/" + item.id + ".php";
});
// Get new parent after initialization
var elementParent = element.parent();
searchTypeahead.on('typeahead:render', function (evt, renderedSuggestions, fetchedAsync, datasetIndex) {
// Fix the missing wrapper from typeahead v0.9.3 for UI parity
var set = elementParent.find('.tt-dataset-' + datasetIndex);
set.children('.tt-suggestions').first().append(set.children('.tt-suggestion'));
});
var lastPattern;
searchTypeahead.on("keyup", (function () {
/* typeahead.js doesn't give us a reliable event for the
* dropdown entries having been updated, so we'll hook into the
* input element's keyup instead. The aim here is to put in
* fake entries so that the user has a discoverable way to
* perform different searches based on what he or she has
* entered. */
// Precompile the templates we need for the fake entries.
var searchTemplate = Hogan.compile("<a class='search' href='{{ url }}'>&raquo; Search php.net for {{ pattern }}</a>");
/* Now we'll return the actual function that should be invoked
* when the user has typed something into the search box after
* typeahead.js has done its thing. */
return function () {
// Grab what the user entered.
var pattern = element.val();
if (pattern == lastPattern) {
return;
}
lastPattern = pattern;
/* Add a global search option. Note that, as above, the
* link is only displayed if more than 2 characters have
* been entered: this is due to our search functionality
* requiring at least 3 characters in the pattern. */
var dropdown = elementParent.children('.tt-dropdown-menu');
dropdown.children('.search').remove();
if (pattern.length > 2) {
dropdown.append(searchTemplate.render({
pattern: pattern,
url: "/search.php?pattern=" + encodeURIComponent(pattern)
}));
/* If the dropdown is hidden (because there are no
* results), show it anyway. */
dropdown.show();
}
};
})());
/* typeahead.js adds another input element as part of its DOM
* manipulation, which breaks the auto-submit functionality we
* previously relied upon for enter keypresses in the input box to
* work. Adding a hidden submit button re-enables it. */
$("<input type='submit' style='visibility: hidden; position: fixed'>").insertAfter(element);
// Fix for a styling issue on the created input element.
elementParent.children(".tt-hint").addClass("search-query");
/**
* Render the search results.
*
* @param {Array} results The search results.
*/
const renderResults = (results) => {
const escape = (html) => {
const div = document.createElement("div");
const node = document.createTextNode(html);
div.appendChild(node);
return div.innerHTML;
};
// Look for the user's language, then fall back to English.
loadLanguage(options.language, enableSearchTypeahead, function () {
loadLanguage("en", enableSearchTypeahead);
let resultsHtml = "";
results.forEach(({ item }, i) => {
const icon = ["General", "Extension"].includes(item.type)
? DOCUMENT_ICON
: BRACES_ICON;
const link = `/manual/${encodeURIComponent(language)}/${encodeURIComponent(item.id)}.php`;
const description =
item.type !== "General"
? `${item.type}${item.description}`
: item.description;
resultsHtml += `
<a
href="${link}"
class="search-modal__result"
role="option"
aria-labelledby="search-modal__result-name-${i}"
aria-describedby="search-modal__result-description-${i}"
aria-selected="false"
>
<div class="search-modal__result-icon">${icon}</div>
<div class="search-modal__result-content">
<div
id="search-modal__result-name-${i}"
class="search-modal__result-name"
>
${escape(item.name)}
</div>
<div
id="search-modal__result-description-${i}"
class="search-modal__result-description"
>
${escape(description)}
</div>
</div>
</a>
`;
});
return this;
resultsElement.innerHTML = resultsHtml;
};
})(jQuery);
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
};
const handleKeyDown = (event) => {
const resultsElements = resultsElement.querySelectorAll(
".search-modal__result",
);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
selectedIndex = Math.min(
selectedIndex + 1,
resultsElements.length - 1,
);
updateSelectedResult();
break;
case "ArrowUp":
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelectedResult();
break;
case "Enter":
if (selectedIndex !== -1) {
event.preventDefault();
resultsElements[selectedIndex].click();
} else {
window.location.href = `/search.php?lang=${language}&q=${encodeURIComponent(inputElement.value)}`;
}
break;
case "Escape":
selectedIndex = -1;
break;
}
};
const handleInput = (event) => {
const results = searchCallback(event.target.value);
renderResults(results.slice(0, limit), language, resultsElement);
selectedIndex = -1;
};
const debouncedHandleInput = debounce(handleInput, DEBOUNCE_DELAY);
inputElement.addEventListener("input", debouncedHandleInput);
inputElement.addEventListener("keydown", handleKeyDown);
};