[DOC-CVS] [phd] master: Chunked XHTML with JS search (#204)
[email protected] (AllenJB via GitHub)
| Newsgroups | php.doc.cvs |
|---|---|
| Message-ID | <[email protected]> |
Author: AllenJB (AllenJB)
Committer: GitHub (web-flow)
Pusher: Girgias
Date: 2025-11-04T17:15:47Z
Commit: https://github.com/php/phd/commit/5b4703a5b71a149644b0b8fd97c8bde2b678ee22
Raw diff: https://github.com/php/phd/commit/5b4703a5b71a149644b0b8fd97c8bde2b678ee22.diff
Chunked XHTML with JS search (#204)
* Chunked XHTML with JS search
* Improve prev/next nav alignment/sizing with differing content
* Refactor to merge changes into Chunked XHTML directly;
New search indexes pushed up to Web (allowing search.js changes to be pushed to web-php, avoiding duplication)
* Add search access key hint
* Update tests
* Exclude search from CHM format
* Chunked XHTML with JS search (don't use the localStorage cache for local docs)
Changed paths:
A phpdotnet/phd/Package/PHP/search.js
M phpdotnet/phd/Package/PHP/CHM.php
M phpdotnet/phd/Package/PHP/ChunkedXHTML.php
M phpdotnet/phd/Package/PHP/Web.php
Diff:
diff --git a/phpdotnet/phd/Package/PHP/CHM.php b/phpdotnet/phd/Package/PHP/CHM.php
index 40562440..86d9c445 100644
--- a/phpdotnet/phd/Package/PHP/CHM.php
+++ b/phpdotnet/phd/Package/PHP/CHM.php
@@ -449,4 +449,14 @@ public function format_link($open, $name, $attrs, $props) {
$link = preg_replace($search, $replacement, $link);
return $link;
}
+
+ protected function headerNav($id): string
+ {
+ return '';
+ }
+
+ protected function footerSearch(): string
+ {
+ return '';
+ }
}
diff --git a/phpdotnet/phd/Package/PHP/ChunkedXHTML.php b/phpdotnet/phd/Package/PHP/ChunkedXHTML.php
index 0440afab..9c26c31b 100644
--- a/phpdotnet/phd/Package/PHP/ChunkedXHTML.php
+++ b/phpdotnet/phd/Package/PHP/ChunkedXHTML.php
@@ -2,6 +2,8 @@
namespace phpdotnet\phd;
class Package_PHP_ChunkedXHTML extends Package_PHP_Web {
+ protected array $js = [];
+
public function __construct(
Config $config,
OutputHandler $outputHandler
@@ -15,37 +17,69 @@ public function __destruct() {
parent::__destruct();
}
- public function header($id) {
- $title = Format::getLongDescription($id);
- static $cssLinks = null;
- if ($cssLinks === null) {
- $cssLinks = $this->createCSSLinks();
- }
- $header = <<<HEADER
-<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
-<html>
- <head>
- <meta http-equiv="content-type" content="text/html; charset=UTF-8">
- <title>$title</title>
-{$cssLinks}
- </head>
- <body class="docs">
-HEADER;
- $nextLink = $prevLink = $upLink = '';
+ protected function headerNav($id): string
+ {
+ // https://feathericons.com search
+ $searchIcon = <<<SVG
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ aria-hidden="true"
+ width="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <circle cx="11" cy="11" r="8"></circle>
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+ </svg>
+ SVG;
+
+ $nextLink = $prevLink = '<li class="navbar__item"></li>';
if ($prevId = Format::getPrevious($id)) {
$prev = array(
"href" => $this->getFilename($prevId) . $this->getExt(),
"desc" => $this->getShortDescription($prevId),
);
- $prevLink = "<li style=\"float: left;\"><a href=\"{$prev["href"]}\">« {$prev["desc"]}</a></li>";
+ $prevLink = "<li class=\"navbar__item prev\"><a href=\"{$prev["href"]}\" class=\"navbar__link\">« {$prev["desc"]}</a></li>";
}
if ($nextId = Format::getNext($id)) {
$next = array(
"href" => $this->getFilename($nextId) . $this->getExt(),
"desc" => $this->getShortDescription($nextId),
);
- $nextLink = "<li style=\"float: right;\"><a href=\"{$next["href"]}\">{$next["desc"]} »</a></li>";
+ $nextLink = "<li class=\"navbar__item next\"><a href=\"{$next["href"]}\" class=\"navbar__link\">{$next["desc"]} »</a></li>";
}
+
+ return <<<HTML
+ <div class="navbar navbar-fixed-top">
+ <div class="navbar__inner clearfix">
+ <ul class="navbar__local">
+ {$prevLink}
+ <li class="navbar__item search">
+ <!-- Desktop encanced search -->
+ <button
+ id="navbar__search-button"
+ class="navbar__search-button"
+ hidden
+ >
+ $searchIcon
+ Search
+ </button>
+ </li>
+ {$nextLink}
+ </ul>
+ </div>
+ </div>
+ HTML;
+ }
+
+ protected function headerCrumbs($id): string
+ {
+ $title = Format::getLongDescription($id);
+ $upLink = '';
if ($parentId = Format::getParent($id)) {
$up = array(
"href" => $this->getFilename($parentId) . $this->getExt(),
@@ -56,31 +90,196 @@ public function header($id) {
}
}
- $nav = <<<NAV
-<div class="navbar navbar-fixed-top">
- <div class="navbar-inner clearfix">
- <ul class="nav" style="width: 100%">
- {$prevLink}
- {$nextLink}
- </ul>
- </div>
-</div>
-<div id="breadcrumbs" class="clearfix">
- <ul class="breadcrumbs-container">
- <li><a href="index.html">PHP Manual</a></li>
- {$upLink}
- <li>{$title}</li>
- </ul>
-</div>
-<div id="layout">
- <div id="layout-content">
-NAV;
- $header .= $nav;
- return $header;
+ return <<<HTML
+ <div id="breadcrumbs" class="clearfix">
+ <ul class="breadcrumbs-container">
+ <li><a href="index.html">PHP Manual</a></li>
+ {$upLink}
+ <li>{$title}</li>
+ </ul>
+ </div>
+ HTML;
+ }
+
+ public function header($id) {
+ $title = Format::getLongDescription($id);
+ static $cssLinks = null;
+ if ($cssLinks === null) {
+ $cssLinks = $this->createCSSLinks();
+ }
+ $header = <<<HTML
+ <!DOCTYPE HTML>
+ <html>
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ <style>
+ .navbar__local {
+ flex-grow: 1;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr min-content 1fr;
+ margin: 0;
+ height: 100%;
+
+ .navbar__item {
+ align-content: center;
+ }
+ .navbar__link {
+ display: inline;
+ }
+ .search {
+ text-align: center;
+ }
+ .next {
+ text-align: right;
+ }
+ }
+ </style>
+ <title>$title</title>
+ {$cssLinks}
+ </head>
+ <body class="docs">
+ HTML;
+
+ $contentOpen = <<<HTML
+ <div id="layout">
+ <div id="layout-content">
+ HTML;
+
+ return $header . $this->headerNav($id) . $this->headerCrumbs($id) . $contentOpen;
+ }
+
+ protected function footerSearch(): string
+ {
+ $this->fetchJS();
+
+ $footer = '';
+ $footer .= '<script type="text/javascript" src="search-combined.js"></script>';
+ foreach ($this->js as $jsFile) {
+ $footer .= '<script type="text/javascript" src="' . $jsFile . '"></script>';
+ }
+
+ $footer .= <<<HTML
+ <div id="search-modal__backdrop" class="search-modal__backdrop">
+ <div
+ role="dialog"
+ aria-label="Search modal"
+ id="search-modal"
+ class="search-modal"
+ >
+ <div class="search-modal__header">
+ <div class="search-modal__form">
+ <div class="search-modal__input-icon">
+ <!-- https://feathericons.com search -->
+ <svg xmlns="http://www.w3.org/2000/svg"
+ aria-hidden="true"
+ width="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <circle cx="11" cy="11" r="8"></circle>
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+ </svg>
+ </div>
+ <input
+ type="search"
+ id="search-modal__input"
+ class="search-modal__input"
+ placeholder="Search docs"
+ aria-label="Search docs"
+ />
+ </div>
+
+ <button aria-label="Close" class="search-modal__close">
+ <!-- https://pictogrammers.com/library/mdi/icon/close/ -->
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ aria-hidden="true"
+ width="24"
+ viewBox="0 0 24 24"
+ >
+ <path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
+ </svg>
+ </button>
+ </div>
+ <div
+ role="listbox"
+ aria-label="Search results"
+ id="search-modal__results"
+ class="search-modal__results"
+ ></div>
+ <div class="search-modal__helper-text">
+ <div>
+ <kbd>↑</kbd> and <kbd>↓</kbd> to navigate •
+ <kbd>Enter</kbd> to select •
+ <kbd>Esc</kbd> to close • <kbd>/</kbd> to open
+ </div>
+ </div>
+ </div>
+ </div>
+ <script type="text/javascript">
+ document.addEventListener("DOMContentLoaded", function() {
+ /*{{{Search Modal*/
+ const language = 'local';
+ initSearchModal();
+ initPHPSearch(language).then((searchCallback) => {
+ initSearchUI({language, searchCallback, limit: 30});
+ });
+ /*}}}*/
+ });
+ </script>
+ HTML;
+
+ return $footer;
+ }
+
+ private function mkdir(string $dir): void
+ {
+ if (file_exists($dir)) {
+ if (!is_dir($dir)) {
+ trigger_error("The specified directory is a file: {$dir}", E_USER_ERROR);
+ }
+ return;
+ }
+ if (!mkdir($dir, 0777, true)) {
+ trigger_error("Can't create the specified directory: {$dir}", E_USER_ERROR);
+ }
+ }
+
+ private function fetchJS(): void
+ {
+ if ($this->js !== []) {
+ return;
+ }
+ $outputDir = $this->getOutputDir();
+ if (!$outputDir) {
+ $outputDir = $this->config->outputDir;
+ }
+ $jsDir = 'js/';
+ $outputDir .= '/' . $jsDir;
+ $this->mkdir($outputDir);
+
+ $files = [
+ 'https://www.php.net/js/ext/FuzzySearch.min.js',
+ __DIR__ . '/search.js',
+ ];
+ foreach ($files as $sourceFile) {
+ $basename = basename($sourceFile);
+ $dest = md5(substr($sourceFile, 0, -strlen($basename))) . '-' . $basename;
+ if (! @copy($sourceFile, $outputDir . $dest)) {
+ trigger_error(vsprintf('Impossible to copy the %s file.', [$sourceFile]), E_USER_WARNING);
+ continue;
+ }
+ $this->js[] = $jsDir . $dest;
+ }
}
public function footer($id)
{
- return '</div></div></body></html>';
+ return '</div></div>' . $this->footerSearch() . '</body></html>';
}
}
diff --git a/phpdotnet/phd/Package/PHP/Web.php b/phpdotnet/phd/Package/PHP/Web.php
index 54a24216..ad36b7cf 100644
--- a/phpdotnet/phd/Package/PHP/Web.php
+++ b/phpdotnet/phd/Package/PHP/Web.php
@@ -337,10 +337,6 @@ private function processCombinedJsonIndex(): array
$nameParts = explode('::', $index['sdesc']);
$methodName = array_pop($nameParts);
- if (str_contains('wrapper', $index['filename'])) {
- print "Combined index: adding " . $index['filename'] . " :: " . $index['sdesc'] . "\n";
- }
-
$type = 'General';
switch ($index['element']) {
case "phpdoc:varentry":
diff --git a/phpdotnet/phd/Package/PHP/search.js b/phpdotnet/phd/Package/PHP/search.js
new file mode 100644
index 00000000..f2e89e43
--- /dev/null
+++ b/phpdotnet/phd/Package/PHP/search.js
@@ -0,0 +1,437 @@
+/**
+ * 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.
+ */
+const initPHPSearch = async (language) => {
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+ const CACHE_DAYS = 14;
+
+ /**
+ * Looks up the search index cached in localStorage.
+ *
+ * @returns {Array|null}
+ */
+ const lookupIndexCache = () => {
+ const key = `search2-${language}`;
+ const cache = window.localStorage.getItem(key);
+
+ if ((!cache) || (language === 'local')) {
+ return null;
+ }
+
+ const { data, time: cachedDate } = JSON.parse(cache);
+
+ 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 = `search2-${language}`;
+ let items;
+ if (language === 'local') {
+ items = localSearchIndexes;
+ } else {
+ const response = await fetch(`/js/search-index.php?lang=${language}`);
+ items = await response.json();
+
+ 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);
+ }
+ }
+
+ 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 {
+ return await loadIndex();
+ } catch (error) {
+ if ((language !== "en") && (language !== "local")) {
+ language = "en";
+ return loadIndexWithFallback();
+ }
+ 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");
+ let buttons = [searchButton];
+
+ // Enhance mobile search
+ if (searchLink !== null) {
+ searchLink.setAttribute("hidden", "true");
+ searchButtonMobile.removeAttribute("hidden");
+ buttons.push(searchButtonMobile);
+ }
+
+ // Enhance desktop search
+ const searchForm = document
+ .querySelector(".navbar__search-form");
+ if (searchForm !== null) {
+ searchForm.setAttribute("hidden", "true");
+ }
+ searchButton.removeAttribute("hidden");
+
+ // Open when the search button is clicked
+ buttons.forEach((button) =>
+ button.addEventListener("click", show),
+ );
+
+ // Open when / is pressed
+ document.addEventListener("keydown", (event) => {
+ const target = event.target;
+
+ if (
+ target.contentEditable === "true" ||
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA"
+ ) {
+ return;
+ }
+
+ 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",
+ });
+ });
+ };
+
+ /**
+ * 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;
+ };
+
+ let resultsHtml = "";
+ results.forEach(({ item }, i) => {
+ const icon = ["General", "Extension"].includes(item.type)
+ ? DOCUMENT_ICON
+ : BRACES_ICON;
+ let link = `/manual/${encodeURIComponent(language)}/${encodeURIComponent(item.id)}.php`;
+ if (language === 'local') {
+ link = encodeURIComponent(item.id) + '.html';
+ }
+
+ 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>
+ `;
+ });
+
+ resultsElement.innerHTML = resultsHtml;
+ };
+
+ 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 if (language !== 'local') {
+ 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);
+};