Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add search-as-you-type (inline search results) feature #2093

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@
"version_match": version_match,
},
# "back_to_top_button": False,
"search_as_you_type": True,
}

html_sidebars = {
Expand Down
11 changes: 11 additions & 0 deletions docs/user_guide/search.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,14 @@ following configuration to your ``conf.py`` file:
html_theme_options = {
"search_bar_text": "Your text here..."
}

Configure the inline search results (search-as-you-type) feature
----------------------------------------------------------------

Set the ``search_as_you_type`` HTML theme option to ``True``.

.. code:: python

html_theme_options = {
"search_as_you_type": True
}
6 changes: 6 additions & 0 deletions src/pydata_sphinx_theme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ def update_and_remove_templates(
"""
app.add_js_file(None, body=js)

# Specify whether search-as-you-type should be used or not.
search_as_you_type = str(context["theme_search_as_you_type"]).lower()
app.add_js_file(
None, body=f"DOCUMENTATION_OPTIONS.search_as_you_type = {search_as_you_type};"
)

# Update version number for the "made with version..." component
context["theme_version"] = __version__

Expand Down
152 changes: 152 additions & 0 deletions src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,157 @@ var setupSearchButtons = () => {
searchDialog.addEventListener("click", closeDialogOnBackdropClick);
};

/*******************************************************************************
* Inline search results (search-as-you-type)
*
* Immediately displays search results under the search query textbox.
*
* The search is conducted by Sphinx's built-in search tools (searchtools.js).
* Usually searchtools.js is only available on /search.html but
* pydata-sphinx-theme (PST) has been modified to load searchtools.js on every
* page. After the user types something into PST's search query textbox,
* searchtools.js executes the search and populates the results into
* the #search-results container. searchtools.js expects the results container
* to have that exact ID.
*/
var setupSearchAsYouType = () => {
if (!DOCUMENTATION_OPTIONS.search_as_you_type) {
return;
}

// Don't interfere with the default search UX on /search.html.
if (window.location.pathname.endsWith("/search.html")) {
return;
}
Comment on lines +353 to +356
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also account for dirhtml builds, which I think (?) will have a url like https://whatever.com/en/search/ or potentially https://whatever.com/en/search/index.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dirhtml was not on my radar. Will need to look into what that does to figure out how it affects the impl.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I introduced a temporary change to tox.ini in this PR to make docs-dev build dirhtml instead of html. It all seems to work fine still


// Bail if the Search class is not available. Search-as-you-type is
// impossible without that class. layout.html should ensure that
// searchtools.js loads.
//
// Search class is defined in upstream Sphinx:
// https://github.com/sphinx-doc/sphinx/blob/master/sphinx/themes/basic/static/searchtools.js#L181
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

link to .../blob/master/... may suffer from line number drift or otherwise go stale. Let's use a permalink to a specific repo state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed now, will let you resolve

if (!Search) {
return;
}

// Destroy the previous search container and create a new one.
resetSearchAsYouTypeResults();
let timeoutId = null;
let lastQuery = "";
const searchInput = document.querySelector(
"#pst-search-dialog input[name=q]",
);

// Initiate searches whenever the user types stuff in the search modal textbox.
searchInput.addEventListener("keyup", () => {
const query = searchInput.value;

// Don't search when there's nothing in the query textbox.
if (query === "") {
resetSearchAsYouTypeResults(); // Remove previous results.
return;
}

// Don't search if there is no detectable change between
// the last query and the current query. E.g. the user presses
// Tab to start navigating the search results.
if (query === lastQuery) {
return;
}

// The user has changed the search query. Delete the old results
// and start setting up the new container.
resetSearchAsYouTypeResults();

// Debounce so that the search only starts when the user stops typing.
const delay_ms = 300;
lastQuery = query;
if (timeoutId) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
Search.performSearch(query);
document.querySelector("#search-results").classList.remove("empty");
timeoutId = null;
}, delay_ms);
});
};

// Delete the old search results container (if it exists) and set up a new one.
//
// There is some complexity around ensuring that the search results links are
// correct because we're extending searchtools.js past its assumed usage.
// Sphinx assumes that searches are only executed from /search.html and
// therefore it assumes that all search results links should be relative to
// the root directory of the website. In our case the search can now execute
// from any page of the website so we must fix the relative URLs that
// searchtools.js generates.
var resetSearchAsYouTypeResults = () => {
if (!DOCUMENTATION_OPTIONS.search_as_you_type) {
return;
}
// If a search-as-you-type results container was previously added,
// remove it now.
let results = document.querySelector("#search-results");
if (results) {
results.remove();
}

// Create a new search-as-you-type results container.
results = document.createElement("section");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gabalafou would appreciate your perspective on what is the best node type for an appearing/disappearing list of search results, and how this can/should/will interact with things like tab focus.

results.classList.add("search-results");
results.classList.add("empty");
// IMPORTANT: The search results container MUST have this exact ID.
// searchtools.js is hardcoded to populate into the node with this ID.
results.id = "search-results";
let modal = document.querySelector("#pst-search-dialog");
modal.appendChild(results);

// Get the relative path back to the root of the website.
const root =
"URL_ROOT" in DOCUMENTATION_OPTIONS
? DOCUMENTATION_OPTIONS.URL_ROOT // Sphinx v6 and earlier
: document.documentElement.dataset.content_root; // Sphinx v7 and later

// As Sphinx populates the search results, this observer makes sure that
// each URL is correct (i.e. doesn't 404).
const linkObserver = new MutationObserver(() => {
const links = Array.from(
document.querySelectorAll("#search-results .search a"),
);
// Check every link every time because the timing of when new results are
// added is unpredictable and it's not an expensive operation.
links.forEach((link) => {
// Don't use the link.href getter because the browser computes the href
// as a full URL. We need the relative URL that Sphinx generates.
const href = link.getAttribute("href");
if (href.startsWith(root)) {
// No work needed. The root has already been prepended to the href.
return;
}
link.href = `${root}${href}`;
});
});

// The node that linkObserver watches doesn't exist until the user types
// something into the search textbox. This second observer (resultsObserver)
// just waits for #search-results to exist and then registers
// linkObserver on it.
let isObserved = false;
const resultsObserver = new MutationObserver(() => {
if (isObserved) {
return;
}
const container = document.querySelector("#search-results .search");
if (!container) {
return;
}
linkObserver.observe(container, { childList: true });
isObserved = true;
});
resultsObserver.observe(results, { childList: true });
};

/*******************************************************************************
* Version Switcher
* Note that this depends on two variables existing that are defined in
Expand Down Expand Up @@ -857,6 +1008,7 @@ documentReady(addModeListener);
documentReady(scrollToActive);
documentReady(addTOCInteractivity);
documentReady(setupSearchButtons);
documentReady(setupSearchAsYouType);
documentReady(setupMobileSidebarKeyboardHandlers);

// Determining whether an element has scrollable content depends on stylesheets,
Expand Down
25 changes: 23 additions & 2 deletions src/pydata_sphinx_theme/assets/styles/components/_search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -93,29 +93,50 @@
z-index: $zindex-modal;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
transform: translate(-50%, -30%);
drammock marked this conversation as resolved.
Show resolved Hide resolved
right: 1rem;
margin-bottom: 0;
margin-top: 0.5rem;
width: 90%;
max-width: 800px;
background-color: transparent;
padding: $focus-ring-width;
border: none;
flex-direction: column;
height: 80vh;

&::backdrop {
background-color: black;
opacity: 0.5;
}

form.bd-search {
flex-grow: 1;
flex-grow: 0;

// Font and input text a bit bigger
svg,
input {
font-size: var(--pst-font-size-icon);
}
}

/* In pydata-sphinx-theme.js this container is appended below
* the query input node after the user types their search query.
* Search results are populated into this container using Sphinx's
* built-in, JS-powered local search tools. */
#search-results {
overflow-y: scroll;
background-color: var(--pst-color-background);
padding: 1em;

a {
color: var(--pst-color-link);
}

&.empty {
display: none;
}
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
{%- if last_updated %}
<meta name="docbuild:last-update" content="{{ last_updated | e }}"/>
{%- endif %}
{% if pagename == 'search' %}
{# Search tools are already loaded on search page. Don't load them twice. #}
{% else %}
{# Load Sphinx's built-in search tools so that our custom inline search
experience can work on any page. #}
<script src="{{ pathto('_static/searchtools.js', 1) | e }}"></script>
<script src="{{ pathto('_static/language_data.js', 1) | e }}"></script>
<script src="{{ pathto('searchindex.js', 1) | e }}"></script>
{% endif %}
{%- endblock extrahead %}
{% block body_tag %}
{# set up with scrollspy to update the toc as we scroll #}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ logo =
logo_link =
surface_warnings = True
back_to_top_button = True
search_as_you_type = False

# Template placement in theme layouts
navbar_start = navbar-logo
Expand Down
27 changes: 27 additions & 0 deletions tests/test_a11y.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,30 @@ def test_breadcrumb_expansion(page: Page, url_base: str) -> None:
expect(page.get_by_label("Breadcrumb").get_by_role("list")).to_contain_text(
"Update Sphinx configuration during the build"
)


@pytest.mark.a11y
def test_search_as_you_type(page: Page, url_base: str) -> None:
"""Search-as-you-type feature should support keyboard navigation.

When the search-as-you-type (inline search results) feature is enabled,
pressing Tab after entering a search query should focus the first inline
search result.
"""
page.set_viewport_size({"width": 1440, "height": 720})
page.goto(urljoin(url_base, "/examples/kitchen-sink/blocks.html"))
# Click the search textbox.
searchbox = page.locator("css=.navbar-header-items .search-button__default-text")
searchbox.click()
# Type a search query.
query_input = page.locator("css=#pst-search-dialog input[type=search]")
expect(query_input).to_be_visible()
query_input.type("test")
page.wait_for_timeout(301) # Search execution is debounced for 300 ms.
search_results = page.locator("css=#search-results")
expect(search_results).to_be_visible()
# Navigate into the inline search results.
query_input.press("Tab")
focused_element_actual_content = page.evaluate("document.activeElement.textContent")
focused_element_expected_content = "1. Test of no sidebar"
assert focused_element_actual_content == focused_element_expected_content
drammock marked this conversation as resolved.
Show resolved Hide resolved
Loading