[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);
+};
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.