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