mirror of
https://github.com/php/phd.git
synced 2026-03-23 22:52:05 +01:00
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)
This commit is contained in:
@@ -449,4 +449,14 @@ res' . DIRECTORY_SEPARATOR . 'style.css
|
||||
$link = preg_replace($search, $replacement, $link);
|
||||
return $link;
|
||||
}
|
||||
|
||||
protected function headerNav($id): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function footerSearch(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ class Package_PHP_ChunkedXHTML extends Package_PHP_Web {
|
||||
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 @@ HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
$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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,10 +337,6 @@ contributors($setup);
|
||||
$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":
|
||||
|
||||
437
phpdotnet/phd/Package/PHP/search.js
Normal file
437
phpdotnet/phd/Package/PHP/search.js
Normal file
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user