Skip to content

Commit 7c54d4a

Browse files
kaycebasquesKayce Basquespre-commit-ci[bot]
authored
Add search-as-you-type (inline search results) feature (#2093)
Fixes #1977 --------- Co-authored-by: Kayce Basques <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d76892d commit 7c54d4a

File tree

8 files changed

+248
-2
lines changed

8 files changed

+248
-2
lines changed

docs/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@
217217
"version_match": version_match,
218218
},
219219
# "back_to_top_button": False,
220+
"search_as_you_type": True,
220221
}
221222

222223
html_sidebars = {

docs/user_guide/search.rst

+11
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,14 @@ following configuration to your ``conf.py`` file:
6363
html_theme_options = {
6464
"search_bar_text": "Your text here..."
6565
}
66+
67+
Configure the inline search results (search-as-you-type) feature
68+
----------------------------------------------------------------
69+
70+
Set the ``search_as_you_type`` HTML theme option to ``True``.
71+
72+
.. code:: python
73+
74+
html_theme_options = {
75+
"search_as_you_type": True
76+
}

src/pydata_sphinx_theme/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ def update_and_remove_templates(
241241
"""
242242
app.add_js_file(None, body=js)
243243

244+
# Specify whether search-as-you-type should be used or not.
245+
search_as_you_type = str(context["theme_search_as_you_type"]).lower()
246+
app.add_js_file(
247+
None, body=f"DOCUMENTATION_OPTIONS.search_as_you_type = {search_as_you_type};"
248+
)
249+
244250
# Update version number for the "made with version..." component
245251
context["theme_version"] = __version__
246252

src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js

+166
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ var addEventListenerForSearchKeyboard = () => {
261261
// also allow Escape key to hide (but not show) the dynamic search field
262262
else if (document.activeElement === input && /Escape/i.test(event.key)) {
263263
toggleSearchField();
264+
resetSearchAsYouTypeResults();
264265
}
265266
},
266267
true,
@@ -332,6 +333,170 @@ var setupSearchButtons = () => {
332333
searchDialog.addEventListener("click", closeDialogOnBackdropClick);
333334
};
334335

336+
/*******************************************************************************
337+
* Inline search results (search-as-you-type)
338+
*
339+
* Immediately displays search results under the search query textbox.
340+
*
341+
* The search is conducted by Sphinx's built-in search tools (searchtools.js).
342+
* Usually searchtools.js is only available on /search.html but
343+
* pydata-sphinx-theme (PST) has been modified to load searchtools.js on every
344+
* page. After the user types something into PST's search query textbox,
345+
* searchtools.js executes the search and populates the results into
346+
* the #search-results container. searchtools.js expects the results container
347+
* to have that exact ID.
348+
*/
349+
var setupSearchAsYouType = () => {
350+
if (!DOCUMENTATION_OPTIONS.search_as_you_type) {
351+
return;
352+
}
353+
354+
// Don't interfere with the default search UX on /search.html.
355+
if (window.location.pathname.endsWith("/search.html")) {
356+
return;
357+
}
358+
359+
// Bail if the Search class is not available. Search-as-you-type is
360+
// impossible without that class. layout.html should ensure that
361+
// searchtools.js loads.
362+
//
363+
// Search class is defined in upstream Sphinx:
364+
// https://github.com/sphinx-doc/sphinx/blob/6678e357048ea1767daaad68e7e0569786f3b458/sphinx/themes/basic/static/searchtools.js#L181
365+
if (!Search) {
366+
return;
367+
}
368+
369+
// Destroy the previous search container and create a new one.
370+
resetSearchAsYouTypeResults();
371+
let timeoutId = null;
372+
let lastQuery = "";
373+
const searchInput = document.querySelector(
374+
"#pst-search-dialog input[name=q]",
375+
);
376+
377+
// Initiate searches whenever the user types stuff in the search modal textbox.
378+
searchInput.addEventListener("keyup", () => {
379+
const query = searchInput.value;
380+
381+
// Don't search when there's nothing in the query textbox.
382+
if (query === "") {
383+
resetSearchAsYouTypeResults(); // Remove previous results.
384+
return;
385+
}
386+
387+
// Don't search if there is no detectable change between
388+
// the last query and the current query. E.g. the user presses
389+
// Tab to start navigating the search results.
390+
if (query === lastQuery) {
391+
return;
392+
}
393+
394+
// The user has changed the search query. Delete the old results
395+
// and start setting up the new container.
396+
resetSearchAsYouTypeResults();
397+
398+
// Debounce so that the search only starts when the user stops typing.
399+
const delay_ms = 300;
400+
lastQuery = query;
401+
if (timeoutId) {
402+
window.clearTimeout(timeoutId);
403+
}
404+
timeoutId = window.setTimeout(() => {
405+
Search.performSearch(query);
406+
document.querySelector("#search-results").classList.remove("empty");
407+
timeoutId = null;
408+
}, delay_ms);
409+
});
410+
};
411+
412+
// Delete the old search results container (if it exists) and set up a new one.
413+
//
414+
// There is some complexity around ensuring that the search results links are
415+
// correct because we're extending searchtools.js past its assumed usage.
416+
// Sphinx assumes that searches are only executed from /search.html and
417+
// therefore it assumes that all search results links should be relative to
418+
// the root directory of the website. In our case the search can now execute
419+
// from any page of the website so we must fix the relative URLs that
420+
// searchtools.js generates.
421+
var resetSearchAsYouTypeResults = () => {
422+
if (!DOCUMENTATION_OPTIONS.search_as_you_type) {
423+
return;
424+
}
425+
// If a search-as-you-type results container was previously added,
426+
// remove it now.
427+
let results = document.querySelector("#search-results");
428+
if (results) {
429+
results.remove();
430+
}
431+
432+
// Create a new search-as-you-type results container.
433+
results = document.createElement("section");
434+
results.classList.add("empty");
435+
// Remove the container element from the tab order. Individual search
436+
// results are still focusable.
437+
results.tabIndex = -1;
438+
// When focus is on a search result, make sure that pressing Escape closes
439+
// the search modal.
440+
results.addEventListener("keydown", (event) => {
441+
if (event.key === "Escape") {
442+
event.preventDefault();
443+
event.stopPropagation();
444+
toggleSearchField();
445+
resetSearchAsYouTypeResults();
446+
}
447+
});
448+
// IMPORTANT: The search results container MUST have this exact ID.
449+
// searchtools.js is hardcoded to populate into the node with this ID.
450+
results.id = "search-results";
451+
let modal = document.querySelector("#pst-search-dialog");
452+
modal.appendChild(results);
453+
454+
// Get the relative path back to the root of the website.
455+
const root =
456+
"URL_ROOT" in DOCUMENTATION_OPTIONS
457+
? DOCUMENTATION_OPTIONS.URL_ROOT // Sphinx v6 and earlier
458+
: document.documentElement.dataset.content_root; // Sphinx v7 and later
459+
460+
// As Sphinx populates the search results, this observer makes sure that
461+
// each URL is correct (i.e. doesn't 404).
462+
const linkObserver = new MutationObserver(() => {
463+
const links = Array.from(
464+
document.querySelectorAll("#search-results .search a"),
465+
);
466+
// Check every link every time because the timing of when new results are
467+
// added is unpredictable and it's not an expensive operation.
468+
links.forEach((link) => {
469+
link.tabIndex = 0; // Use natural tab order for search results.
470+
// Don't use the link.href getter because the browser computes the href
471+
// as a full URL. We need the relative URL that Sphinx generates.
472+
const href = link.getAttribute("href");
473+
if (href.startsWith(root)) {
474+
// No work needed. The root has already been prepended to the href.
475+
return;
476+
}
477+
link.href = `${root}${href}`;
478+
});
479+
});
480+
481+
// The node that linkObserver watches doesn't exist until the user types
482+
// something into the search textbox. This second observer (resultsObserver)
483+
// just waits for #search-results to exist and then registers
484+
// linkObserver on it.
485+
let isObserved = false;
486+
const resultsObserver = new MutationObserver(() => {
487+
if (isObserved) {
488+
return;
489+
}
490+
const container = document.querySelector("#search-results .search");
491+
if (!container) {
492+
return;
493+
}
494+
linkObserver.observe(container, { childList: true });
495+
isObserved = true;
496+
});
497+
resultsObserver.observe(results, { childList: true });
498+
};
499+
335500
/*******************************************************************************
336501
* Version Switcher
337502
* Note that this depends on two variables existing that are defined in
@@ -857,6 +1022,7 @@ documentReady(addModeListener);
8571022
documentReady(scrollToActive);
8581023
documentReady(addTOCInteractivity);
8591024
documentReady(setupSearchButtons);
1025+
documentReady(setupSearchAsYouType);
8601026
documentReady(setupMobileSidebarKeyboardHandlers);
8611027

8621028
// Determining whether an element has scrollable content depends on stylesheets,

src/pydata_sphinx_theme/assets/styles/components/_search.scss

+23-2
Original file line numberDiff line numberDiff line change
@@ -93,29 +93,50 @@
9393
z-index: $zindex-modal;
9494
top: 30%;
9595
left: 50%;
96-
transform: translate(-50%, -50%);
96+
transform: translate(-50%, -30%);
9797
right: 1rem;
98+
margin-bottom: 0;
9899
margin-top: 0.5rem;
99100
width: 90%;
100101
max-width: 800px;
101102
background-color: transparent;
102103
padding: $focus-ring-width;
103104
border: none;
105+
flex-direction: column;
106+
height: 80vh;
104107

105108
&::backdrop {
106109
background-color: black;
107110
opacity: 0.5;
108111
}
109112

110113
form.bd-search {
111-
flex-grow: 1;
114+
flex-grow: 0;
112115

113116
// Font and input text a bit bigger
114117
svg,
115118
input {
116119
font-size: var(--pst-font-size-icon);
117120
}
118121
}
122+
123+
/* In pydata-sphinx-theme.js this container is appended below
124+
* the query input node after the user types their search query.
125+
* Search results are populated into this container using Sphinx's
126+
* built-in, JS-powered local search tools. */
127+
#search-results {
128+
overflow-y: scroll;
129+
background-color: var(--pst-color-background);
130+
padding: 1em;
131+
132+
a {
133+
color: var(--pst-color-link);
134+
}
135+
136+
&.empty {
137+
display: none;
138+
}
139+
}
119140
}
120141
}
121142

src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html

+9
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@
3737
{%- if last_updated %}
3838
<meta name="docbuild:last-update" content="{{ last_updated | e }}"/>
3939
{%- endif %}
40+
{% if pagename == 'search' %}
41+
{# Search tools are already loaded on search page. Don't load them twice. #}
42+
{% else %}
43+
{# Load Sphinx's built-in search tools so that our custom inline search
44+
experience can work on any page. #}
45+
<script src="{{ pathto('_static/searchtools.js', 1) | e }}"></script>
46+
<script src="{{ pathto('_static/language_data.js', 1) | e }}"></script>
47+
<script src="{{ pathto('searchindex.js', 1) | e }}"></script>
48+
{% endif %}
4049
{%- endblock extrahead %}
4150
{% block body_tag %}
4251
{# set up with scrollspy to update the toc as we scroll #}

src/pydata_sphinx_theme/theme/pydata_sphinx_theme/theme.conf

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ logo =
3636
logo_link =
3737
surface_warnings = True
3838
back_to_top_button = True
39+
search_as_you_type = False
3940

4041
# Template placement in theme layouts
4142
navbar_start = navbar-logo

tests/test_a11y.py

+31
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,34 @@ def test_breadcrumb_expansion(page: Page, url_base: str) -> None:
291291
expect(page.get_by_label("Breadcrumb").get_by_role("list")).to_contain_text(
292292
"Update Sphinx configuration during the build"
293293
)
294+
295+
296+
@pytest.mark.a11y
297+
def test_search_as_you_type(page: Page, url_base: str) -> None:
298+
"""Search-as-you-type feature should support keyboard navigation.
299+
300+
When the search-as-you-type (inline search results) feature is enabled,
301+
pressing Tab after entering a search query should focus the first inline
302+
search result.
303+
"""
304+
page.set_viewport_size({"width": 1440, "height": 720})
305+
page.goto(urljoin(url_base, "/examples/kitchen-sink/blocks.html"))
306+
# Click the search textbox.
307+
searchbox = page.locator("css=.navbar-header-items .search-button__default-text")
308+
searchbox.click()
309+
# Type a search query.
310+
query_input = page.locator("css=#pst-search-dialog input[type=search]")
311+
expect(query_input).to_be_visible()
312+
query_input.type("test")
313+
page.wait_for_timeout(301) # Search execution is debounced for 300 ms.
314+
search_results = page.locator("css=#search-results")
315+
expect(search_results).to_be_visible()
316+
# Navigate with the keyboard.
317+
query_input.press("Tab")
318+
# Make sure that the first inline search result is focused.
319+
actual_focused_content = page.evaluate("document.activeElement.textContent")
320+
first_result_selector = "#search-results .search li:first-child a"
321+
expected_focused_content = page.evaluate(
322+
f"document.querySelector('{first_result_selector}').textContent"
323+
)
324+
assert actual_focused_content == expected_focused_content

0 commit comments

Comments
 (0)