From 5c47b4e95e22c6d2fcab0b13b4c7387635a206e6 Mon Sep 17 00:00:00 2001
From: Tim Paine <3105306+timkpaine@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:06:59 -0400
Subject: [PATCH] Fix search dialog focus trap and nested result links
Adopted theme search fields opened the dialog on focus. Closing a dialog
restores focus to the element that opened it, so Escape and backdrop
clicks reopened it immediately and left no way out. Open on click and on
Enter or Space instead.
Chrome's `` consumes the first Escape to clear the
field, so closing took two presses despite the `Esc` hint. Close the
dialog explicitly.
Record urls are relative to the documentation root but were used verbatim
as hrefs, so every result 404'd from a page below the root. Resolve them
against the root as the index loads.
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
---
sphinx_searchlite/static/engine/searchlite.js | 6 ++++++
sphinx_searchlite/static/ui/searchlite-ui.js | 14 +++++++++++++-
sphinx_searchlite/tests/test_searchlite.py | 19 +++++++++++++++++++
3 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/sphinx_searchlite/static/engine/searchlite.js b/sphinx_searchlite/static/engine/searchlite.js
index be31485..2c1eb70 100644
--- a/sphinx_searchlite/static/engine/searchlite.js
+++ b/sphinx_searchlite/static/engine/searchlite.js
@@ -196,6 +196,12 @@
return [];
})
.then(function (records) {
+ // Record urls are relative to the documentation root, but a page
+ // nested below it would resolve them against its own directory.
+ var root = new URL("../", new URL(url, document.baseURI));
+ records.forEach(function (record) {
+ record.u = new URL(record.u, root).href;
+ });
index = build(records);
return index;
});
diff --git a/sphinx_searchlite/static/ui/searchlite-ui.js b/sphinx_searchlite/static/ui/searchlite-ui.js
index f2a17c1..99d692a 100644
--- a/sphinx_searchlite/static/ui/searchlite-ui.js
+++ b/sphinx_searchlite/static/ui/searchlite-ui.js
@@ -142,8 +142,15 @@
seen.add(field);
field.classList.add("searchlite-adopted");
field.readOnly = true;
- field.addEventListener("focus", open);
field.addEventListener("click", open);
+ // Opening on `focus` traps the user: closing the dialog restores focus
+ // to the field that opened it, which would immediately reopen it.
+ field.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ open();
+ }
+ });
var form = field.closest("form");
if (form) {
form.addEventListener("submit", function (event) {
@@ -174,6 +181,11 @@
event.preventDefault();
window.location.href = active.href;
}
+ } else if (event.key === "Escape") {
+ // `` swallows the first Escape to clear itself, which
+ // would leave the dialog needing two presses despite the `Esc` hint.
+ event.preventDefault();
+ dialog.close();
}
});
diff --git a/sphinx_searchlite/tests/test_searchlite.py b/sphinx_searchlite/tests/test_searchlite.py
index df1cdb9..54370eb 100644
--- a/sphinx_searchlite/tests/test_searchlite.py
+++ b/sphinx_searchlite/tests/test_searchlite.py
@@ -117,6 +117,14 @@ def test_index_is_still_emitted(self, built):
assert (built / "_static" / "searchlite-index.json").is_file()
+class TestResultLinks:
+ def test_urls_resolve_against_the_documentation_root(self, built):
+ js = (built / "_static" / "searchlite.js").read_text()
+ # Records store root-relative urls, so a page nested below the root
+ # would otherwise resolve them against its own directory.
+ assert 'new URL("../", new URL(url, document.baseURI))' in js
+
+
class TestThemeSearchAdoption:
def test_adoption_is_advertised_to_the_ui_script(self, built):
assert 'data-searchlite-adopt="true"' in (built / "guide.html").read_text()
@@ -125,6 +133,17 @@ def test_adoption_can_be_switched_off(self, tmp_path_factory):
out = _build(tmp_path_factory, CONF + "\nsearchlite_adopt_theme_search = False\n", "src_no_adopt")
assert 'data-searchlite-adopt="false"' in (out / "guide.html").read_text()
+ def test_adopted_fields_do_not_reopen_the_dialog_on_focus(self, built):
+ js = (built / "_static" / "searchlite-ui.js").read_text()
+ # Closing the dialog restores focus to the field that opened it, so an
+ # opener bound to `focus` would reopen it and trap the reader.
+ assert 'field.addEventListener("focus", open)' not in js
+
+ def test_escape_closes_the_dialog_on_the_first_press(self, built):
+ js = (built / "_static" / "searchlite-ui.js").read_text()
+ # `` eats the first Escape to clear itself.
+ assert 'event.key === "Escape"' in js
+
def test_styles_no_longer_hardcode_a_dark_palette(self, built):
css = (built / "_static" / "searchlite.css").read_text()
# Colours are adopted from the host page instead, since themes signal